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;
}
}