diff --git a/CHANGELOG.md b/CHANGELOG.md index c71443e..3cc68df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING CHANGE**: `bv` no longer forces `-maxcpucount:1` on the `dotnet` invocations of `restore`/`build`/`test`/`pack`. MSBuild now uses its default parallelism unless you forward your own `-m`/`-maxcpucount` switch. - **BREAKING CHANGE**: The `-c`/`--configuration` option is no longer parsed by `bv restore`/`build`/`test`/`pack`; for those commands it is just another forwarded argument, passed after the `--` separator. `bv` emits `Release` as an overridable default, so a forwarded `-c`/`-p:Configuration=` still wins (e.g. `bv build -- -c Debug` builds `Debug`). `bv release` keeps `-c`/`--configuration` as a parsed option, since it needs the value to locate build artifacts. - `--main-branch` is now a global option: it is accepted at any position on the command line (before or after the subcommand name), appears in the `GLOBAL OPTIONS` section of help, and is honored by the commands that talk to Git. +- `bv`'s build commands (`clean`, `restore`, `build`, `test`, `pack`) and `release` now observe cancellation. Pressing Ctrl-C (or a host cancelling the operation) stops the pipeline promptly: it stops launching further steps and terminates the running `dotnet` child process instead of waiting for it to finish, then `bv` exits with code 130. Partial build output may be left behind on cancellation; `bv clean` recovers. ### Bugs fixed in this release diff --git a/src/Buildvana.Tool/Build/BuildPipeline.cs b/src/Buildvana.Tool/Build/BuildPipeline.cs index 0154e6f..341263f 100644 --- a/src/Buildvana.Tool/Build/BuildPipeline.cs +++ b/src/Buildvana.Tool/Build/BuildPipeline.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Infrastructure; @@ -56,9 +57,10 @@ public BuildPipeline( /// /// The last step to run. /// The MSBuild configuration to build. + /// A token to observe while running the pipeline. When signalled, the pipeline stops launching further steps and the running dotnet child process is terminated. /// A representing the ongoing operation. - public Task RunThroughAsync(BuildStep last, string configuration = DefaultConfiguration) - => RunRangeAsync(BuildStep.Clean, last, configuration); + public Task RunThroughAsync(BuildStep last, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) + => RunRangeAsync(BuildStep.Clean, last, configuration, cancellationToken); /// /// Runs the pipeline from through , inclusive. @@ -66,13 +68,15 @@ public Task RunThroughAsync(BuildStep last, string configuration = DefaultConfig /// The first step to run. /// The last step to run. /// The MSBuild configuration to build. + /// A token to observe while running the pipeline. When signalled, the pipeline stops launching further steps and the running dotnet child process is terminated. /// A representing the ongoing operation. - public async Task RunRangeAsync(BuildStep first, BuildStep last, string configuration = DefaultConfiguration) + public async Task RunRangeAsync(BuildStep first, BuildStep last, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) { Guard.IsLessThanOrEqualTo((int)first, (int)last, nameof(first)); for (var step = first; step <= last; step++) { - await RunAsync(step, configuration).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await RunAsync(step, configuration, cancellationToken).ConfigureAwait(false); } } @@ -81,19 +85,20 @@ public async Task RunRangeAsync(BuildStep first, BuildStep last, string configur /// /// The step to run. /// The MSBuild configuration to build (ignored by and ). + /// A token to observe while running the step. When signalled, the running dotnet child process is terminated. /// A representing the ongoing operation. - public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration) + public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) => step switch { - BuildStep.Clean => CleanAsync(), - BuildStep.Restore => RestoreAsync(), - BuildStep.Build => BuildAsync(configuration), - BuildStep.Test => TestAsync(configuration), - BuildStep.Pack => PackAsync(configuration), + BuildStep.Clean => CleanAsync(cancellationToken), + BuildStep.Restore => RestoreAsync(cancellationToken), + BuildStep.Build => BuildAsync(configuration, cancellationToken), + BuildStep.Test => TestAsync(configuration, cancellationToken), + BuildStep.Pack => PackAsync(configuration, cancellationToken), _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(step), step, "Unknown build step."), }; - private Task CleanAsync() + private Task CleanAsync(CancellationToken cancellationToken) { var logger = _loggerFactory.CreateLogger("Clean"); FileSystemHelper.DeleteDirectory(_solution.ResolvePath(".vs"), logger); @@ -103,6 +108,7 @@ private Task CleanAsync() FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.TestResults), logger); foreach (var project in _solution.Model.SolutionProjects) { + cancellationToken.ThrowIfCancellationRequested(); var projectDirectory = Path.GetDirectoryName(_solution.ResolveProjectPath(project))!; FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "bin"), logger); FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "obj"), logger); @@ -111,15 +117,15 @@ private Task CleanAsync() return Task.CompletedTask; } - private Task RestoreAsync() - => _dotnet.RestoreSolutionAsync(_solution, _forwardedArgs); + private Task RestoreAsync(CancellationToken cancellationToken) + => _dotnet.RestoreSolutionAsync(_solution, _forwardedArgs, cancellationToken); - private Task BuildAsync(string configuration) - => _dotnet.BuildSolutionAsync(_solution, configuration, _forwardedArgs, restore: false); + private Task BuildAsync(string configuration, CancellationToken cancellationToken) + => _dotnet.BuildSolutionAsync(_solution, configuration, _forwardedArgs, restore: false, cancellationToken); - private Task TestAsync(string configuration) - => _dotnet.TestSolutionAsync(_solution, configuration, _forwardedArgs, restore: false, build: false); + private Task TestAsync(string configuration, CancellationToken cancellationToken) + => _dotnet.TestSolutionAsync(_solution, configuration, _forwardedArgs, restore: false, build: false, cancellationToken); - private Task PackAsync(string configuration) - => _dotnet.PackSolutionAsync(_solution, configuration, _forwardedArgs, restore: false, build: false); + private Task PackAsync(string configuration, CancellationToken cancellationToken) + => _dotnet.PackSolutionAsync(_solution, configuration, _forwardedArgs, restore: false, build: false, cancellationToken); } diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index 1dc5ec7..88a3e29 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -29,6 +29,9 @@ namespace Buildvana.Tool; internal static class Program { + // 128 + SIGINT (2): the POSIX convention for a process terminated by Ctrl-C. + private const int CancelledExitCode = 130; + public static async Task Main(string[] args) { var console = AnsiConsole.Console; @@ -81,16 +84,31 @@ public static async Task Main(string[] args) var services = BuildServiceProvider(console, globals, parsed, initialLogLevel); await using (services.ConfigureAwait(false)) { - using var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(); + + // Serializes the Ctrl-C handler with cts disposal in the finally block below. Unsubscribing the + // handler does not wait for an in-flight invocation, so without this gate a cts.Cancel() racing + // cts.Dispose() could throw ObjectDisposedException on the handler thread. + var cancelGate = new Lock(); + var ctsDisposed = false; void OnCancel(object? sender, ConsoleCancelEventArgs e) { - // Suppress the immediate process kill so commands can observe the token; child processes - // still receive their own Ctrl-C from the console and terminate on their own. + // Suppress bv's own immediate termination so the command can observe the token and shut down + // cleanly: the token is forwarded down to the running `dotnet` child, whose process tree is + // then killed. e.Cancel = true; - // Safe: the handler is removed in the finally below before cts is disposed at end of scope. - // ReSharper disable once AccessToDisposedClosure - cts.Cancel(); + lock (cancelGate) + { + // ReSharper disable once AccessToModifiedClosure + if (ctsDisposed) + { + return; + } + + // ReSharper disable once AccessToDisposedClosure + cts.Cancel(); + } } Console.CancelKeyPress += OnCancel; @@ -102,9 +120,19 @@ void OnCancel(object? sender, ConsoleCancelEventArgs e) finally { Console.CancelKeyPress -= OnCancel; + lock (cancelGate) + { + ctsDisposed = true; + cts.Dispose(); + } } } } + catch (OperationCanceledException) + { + console.MarkupLine("[yellow]Operation cancelled.[/]"); + return CancelledExitCode; + } catch (BuildFailedException ex) { console.MarkupLineInterpolated($"[red]{ex.Message}[/]"); diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 951d60b..9cd4b53 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Buildvana.Tool.Configuration; using Buildvana.Tool.Infrastructure; @@ -74,8 +75,9 @@ public DotNetService( /// /// The solution to restore. /// Extra arguments to forward verbatim to the dotnet invocation. + /// A token that, when signalled, terminates the spawned dotnet child process. /// A representing the ongoing operation. - public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList forwardedArgs) + public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList forwardedArgs, CancellationToken cancellationToken = default) { Guard.IsNotNull(solution); Guard.IsNotNull(forwardedArgs); @@ -83,7 +85,7 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList List args = ["restore", solution.SolutionPath, "--disable-parallel", "-nologo", "-v", Verbosity]; args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args); + return RunDotNetAsync(args, cancellationToken); } /// @@ -93,8 +95,9 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList /// The MSBuild configuration to build. /// Extra arguments to forward verbatim to the dotnet invocation. /// to restore NuGet packages before building, otherwise. + /// A token that, when signalled, terminates the spawned dotnet child process. /// A representing the ongoing operation. - public Task BuildSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore) + public Task BuildSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore, CancellationToken cancellationToken = default) { Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); @@ -108,7 +111,7 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args); + return RunDotNetAsync(args, cancellationToken); } /// @@ -120,8 +123,9 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I /// (reaching the Microsoft.Testing.Platform test applications). /// to restore NuGet packages before testing, otherwise. /// to build the solution before testing, otherwise. + /// A token that, when signalled, terminates the spawned dotnet child process. /// A representing the ongoing operation. - public async Task TestSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore, bool build) + public async Task TestSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore, bool build, CancellationToken cancellationToken = default) { Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); @@ -136,7 +140,7 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati // bv-internal MSBuild evaluation: do not forward the user's arguments here, as they may be // test-application options that `dotnet msbuild` would reject. List probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; - var probe = await RunDotNetAsync(probeArgs).ConfigureAwait(false); + var probe = await RunDotNetAsync(probeArgs, cancellationToken).ConfigureAwait(false); if (string.Equals(probe.StandardOutput.Trim(), "true", StringComparison.OrdinalIgnoreCase)) { @@ -171,7 +175,7 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati args.AddRange(["--coverage", "--coverage-output-format", "cobertura", "--results-directory", CommonPaths.TestResults]); args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: true)); - await RunDotNetAsync(args).ConfigureAwait(false); + await RunDotNetAsync(args, cancellationToken).ConfigureAwait(false); } /// @@ -182,8 +186,9 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati /// Extra arguments to forward verbatim to the dotnet invocation. /// to restore NuGet packages before packing, otherwise. /// to build the solution before packing, otherwise. + /// A token that, when signalled, terminates the spawned dotnet child process. /// A representing the ongoing operation. - public Task PackSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore, bool build) + public Task PackSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList forwardedArgs, bool restore, bool build, CancellationToken cancellationToken = default) { Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); @@ -202,15 +207,16 @@ public Task PackSolutionAsync(SolutionContext solution, string configuration, IR args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args); + return RunDotNetAsync(args, cancellationToken); } /// /// Asynchronously pushes all produced NuGet packages to the appropriate NuGet server. /// /// The path of the directory containing the produced *.nupkg files. + /// A token that, when signalled, terminates the spawned dotnet child process. /// A representing the ongoing operation. - public async Task NuGetPushAllAsync(string artifactsPath) + public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken cancellationToken = default) { Guard.IsNotNullOrEmpty(artifactsPath); var packages = FileSystemHelper.EnumerateFiles(artifactsPath, "*.nupkg").ToArray(); @@ -231,7 +237,8 @@ public async Task NuGetPushAllAsync(string artifactsPath) await _processRunner .RunAsync( DotNetMuxer, - ["nuget", "push", path, "--source", target.Source, "--api-key", target.ApiKey, "--skip-duplicate", "--force-english-output"]) + ["nuget", "push", path, "--source", target.Source, "--api-key", target.ApiKey, "--skip-duplicate", "--force-english-output"], + cancellationToken: cancellationToken) .ConfigureAwait(false); } } @@ -244,6 +251,6 @@ private string ContinuousIntegrationBuildArg(bool dotnetTest) return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}"; } - private Task RunDotNetAsync(IEnumerable args) - => _processRunner.RunAsync(DotNetMuxer, args); + private Task RunDotNetAsync(IEnumerable args, CancellationToken cancellationToken = default) + => _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken); } diff --git a/src/Buildvana.Tool/Subcommands/BuildCommand.cs b/src/Buildvana.Tool/Subcommands/BuildCommand.cs index 8285929..53f7440 100644 --- a/src/Buildvana.Tool/Subcommands/BuildCommand.cs +++ b/src/Buildvana.Tool/Subcommands/BuildCommand.cs @@ -15,7 +15,7 @@ internal sealed class BuildCommand(BuildPipeline pipeline) : IBvCommand { public async Task ExecuteAsync(CancellationToken cancellationToken) { - await pipeline.RunThroughAsync(BuildStep.Build).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Build, cancellationToken: cancellationToken).ConfigureAwait(false); return 0; } } diff --git a/src/Buildvana.Tool/Subcommands/CleanCommand.cs b/src/Buildvana.Tool/Subcommands/CleanCommand.cs index 6c91bdf..84f4a32 100644 --- a/src/Buildvana.Tool/Subcommands/CleanCommand.cs +++ b/src/Buildvana.Tool/Subcommands/CleanCommand.cs @@ -15,7 +15,7 @@ internal sealed class CleanCommand(BuildPipeline pipeline) : IBvCommand { public async Task ExecuteAsync(CancellationToken cancellationToken) { - await pipeline.RunThroughAsync(BuildStep.Clean).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Clean, cancellationToken: cancellationToken).ConfigureAwait(false); return 0; } } diff --git a/src/Buildvana.Tool/Subcommands/PackCommand.cs b/src/Buildvana.Tool/Subcommands/PackCommand.cs index 694ac1c..ccc15c4 100644 --- a/src/Buildvana.Tool/Subcommands/PackCommand.cs +++ b/src/Buildvana.Tool/Subcommands/PackCommand.cs @@ -15,7 +15,7 @@ internal sealed class PackCommand(BuildPipeline pipeline) : IBvCommand { public async Task ExecuteAsync(CancellationToken cancellationToken) { - await pipeline.RunThroughAsync(BuildStep.Pack).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Pack, cancellationToken: cancellationToken).ConfigureAwait(false); return 0; } } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index f46c6e5..82d866c 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -34,7 +34,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var artifactsPath = Path.Combine(CommonPaths.AllArtifacts, configuration); // Verification pass (Clean→Test), mirroring today's [IsDependentOn(TestTask)] chain. - await pipeline.RunThroughAsync(BuildStep.Test, configuration).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Test, configuration, cancellationToken).ConfigureAwait(false); var logger = services.GetRequiredService().CreateLogger("Release"); var home = services.GetRequiredService(); @@ -170,7 +170,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) BuildFailedException.ThrowIfNot(!git.TagExists(version.CurrentStr), $"Tag '{version.CurrentStr}' already exists in repository."); // Artifact pass (Restore→Pack, no Clean): rebuild against the resolved version and make artifacts. - await pipeline.RunRangeAsync(BuildStep.Restore, BuildStep.Pack, configuration).ConfigureAwait(false); + await pipeline.RunRangeAsync(BuildStep.Restore, BuildStep.Pack, configuration, cancellationToken).ConfigureAwait(false); if (changelogUpdated) { @@ -220,7 +220,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) release.PushUpdates(); // Publish NuGet packages - await dotnet.NuGetPushAllAsync(artifactsPath).ConfigureAwait(false); + await dotnet.NuGetPushAllAsync(artifactsPath, cancellationToken).ConfigureAwait(false); // Gather build assets from Buildvana.Sdk release asset lists logger.LogInformation("Reading release asset lists..."); diff --git a/src/Buildvana.Tool/Subcommands/RestoreCommand.cs b/src/Buildvana.Tool/Subcommands/RestoreCommand.cs index 3a97105..f8ba572 100644 --- a/src/Buildvana.Tool/Subcommands/RestoreCommand.cs +++ b/src/Buildvana.Tool/Subcommands/RestoreCommand.cs @@ -15,7 +15,7 @@ internal sealed class RestoreCommand(BuildPipeline pipeline) : IBvCommand { public async Task ExecuteAsync(CancellationToken cancellationToken) { - await pipeline.RunThroughAsync(BuildStep.Restore).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Restore, cancellationToken: cancellationToken).ConfigureAwait(false); return 0; } } diff --git a/src/Buildvana.Tool/Subcommands/TestCommand.cs b/src/Buildvana.Tool/Subcommands/TestCommand.cs index 24de93e..4e5a0fc 100644 --- a/src/Buildvana.Tool/Subcommands/TestCommand.cs +++ b/src/Buildvana.Tool/Subcommands/TestCommand.cs @@ -15,7 +15,7 @@ internal sealed class TestCommand(BuildPipeline pipeline) : IBvCommand { public async Task ExecuteAsync(CancellationToken cancellationToken) { - await pipeline.RunThroughAsync(BuildStep.Test).ConfigureAwait(false); + await pipeline.RunThroughAsync(BuildStep.Test, cancellationToken: cancellationToken).ConfigureAwait(false); return 0; } }