From c23b938f7f32327aac91bb197c496e63d78c2679 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Tue, 3 Nov 2026 14:38:35 +0900 Subject: [PATCH] fix(engines): LibreOffice 26 version, output drain timeout, msiexec log cleanup - CopyVersions: LibreOffice 26's version.ini has no MsiProductVersion any more, so a user's own LibreOffice showed no version. Also read the product version of soffice.exe next to it. - ProcessRunner: a program can exit while one it started still holds the inherited output pipes (soffice.bin after soffice.exe). WaitForExitAsync also waits for the end of the output, so the run waited until its time limit and failed although the program had finished. Wait for the exit itself, then give the rest of the output 30 s at most and note in standard error when it is incomplete. - EngineInstaller: delete the verbose msiexec log after a successful unpack; a failed one keeps it, since the error names it. Co-authored-by: jojofox0815 <125126720+jojofox0815@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../Infrastructure/CopyVersions.cs | 7 ++- .../Infrastructure/EngineInstaller.cs | 8 ++++ .../Infrastructure/ProcessRunner.cs | 45 +++++++++++++++++-- tests/Filee.Engines.Tests/OwnCopyTests.cs | 24 ++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/Filee.Engines/Infrastructure/CopyVersions.cs b/src/Filee.Engines/Infrastructure/CopyVersions.cs index b8c942988..5c0fe5440 100644 --- a/src/Filee.Engines/Infrastructure/CopyVersions.cs +++ b/src/Filee.Engines/Infrastructure/CopyVersions.cs @@ -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 { diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs index 4e4a7d864..2f47e6949 100644 --- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs +++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs @@ -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) + { + } } /// What the Engines card shows: the reasons a user can act on in their language (error.* keys). diff --git a/src/Filee.Engines/Infrastructure/ProcessRunner.cs b/src/Filee.Engines/Infrastructure/ProcessRunner.cs index 55297a818..1881c9906 100644 --- a/src/Filee.Engines/Infrastructure/ProcessRunner.cs +++ b/src/Filee.Engines/Infrastructure/ProcessRunner.cs @@ -78,7 +78,7 @@ public static async Task RunAsync( timeoutCts.CancelAfter(timeout); try { - var exited = process.WaitForExitAsync(timeoutCts.Token); + var exited = WaitForExitOnlyAsync(process, timeoutCts.Token); if (watch is null) await exited; else if (!await watch.WaitAsync(exited, timeoutCts.Token)) @@ -95,15 +95,54 @@ public static async Task RunAsync( } // 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()); } /// "10 min", or "30 s" below a minute. internal static string Describe(TimeSpan limit) => limit >= TimeSpan.FromMinutes(1) ? $"{limit.TotalMinutes:0} min" : $"{limit.TotalSeconds:0} s"; + /// How long the rest of the output may take once the program has exited. + private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(30); + + /// + /// Waits for the program itself to exit. also waits for the end of its + /// output, which a program it started can hold open long after (soffice.bin after soffice.exe): see + /// . + /// + 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); + } + + /// + /// Waits for the rest of the output after the program exited, for 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. + /// + 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)"); + } + } + /// /// 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 diff --git a/tests/Filee.Engines.Tests/OwnCopyTests.cs b/tests/Filee.Engines.Tests/OwnCopyTests.cs index cac063e11..1ae8eb6fc 100644 --- a/tests/Filee.Engines.Tests/OwnCopyTests.cs +++ b/tests/Filee.Engines.Tests/OwnCopyTests.cs @@ -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"));