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
7 changes: 6 additions & 1 deletion src/Filee.Engines/Infrastructure/CopyVersions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ public static bool IsOutdated(string id, string folder) =>
private static string Read(string id, string program)
{
if (id == "libreoffice" && OperatingSystem.IsWindows())
return File.ReadAllText(program);
{
// LibreOffice 26 dropped MsiProductVersion from version.ini; soffice.exe next to it still says it.
var soffice = Path.Combine(Path.GetDirectoryName(program)!, "soffice.exe");
var product = File.Exists(soffice) ? FileVersionInfo.GetVersionInfo(soffice).ProductVersion : null;
return File.ReadAllText(program) + (product is null ? "" : $"\nLibreOffice {product}");
}
var arguments = id == "ffmpeg" ? "-hide_banner -version" : "--version";
try
{
Expand Down
8 changes: 8 additions & 0 deletions src/Filee.Engines/Infrastructure/EngineInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,14 @@ private async Task UnpackMsiAsync(string id, string msi, string target, string l
}
if (process.ExitCode != 0)
throw MsiFailure(id, process.ExitCode, log);
// The verbose log is only worth keeping when unpacking failed (the error names it); else it would stay in %TEMP%.
try
{
File.Delete(log);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
}

/// <summary>What the Engines card shows: the reasons a user can act on in their language (error.* keys).</summary>
Expand Down
47 changes: 44 additions & 3 deletions src/Filee.Engines/Infrastructure/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public static async Task<ProcessResult> RunAsync(
var finishedEarly = false;
try
{
var exited = process.WaitForExitAsync(timeoutCts.Token);
var exited = WaitForExitOnlyAsync(process, timeoutCts.Token);
if (watch is null && outputReady is not null && await WatchOutputAsync(outputReady, exited, timeoutCts.Token))
{
TryKill(process, job);
Expand Down Expand Up @@ -123,10 +123,14 @@ public static async Task<ProcessResult> RunAsync(
return new ProcessResult(0, stdout.ToString(), stderr.ToString());
}

// The program exited; the ones it started that still run (and may hold the output) end with the job.
job?.Terminate();
// Make sure the async output readers have drained.
process.WaitForExit();
await DrainAsync(process, executable, stderr, cancellationToken);
AppLocalRuntime.ThrowIfMissing(executable, process.ExitCode);
return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString());
lock (stdout)
lock (stderr)
return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString());
}

/// <summary>
Expand Down Expand Up @@ -160,6 +164,43 @@ private static async Task<bool> WatchOutputAsync(Func<bool> ready, Task exited,
internal static string Describe(TimeSpan limit) =>
limit >= TimeSpan.FromMinutes(1) ? $"{limit.TotalMinutes:0} min" : $"{limit.TotalSeconds:0} s";

/// <summary>How long the rest of the output may take once the program has exited.</summary>
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(30);

/// <summary>
/// Waits for the program itself to exit. <see cref="Process.WaitForExitAsync"/> also waits for the end of its
/// output, which a program it started can hold open long after (soffice.bin after soffice.exe): see
/// <see cref="DrainAsync"/>.
/// </summary>
private static async Task WaitForExitOnlyAsync(Process process, CancellationToken cancellationToken)
{
var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
process.EnableRaisingEvents = true;
process.Exited += (_, _) => exited.TrySetResult();
if (process.HasExited)
exited.TrySetResult();
await exited.Task.WaitAsync(cancellationToken);
}

/// <summary>
/// Waits for the rest of the output after the program exited, for <see cref="DrainTimeout"/> at most, rather than
/// block the conversion until its time limit: only the rest of the output is lost, with a note in standard error.
/// </summary>
private static async Task DrainAsync(Process process, string executable, StringBuilder stderr, CancellationToken cancellationToken)
{
using var drain = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
drain.CancelAfter(DrainTimeout);
try
{
await process.WaitForExitAsync(drain.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
lock (stderr)
stderr.AppendLine($"(output not complete: a program {Path.GetFileName(executable)} started still holds it)");
}
}

/// <summary>
/// Below normal priority, so a long conversion on every core leaves room for the desktop and Filee's own input
/// hook (#511); on an idle machine it is as fast. Programs it starts inherit it. A process that is already gone
Expand Down
24 changes: 24 additions & 0 deletions tests/Filee.Engines.Tests/OwnCopyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,33 @@ public void LibreOffice_is_found_in_the_folder_its_installer_recorded()
[InlineData("ghostscript", "10.04.0\r\n", "10.4")]
[InlineData("libreoffice", "[Version]\nAllLanguages=en-US\nMsiProductVersion=25.2.4.3\n", "25.2")]
[InlineData("libreoffice", "LibreOffice 26.2.6.2 98d5f8\n", "26.2")]
// LibreOffice 26.8's version.ini has no MsiProductVersion; Installed adds soffice.exe's product version to it.
[InlineData("libreoffice", "[Version]\nUpdateID=LibreOffice_26_en-US_de\nVendor=The Document Foundation\n", null)]
[InlineData("libreoffice", "[Version]\nUpdateID=LibreOffice_26_en-US_de\nVendor=The Document Foundation\nLibreOffice 26.8.1.1", "26.8")]
public void Versions_are_read_from_each_program(string id, string output, string? expected) =>
Assert.Equal(expected is null ? null : Version.Parse(expected), CopyVersions.ParseVersion(id, output));

[Fact]
public void A_libreoffice_without_msi_version_is_told_by_its_program()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "version.ini is read on Windows only");
// Any program with a version resource stands in for soffice.exe.
var program = Environment.ProcessPath!;
var folder = Directory.CreateTempSubdirectory("filee-lo-version-").FullName;
try
{
File.WriteAllText(Path.Combine(folder, "version.ini"), "[Version]\nVendor=The Document Foundation\n");
File.Copy(program, Path.Combine(folder, "soffice.exe"));
var product = System.Diagnostics.FileVersionInfo.GetVersionInfo(program);

Assert.Equal(new Version(product.ProductMajorPart, product.ProductMinorPart), CopyVersions.Installed("libreoffice", folder));
}
finally
{
Directory.Delete(folder, true);
}
}

[Fact]
public void Expected_versions_come_from_engines_json() =>
Assert.NotNull(CopyVersions.Expected("ffmpeg"));
Expand Down
44 changes: 35 additions & 9 deletions tests/Filee.Engines.Tests/ProcessRunnerOutputTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// The output probe for a program that leaves a child holding Filee's output pipes (#1011): soffice.exe exits, but
// soffice.bin keeps the inherited pipes open, so waiting for the end of the output lasts until the timeout.
// A program that leaves a child holding Filee's output pipes (#1011): soffice.exe exits, but soffice.bin keeps the
// inherited pipes open, so waiting for the end of the output would last until the timeout. And the output probe, for
// a program that finished its file but doesn't exit.

using System.Diagnostics;
using Filee.Engines.Infrastructure;
Expand All @@ -17,34 +18,59 @@ public sealed class ProcessRunnerOutputTests : IDisposable
public void Dispose() => Directory.Delete(_root, recursive: true);

[Fact]
public async Task A_finished_PDF_ends_the_run_and_the_child_that_holds_the_output()
public async Task A_finished_PDF_ends_a_run_that_goes_on()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "cmd.exe and a Job Object.");
// The child writes its process id and waits a minute with the script's output pipes, which it inherits like
// soffice.bin; the script writes a complete PDF and exits.
// The script writes a complete PDF and then waits a minute, like a soffice.exe that doesn't exit.
var script = Script("""
@echo off
start /b %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command "[IO.File]::WriteAllText('%~dp0child.pid', $PID); Start-Sleep -Seconds 60"
>"%~dp0out\out.pdf" echo %%PDF-1.4
>>"%~dp0out\out.pdf" echo %%%%EOF
ping -n 60 127.0.0.1 >nul
""");
var pidFile = Path.Combine(_root, "child.pid");
var complete = LibreOfficeConverter.OutputComplete(Output);
var held = 0;
var started = Stopwatch.StartNew();

var result = await ProcessRunner.RunAsync(Cmd, ["/c", script], TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken,
outputReady: () =>
{
var ready = File.Exists(pidFile) && complete();
var ready = complete();
if (ready)
held++;
return ready;
});

Assert.Equal(0, result.ExitCode);
Assert.True(held >= 4, $"the run ended by itself after {held} ready polls: the child did not hold the output");
Assert.True(held >= 4, $"the run ended by itself after {held} ready polls");
Assert.True(started.Elapsed < TimeSpan.FromSeconds(20), $"took {started.Elapsed}");
}

[Fact]
public async Task A_child_that_holds_the_output_ends_with_the_run()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "cmd.exe and a Job Object.");
// The child writes its process id and waits a minute with the script's output pipes, which it inherits like
// soffice.bin; the script writes a complete PDF and exits.
var script = Script("""
@echo off
start /b %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command "[IO.File]::WriteAllText('%~dp0child.pid', $PID); Start-Sleep -Seconds 60"
>"%~dp0out\out.pdf" echo %%PDF-1.4
>>"%~dp0out\out.pdf" echo %%%%EOF
""");
var pidFile = Path.Combine(_root, "child.pid");
var complete = LibreOfficeConverter.OutputComplete(Output);
var started = Stopwatch.StartNew();

var result = await ProcessRunner.RunAsync(Cmd, ["/c", script], TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken,
outputReady: () => File.Exists(pidFile) && complete());

Assert.Equal(0, result.ExitCode);
// Under the 30 s the rest of the output may take: the job ended the child, so the output ended too.
Assert.True(started.Elapsed < TimeSpan.FromSeconds(20), $"took {started.Elapsed}");
Assert.DoesNotContain("output not complete", result.StandardError);
if (!File.Exists(pidFile))
return; // The script exited before the child had started; the job ended it all the same.
var pid = int.Parse(File.ReadAllText(pidFile).Trim());
try
{
Expand Down
Loading