From cda1e7a9c78c58ed2f9c9e78255321c0dfd4bce2 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Sat, 11 Jul 2026 21:15:47 +0200 Subject: [PATCH 1/4] fix: fail critical output validation --- .../AudioValidationServiceTests.cs | 38 +++++++++++++++++++ Services/AudioValidationService.cs | 9 ++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs index b54534d..e6165dc 100644 --- a/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs +++ b/AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs @@ -79,6 +79,30 @@ public void BuildReport_LowHeadroomOutputIsWarning() Assert.Contains(report.Findings, finding => finding.Kind == AudioComparisonFindingKind.LowHeadroom); } + [Fact] + public void CreateValidationResult_CriticalReportFailsAndPreservesReport() + { + var report = CreateComparisonReport(AudioComparisonStatus.Critical); + + var result = AudioValidationService.CreateValidationResult(report); + + Assert.True(result.IsFailure); + Assert.Same(report, result.Value); + Assert.Equal(report.Summary, result.ErrorMessage); + } + + [Fact] + public void CreateValidationResult_WarningReportSucceedsAndPreservesReport() + { + var report = CreateComparisonReport(AudioComparisonStatus.Warning); + + var result = AudioValidationService.CreateValidationResult(report); + + Assert.True(result.IsSuccess); + Assert.Same(report, result.Value); + Assert.Null(result.ErrorMessage); + } + [Fact] public void BuildReport_DurationMismatchIsCriticalWhenLarge() { @@ -350,6 +374,20 @@ private static ProcessingOptions CreateOptions(AudioInfo sourceInfo, AudioPreset }; } + private static AudioComparisonReport CreateComparisonReport(AudioComparisonStatus status) + { + return new AudioComparisonReport( + status, + status.ToString(), + $"{status} summary", + @"C:\audio\output.flac", + outputInfo: null, + outputDiagnostics: null, + findings: Array.Empty(), + metrics: Array.Empty(), + outputDiagnosticsSkipped: false); + } + private static AudioInfo CreateInfo( string codec, bool isLossy, diff --git a/Services/AudioValidationService.cs b/Services/AudioValidationService.cs index dcbf9a6..aeb8dbe 100644 --- a/Services/AudioValidationService.cs +++ b/Services/AudioValidationService.cs @@ -88,7 +88,14 @@ public async Task> ValidateAsync( outputPath); _logService.Info(LocalizationService.Instance.Format("Log_ValidationCompleteFormat", reportResult.StatusText)); - return Result.Success(reportResult); + return CreateValidationResult(reportResult); + } + + internal static Result CreateValidationResult(AudioComparisonReport report) + { + return report.Status == AudioComparisonStatus.Critical + ? Result.Failure(report.Summary, value: report) + : Result.Success(report); } internal static Result ValidateOutputFile(string outputPath) From e35507e2c54d6f4345221dbe0cac4c6fb435c366 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Sat, 11 Jul 2026 21:16:13 +0200 Subject: [PATCH 2/4] fix: wait for terminated media processes --- Services/ProcessRunner.cs | 103 ++++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs index 7d586f2..9121fda 100644 --- a/Services/ProcessRunner.cs +++ b/Services/ProcessRunner.cs @@ -6,6 +6,9 @@ namespace AudioQualityEnhancer.Services; internal sealed class ProcessRunner : IProcessRunner { + private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan ReaderDrainTimeout = TimeSpan.FromSeconds(2); + public async Task RunAsync(ProcessRunOptions options, CancellationToken cancellationToken) { using var process = new Process(); @@ -66,6 +69,10 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio }; using var watchdogStop = new CancellationTokenSource(); + using var timeoutCancellation = new CancellationTokenSource(); + using var exitCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCancellation.Token); Task? watchdogTask = null; try @@ -80,27 +87,39 @@ public async Task RunAsync(ProcessRunOptions options, Cancellatio process, inactivityTimeout, () => Interlocked.Read(ref lastActivityTimestamp), - () => Interlocked.Exchange(ref timedOutFlag, 1), + () => + { + Interlocked.Exchange(ref timedOutFlag, 1); + timeoutCancellation.Cancel(); + }, watchdogStop.Token); } - await process.WaitForExitAsync(cancellationToken); - await Task.WhenAll(outputClosed.Task, errorClosed.Task); + await process.WaitForExitAsync(exitCancellation.Token).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); return CreateResult(process, stdout, stderr, startedAt, wasCancelled: false, timedOut: timedOutFlag == 1); } catch (OperationCanceledException) { + var timedOut = timedOutFlag == 1; TryKill(process); - await TryWaitForReadersAsync(outputClosed.Task, errorClosed.Task); - return CreateResult(process, stdout, stderr, startedAt, wasCancelled: true, timedOut: timedOutFlag == 1); + await TryWaitForExitAsync(process).ConfigureAwait(false); + await TryWaitForReadersAsync(process, outputClosed.Task, errorClosed.Task).ConfigureAwait(false); + return CreateResult( + process, + stdout, + stderr, + startedAt, + wasCancelled: !timedOut && cancellationToken.IsCancellationRequested, + timedOut); } finally { watchdogStop.Cancel(); if (watchdogTask is not null) { - await watchdogTask; + await watchdogTask.ConfigureAwait(false); } } } @@ -121,7 +140,12 @@ private static async Task WatchForInactivityAsync( { while (!stopToken.IsCancellationRequested) { - await Task.Delay(interval, stopToken); + await Task.Delay(interval, stopToken).ConfigureAwait(false); + if (HasExited(process)) + { + return; + } + var idleTime = Stopwatch.GetElapsedTime(getLastActivityTimestamp()); if (idleTime >= timeout) { @@ -147,13 +171,21 @@ private static ProcessResult CreateResult( { return new ProcessResult( TryGetExitCode(process, wasCancelled || timedOut ? -1 : 0), - stdout.ToString(), - stderr.ToString(), + Snapshot(stdout), + Snapshot(stderr), DateTimeOffset.Now - startedAt, wasCancelled, TimedOut: timedOut); } + private static string Snapshot(StringBuilder value) + { + lock (value) + { + return value.ToString(); + } + } + private static int TryGetExitCode(Process process, int fallback) { try @@ -166,16 +198,65 @@ private static int TryGetExitCode(Process process, int fallback) } } - private static async Task TryWaitForReadersAsync(Task outputClosed, Task errorClosed) + private static async Task TryWaitForExitAsync(Process process) + { + var startedAt = Stopwatch.GetTimestamp(); + while (!HasExited(process) && Stopwatch.GetElapsedTime(startedAt) < TerminationWaitTimeout) + { + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + } + + private static async Task TryWaitForReadersAsync(Process process, Task outputClosed, Task errorClosed) { try { - await Task.WhenAll(outputClosed, errorClosed).WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(outputClosed, errorClosed).WaitAsync(ReaderDrainTimeout).ConfigureAwait(false); + return; } catch { // Best effort drain after cancellation. } + + TryCancelOutputRead(process); + TryCancelErrorRead(process); + } + + private static bool HasExited(Process process) + { + try + { + return process.HasExited; + } + catch + { + return true; + } + } + + private static void TryCancelOutputRead(Process process) + { + try + { + process.CancelOutputRead(); + } + catch + { + // The reader is already closed or was never started. + } + } + + private static void TryCancelErrorRead(Process process) + { + try + { + process.CancelErrorRead(); + } + catch + { + // The reader is already closed or was never started. + } } private static void TryKill(Process process) From 392e3a71815f564c2a8bb94ddd46fe1c5fbae539 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Sat, 11 Jul 2026 21:16:41 +0200 Subject: [PATCH 3/4] fix: require explicit release package version --- README.md | 8 ++++---- scripts/package-release.ps1 | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1e26d96..be16b24 100644 --- a/README.md +++ b/README.md @@ -170,19 +170,19 @@ Die Tests prüfen Parser, Dateilogik, Batch-Logik, Analyse-Bewertung, FFmpeg-Arg Portable ZIP ohne FFmpeg: ```powershell -.\scripts\package-release.ps1 -Version 0.16.0 +.\scripts\package-release.ps1 -Version ``` Portable ZIP mit FFmpeg und FFprobe: ```powershell -.\scripts\package-release.ps1 -Version 0.16.0 -IncludeFFmpeg +.\scripts\package-release.ps1 -Version -IncludeFFmpeg ``` Pakete prüfen: ```powershell -.\scripts\verify-release-package.ps1 -Version 0.16.0 -RequireFFmpegPackage +.\scripts\verify-release-package.ps1 -Version -RequireFFmpegPackage ``` Die fertigen ZIP-Dateien liegen danach in `artifacts`. Zu jedem ZIP wird eine passende `.sha256.txt`-Datei erzeugt. @@ -190,7 +190,7 @@ Die fertigen ZIP-Dateien liegen danach in `artifacts`. Zu jedem ZIP wird eine pa Checksum eines Downloads prüfen: ```powershell -Get-FileHash .\AudioQualityEnhancer-0.16.0-win-x64.zip -Algorithm SHA256 +Get-FileHash .\AudioQualityEnhancer--win-x64.zip -Algorithm SHA256 ``` Der angezeigte SHA256-Wert muss zum Inhalt der passenden `.sha256.txt`-Datei im Release passen. diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index a43c353..7bc805c 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -1,5 +1,7 @@ param( - [string]$Version = "0.2.0", + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version, [string]$Runtime = "win-x64", [switch]$IncludeFFmpeg ) From 5de3c35241f4bb56785d04e037677f8e61f99ad7 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Tue, 11 Aug 2026 23:38:20 +0200 Subject: [PATCH 4/4] Document the pipeline lifecycle fixes in the changelog The three fixes on this branch are user visible: a critical validation now fails the file, a stopped run is reported as timeout or cancellation according to what actually happened, and the release script no longer falls back to a stale default version. --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06feeb7..66dd35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## 0.17.0 - 2026-06-13 +## Unreleased + +### Fixed + +- A result validation that ends with a critical finding now fails the affected file instead of reporting it as exported successfully. The report stays attached to the item, so the findings remain visible in the validation panel. +- A run that was stopped is no longer misreported: an inactivity timeout now ends the wait for the process directly and is reported as a timeout, while only an actual user cancellation is reported as one. +- After a process is terminated the app waits briefly for it to exit and stops the output readers before reading what was captured, so the collected output can no longer be read while it is still being written. + +### Changed + +- The release packaging script now requires an explicit `-Version` in `major.minor.patch` form instead of falling back to a stale default version. ### Added