From 4634c48043af28cac73e67547429441f7817da20 Mon Sep 17 00:00:00 2001 From: filzrev <103790468+filzrev@users.noreply.github.com> Date: Sun, 15 Dec 2024 00:08:43 +0900 Subject: [PATCH 1/2] chore: add cancellation support for PdfBuilder --- src/Docfx.App/PdfBuilder.cs | 73 ++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/src/Docfx.App/PdfBuilder.cs b/src/Docfx.App/PdfBuilder.cs index c5a40cf603c..819d944c02c 100644 --- a/src/Docfx.App/PdfBuilder.cs +++ b/src/Docfx.App/PdfBuilder.cs @@ -51,17 +51,17 @@ class Outline public string? pdfFooterTemplate { get; init; } } - public static Task Run(BuildJsonConfig config, string configDirectory, string? outputDirectory = null) + public static Task Run(BuildJsonConfig config, string configDirectory, string? outputDirectory = null, CancellationToken cancellationToken = default) { var outputFolder = Path.GetFullPath(Path.Combine( string.IsNullOrEmpty(outputDirectory) ? Path.Combine(configDirectory, config.Output ?? "") : outputDirectory, config.Dest ?? "")); Logger.LogInfo($"Searching for manifest in {outputFolder}"); - return CreatePdf(outputFolder); + return CreatePdf(outputFolder, cancellationToken); } - public static async Task CreatePdf(string outputFolder) + public static async Task CreatePdf(string outputFolder, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var pdfTocs = GetPdfTocs().ToDictionary(p => p.url, p => p.toc); @@ -82,7 +82,7 @@ public static async Task CreatePdf(string outputFolder) using var app = builder.Build(); app.UseServe(outputFolder); app.MapGet("/_pdftoc/{*url}", TocPage); - await app.StartAsync(); + await app.StartAsync(cancellationToken); baseUrl = new Uri(app.Urls.First()); @@ -100,25 +100,53 @@ public static async Task CreatePdf(string outputFolder) var headerFooterTemplateCache = new ConcurrentDictionary(); var headerFooterPageCache = new ConcurrentDictionary<(string, string), Task>(); - await AnsiConsole.Progress().StartAsync(async progress => + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var ctr = SubscribeCancelKeyPressEvent(cts); + + var pdfBuildTask = AnsiConsole.Progress().StartAsync(async progress => { - await Parallel.ForEachAsync(pdfTocs, async (item, _) => + await Parallel.ForEachAsync(pdfTocs, new ParallelOptions { CancellationToken = cts.Token }, async (item, _) => { var (url, toc) = item; var outputName = Path.Combine(Path.GetDirectoryName(url) ?? "", toc.pdfFileName ?? Path.ChangeExtension(Path.GetFileName(url), ".pdf")); var task = progress.AddTask(outputName); - var outputPath = Path.Combine(outputFolder, outputName); + var pdfOutputPath = Path.Combine(outputFolder, outputName); await CreatePdf( - PrintPdf, PrintHeaderFooter, task, new(baseUrl, url), toc, outputFolder, outputPath, - pageNumbers => pdfPageNumbers[url] = pageNumbers); + PrintPdf, PrintHeaderFooter, task, new(baseUrl, url), toc, outputFolder, pdfOutputPath, + pageNumbers => pdfPageNumbers[url] = pageNumbers, + cts.Token); task.Value = task.MaxValue; task.StopTask(); }); }); + // Wait pdfBuildTask completed or cancelled. + await Task.WhenAny(pdfBuildTask, Task.Delay(Timeout.Infinite, cts.Token)); + + if (!pdfBuildTask.IsCompletedSuccessfully) + { + // So manually close playwright context and browser to immeadiately shutdown running task. + if (!pdfBuildTask.IsCompleted) + { + await context.CloseAsync(); + await browser.CloseAsync(); + } + + try + { + await pdfBuildTask; + } + catch (OperationCanceledException) + { + Logger.LogError($"PDF file generation is canceled by user interaction."); + return; + } + } + Logger.LogVerbose($"PDF done in {stopwatch.Elapsed}"); + return; IEnumerable<(string url, Outline toc)> GetPdfTocs() { @@ -150,7 +178,7 @@ IResult TocPage(string url) async Task PrintPdf(Outline outline, Uri url) { - await pageLimiter.WaitAsync(); + await pageLimiter.WaitAsync(cancellationToken); var page = pagePool.TryTake(out var pooled) ? pooled : await context.NewPageAsync(); try @@ -273,7 +301,7 @@ static string ExpandTemplate(string? pdfTemplate, int pageNumber, int totalPages static async Task CreatePdf( Func> printPdf, Func> printHeaderFooter, ProgressTask task, - Uri outlineUrl, Outline outline, string outputFolder, string outputPath, Action> updatePageNumbers) + Uri outlineUrl, Outline outline, string outputFolder, string pdfOutputPath, Action> updatePageNumbers, CancellationToken cancellationToken) { var pages = GetPages(outline).ToArray(); if (pages.Length == 0) @@ -284,7 +312,7 @@ static async Task CreatePdf( // Make progress at 99% before merge PDF task.MaxValue = pages.Length + (pages.Length / 99.0); - await Parallel.ForEachAsync(pages, async (item, _) => + await Parallel.ForEachAsync(pages, new ParallelOptions { CancellationToken = cancellationToken }, async (item, _) => { var (url, node) = item; if (await printPdf(outline, url) is { } bytes) @@ -302,6 +330,8 @@ await Parallel.ForEachAsync(pages, async (item, _) => foreach (var (url, node) in pages) { + cancellationToken.ThrowIfCancellationRequested(); + if (!pageBytes.TryGetValue(node, out var bytes)) continue; @@ -324,13 +354,14 @@ await Parallel.ForEachAsync(pages, async (item, _) => var producer = $"docfx ({typeof(PdfBuilder).Assembly.GetCustomAttribute()?.Version})"; - using var output = File.Create(outputPath); + using var output = File.Create(pdfOutputPath); using var builder = new PdfDocumentBuilder(output); builder.DocumentInformation = new() { Producer = producer }; builder.Bookmarks = CreateBookmarks(outline.items); await MergePdf(); + return; IEnumerable<(Uri url, Outline node)> GetPages(Outline outline) { @@ -368,6 +399,8 @@ async Task MergePdf() foreach (var (url, node) in pages) { + cancellationToken.ThrowIfCancellationRequested(); + if (!pageBytes.TryGetValue(node, out var bytes)) continue; @@ -387,6 +420,8 @@ async Task MergePdf() using var document = PdfDocument.Open(bytes); for (var i = 1; i <= document.NumberOfPages; i++) { + cancellationToken.ThrowIfCancellationRequested(); + pageNumber++; var pageBuilder = builder.AddPage(document, i, x => CopyLink(node, x)); @@ -655,4 +690,16 @@ private static StringComparison GetStringComparison() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } + + private static CancellationTokenRegistration SubscribeCancelKeyPressEvent(CancellationTokenSource cts) + { + void onCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + cts.Cancel(); + } + + Console.CancelKeyPress += onCancelKeyPress; + return cts.Token.Register(() => Console.CancelKeyPress -= onCancelKeyPress); + } } From 253d00197b536c86045f8d2abb34760c1771e42c Mon Sep 17 00:00:00 2001 From: filzrev <103790468+filzrev@users.noreply.github.com> Date: Thu, 26 Dec 2024 06:55:33 +0900 Subject: [PATCH 2/2] chore: add CancellableCommand --- src/Docfx.App/PdfBuilder.cs | 50 +++++++------------ src/docfx/Models/CancellableCommandBase.cs | 33 ++++++++++++ src/docfx/Models/DefaultCommand.cs | 8 +-- src/docfx/Models/PdfCommand.cs | 9 ++-- .../XRefMapSerializationTest.cs | 1 - 5 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 src/docfx/Models/CancellableCommandBase.cs diff --git a/src/Docfx.App/PdfBuilder.cs b/src/Docfx.App/PdfBuilder.cs index 819d944c02c..b31e3704854 100644 --- a/src/Docfx.App/PdfBuilder.cs +++ b/src/Docfx.App/PdfBuilder.cs @@ -100,12 +100,9 @@ public static async Task CreatePdf(string outputFolder, CancellationToken cancel var headerFooterTemplateCache = new ConcurrentDictionary(); var headerFooterPageCache = new ConcurrentDictionary<(string, string), Task>(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var ctr = SubscribeCancelKeyPressEvent(cts); - var pdfBuildTask = AnsiConsole.Progress().StartAsync(async progress => { - await Parallel.ForEachAsync(pdfTocs, new ParallelOptions { CancellationToken = cts.Token }, async (item, _) => + await Parallel.ForEachAsync(pdfTocs, new ParallelOptions { CancellationToken = cancellationToken }, async (item, _) => { var (url, toc) = item; var outputName = Path.Combine(Path.GetDirectoryName(url) ?? "", toc.pdfFileName ?? Path.ChangeExtension(Path.GetFileName(url), ".pdf")); @@ -115,33 +112,34 @@ public static async Task CreatePdf(string outputFolder, CancellationToken cancel await CreatePdf( PrintPdf, PrintHeaderFooter, task, new(baseUrl, url), toc, outputFolder, pdfOutputPath, pageNumbers => pdfPageNumbers[url] = pageNumbers, - cts.Token); + cancellationToken); task.Value = task.MaxValue; task.StopTask(); }); }); - // Wait pdfBuildTask completed or cancelled. - await Task.WhenAny(pdfBuildTask, Task.Delay(Timeout.Infinite, cts.Token)); - - if (!pdfBuildTask.IsCompletedSuccessfully) + try + { + await pdfBuildTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) { - // So manually close playwright context and browser to immeadiately shutdown running task. if (!pdfBuildTask.IsCompleted) { + // If pdf generation task is not completed. + // Manually close playwright context/browser to immediately shutdown remaining tasks. await context.CloseAsync(); await browser.CloseAsync(); - } - - try - { - await pdfBuildTask; - } - catch (OperationCanceledException) - { - Logger.LogError($"PDF file generation is canceled by user interaction."); - return; + try + { + await pdfBuildTask; // Wait AnsiConsole.Progress operation completed to output logs. + } + catch + { + Logger.LogError($"PDF file generation is canceled by user interaction."); + return; + } } } @@ -690,16 +688,4 @@ private static StringComparison GetStringComparison() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } - - private static CancellationTokenRegistration SubscribeCancelKeyPressEvent(CancellationTokenSource cts) - { - void onCancelKeyPress(object? sender, ConsoleCancelEventArgs e) - { - e.Cancel = true; - cts.Cancel(); - } - - Console.CancelKeyPress += onCancelKeyPress; - return cts.Token.Register(() => Console.CancelKeyPress -= onCancelKeyPress); - } } diff --git a/src/docfx/Models/CancellableCommandBase.cs b/src/docfx/Models/CancellableCommandBase.cs new file mode 100644 index 00000000000..471bb9556e7 --- /dev/null +++ b/src/docfx/Models/CancellableCommandBase.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Runtime.InteropServices; +using Spectre.Console.Cli; + +namespace Docfx; + +public abstract class CancellableCommandBase : Command + where TSettings : CommandSettings +{ + public abstract int Execute(CommandContext context, TSettings settings, CancellationToken cancellation); + + public sealed override int Execute(CommandContext context, TSettings settings) + { + using var cancellationSource = new CancellationTokenSource(); + + using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, onSignal); + using var sigQuit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, onSignal); + using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, onSignal); + + var exitCode = Execute(context, settings, cancellationSource.Token); + return exitCode; + + void onSignal(PosixSignalContext context) + { + context.Cancel = true; + cancellationSource.Cancel(); + } + } +} diff --git a/src/docfx/Models/DefaultCommand.cs b/src/docfx/Models/DefaultCommand.cs index fd21157e4be..d026f949f5b 100644 --- a/src/docfx/Models/DefaultCommand.cs +++ b/src/docfx/Models/DefaultCommand.cs @@ -10,7 +10,7 @@ namespace Docfx; -class DefaultCommand : Command +class DefaultCommand : CancellableCommandBase { [Description("Runs metadata, build and pdf commands")] internal class Options : BuildCommandOptions @@ -20,7 +20,7 @@ internal class Options : BuildCommandOptions public bool Version { get; set; } } - public override int Execute(CommandContext context, Options options) + public override int Execute(CommandContext context, Options options, CancellationToken cancellationToken) { if (options.Version) { @@ -48,9 +48,9 @@ public override int Execute(CommandContext context, Options options) if (config.build is not null) { BuildCommand.MergeOptionsToConfig(options, config.build, configDirectory); - serveDirectory = RunBuild.Exec(config.build, new(), configDirectory, outputFolder); + serveDirectory = RunBuild.Exec(config.build, new(), configDirectory, outputFolder, cancellationToken); - PdfBuilder.CreatePdf(serveDirectory).GetAwaiter().GetResult(); + PdfBuilder.CreatePdf(serveDirectory, cancellationToken).GetAwaiter().GetResult(); } if (options.Serve && serveDirectory is not null) diff --git a/src/docfx/Models/PdfCommand.cs b/src/docfx/Models/PdfCommand.cs index 067b11f52de..7a20db92cb8 100644 --- a/src/docfx/Models/PdfCommand.cs +++ b/src/docfx/Models/PdfCommand.cs @@ -1,22 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using Docfx.Pdf; using Spectre.Console.Cli; +#nullable enable + namespace Docfx; -internal class PdfCommand : Command +internal class PdfCommand : CancellableCommandBase { - public override int Execute([NotNull] CommandContext context, [NotNull] PdfCommandOptions options) + public override int Execute(CommandContext context, PdfCommandOptions options, CancellationToken cancellationToken) { return CommandHelper.Run(options, () => { var (config, configDirectory) = Docset.GetConfig(options.ConfigFile); if (config.build is not null) - PdfBuilder.Run(config.build, configDirectory, options.OutputFolder).GetAwaiter().GetResult(); + PdfBuilder.Run(config.build, configDirectory, options.OutputFolder, cancellationToken).GetAwaiter().GetResult(); }); } } diff --git a/test/Docfx.Build.Tests/XRefMapSerializationTest.cs b/test/Docfx.Build.Tests/XRefMapSerializationTest.cs index 8ab4489200b..afae124e4ae 100644 --- a/test/Docfx.Build.Tests/XRefMapSerializationTest.cs +++ b/test/Docfx.Build.Tests/XRefMapSerializationTest.cs @@ -3,7 +3,6 @@ using System.Text; using Docfx.Common; -using Docfx.Plugins; using FluentAssertions; using Xunit;