Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions AudioQualityEnhancer.Tests/AudioValidationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<AudioComparisonFinding>(),
metrics: Array.Empty<AudioComparisonMetric>(),
outputDiagnosticsSkipped: false);
}

private static AudioInfo CreateInfo(
string codec,
bool isLossy,
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# Changelog

## 0.17.0 - 2026-06-13
## Unreleased

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the released 0.17.0 changelog section

Replacing the 0.17.0 heading with Unreleased removes that released version from the changelog and incorrectly groups its existing update-check and drag-and-drop entries with the new fixes. Add a separate Unreleased section above the original ## 0.17.0 - 2026-06-13 heading so the published release history remains accurate.

Useful? React with 👍 / 👎.


### 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

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,27 +170,27 @@ 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 <version>
```

Portable ZIP mit FFmpeg und FFprobe:

```powershell
.\scripts\package-release.ps1 -Version 0.16.0 -IncludeFFmpeg
.\scripts\package-release.ps1 -Version <version> -IncludeFFmpeg
```

Pakete prüfen:

```powershell
.\scripts\verify-release-package.ps1 -Version 0.16.0 -RequireFFmpegPackage
.\scripts\verify-release-package.ps1 -Version <version> -RequireFFmpegPackage
```

Die fertigen ZIP-Dateien liegen danach in `artifacts`. Zu jedem ZIP wird eine passende `.sha256.txt`-Datei erzeugt.

Checksum eines Downloads prüfen:

```powershell
Get-FileHash .\AudioQualityEnhancer-0.16.0-win-x64.zip -Algorithm SHA256
Get-FileHash .\AudioQualityEnhancer-<version>-win-x64.zip -Algorithm SHA256
```

Der angezeigte SHA256-Wert muss zum Inhalt der passenden `.sha256.txt`-Datei im Release passen.
Expand Down
9 changes: 8 additions & 1 deletion Services/AudioValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,14 @@ public async Task<Result<AudioComparisonReport>> ValidateAsync(
outputPath);

_logService.Info(LocalizationService.Instance.Format("Log_ValidationCompleteFormat", reportResult.StatusText));
return Result<AudioComparisonReport>.Success(reportResult);
return CreateValidationResult(reportResult);
}

internal static Result<AudioComparisonReport> CreateValidationResult(AudioComparisonReport report)
{
return report.Status == AudioComparisonStatus.Critical
? Result<AudioComparisonReport>.Failure(report.Summary, value: report)
: Result<AudioComparisonReport>.Success(report);
}

internal static Result ValidateOutputFile(string outputPath)
Expand Down
103 changes: 92 additions & 11 deletions Services/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessResult> RunAsync(ProcessRunOptions options, CancellationToken cancellationToken)
{
using var process = new Process();
Expand Down Expand Up @@ -66,6 +69,10 @@ public async Task<ProcessResult> 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
Expand All @@ -80,27 +87,39 @@ public async Task<ProcessResult> 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);
}
}
}
Expand All @@ -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)
{
Expand All @@ -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
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion scripts/package-release.ps1
Original file line number Diff line number Diff line change
@@ -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
)
Expand Down