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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 25 additions & 19 deletions src/Buildvana.Tool/Build/BuildPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,23 +57,26 @@ public BuildPipeline(
/// </summary>
/// <param name="last">The last step to run.</param>
/// <param name="configuration">The MSBuild configuration to build.</param>
/// <param name="cancellationToken">A token to observe while running the pipeline. When signalled, the pipeline stops launching further steps and the running <c>dotnet</c> child process is terminated.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
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);

/// <summary>
/// Runs the pipeline from <paramref name="first"/> through <paramref name="last"/>, inclusive.
/// </summary>
/// <param name="first">The first step to run.</param>
/// <param name="last">The last step to run.</param>
/// <param name="configuration">The MSBuild configuration to build.</param>
/// <param name="cancellationToken">A token to observe while running the pipeline. When signalled, the pipeline stops launching further steps and the running <c>dotnet</c> child process is terminated.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
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);
}
}

Expand All @@ -81,19 +85,20 @@ public async Task RunRangeAsync(BuildStep first, BuildStep last, string configur
/// </summary>
/// <param name="step">The step to run.</param>
/// <param name="configuration">The MSBuild configuration to build (ignored by <see cref="BuildStep.Clean"/> and <see cref="BuildStep.Restore"/>).</param>
/// <param name="cancellationToken">A token to observe while running the step. When signalled, the running <c>dotnet</c> child process is terminated.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
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<Task>(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);
Expand All @@ -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);
Expand All @@ -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);
}
40 changes: 34 additions & 6 deletions src/Buildvana.Tool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> Main(string[] args)
{
var console = AnsiConsole.Console;
Expand Down Expand Up @@ -81,16 +84,31 @@ public static async Task<int> 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;
Expand All @@ -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}[/]");
Expand Down
33 changes: 20 additions & 13 deletions src/Buildvana.Tool/Services/DotNetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,16 +75,17 @@ public DotNetService(
/// </summary>
/// <param name="solution">The solution to restore.</param>
/// <param name="forwardedArgs">Extra arguments to forward verbatim to the <c>dotnet</c> invocation.</param>
/// <param name="cancellationToken">A token that, when signalled, terminates the spawned <c>dotnet</c> child process.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList<string> forwardedArgs)
public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList<string> forwardedArgs, CancellationToken cancellationToken = default)
{
Guard.IsNotNull(solution);
Guard.IsNotNull(forwardedArgs);
_logger.LogInformation("Restoring NuGet packages for solution...");
List<string> args = ["restore", solution.SolutionPath, "--disable-parallel", "-nologo", "-v", Verbosity];
args.AddRange(forwardedArgs);
args.Add(ContinuousIntegrationBuildArg(dotnetTest: false));
return RunDotNetAsync(args);
return RunDotNetAsync(args, cancellationToken);
}

/// <summary>
Expand All @@ -93,8 +95,9 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList<string>
/// <param name="configuration">The MSBuild configuration to build.</param>
/// <param name="forwardedArgs">Extra arguments to forward verbatim to the <c>dotnet</c> invocation.</param>
/// <param name="restore"><see langword="true"/> to restore NuGet packages before building, <see langword="false"/> otherwise.</param>
/// <param name="cancellationToken">A token that, when signalled, terminates the spawned <c>dotnet</c> child process.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
public Task BuildSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore)
public Task BuildSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore, CancellationToken cancellationToken = default)
{
Guard.IsNotNull(solution);
Guard.IsNotNullOrEmpty(configuration);
Expand All @@ -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);
}

/// <summary>
Expand All @@ -120,8 +123,9 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I
/// (reaching the Microsoft.Testing.Platform test applications).</param>
/// <param name="restore"><see langword="true"/> to restore NuGet packages before testing, <see langword="false"/> otherwise.</param>
/// <param name="build"><see langword="true"/> to build the solution before testing, <see langword="false"/> otherwise.</param>
/// <param name="cancellationToken">A token that, when signalled, terminates the spawned <c>dotnet</c> child process.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
public async Task TestSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore, bool build)
public async Task TestSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore, bool build, CancellationToken cancellationToken = default)
{
Guard.IsNotNull(solution);
Guard.IsNotNullOrEmpty(configuration);
Expand All @@ -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<string> 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))
{
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand All @@ -182,8 +186,9 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati
/// <param name="forwardedArgs">Extra arguments to forward verbatim to the <c>dotnet</c> invocation.</param>
/// <param name="restore"><see langword="true"/> to restore NuGet packages before packing, <see langword="false"/> otherwise.</param>
/// <param name="build"><see langword="true"/> to build the solution before packing, <see langword="false"/> otherwise.</param>
/// <param name="cancellationToken">A token that, when signalled, terminates the spawned <c>dotnet</c> child process.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
public Task PackSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore, bool build)
public Task PackSolutionAsync(SolutionContext solution, string configuration, IReadOnlyList<string> forwardedArgs, bool restore, bool build, CancellationToken cancellationToken = default)
{
Guard.IsNotNull(solution);
Guard.IsNotNullOrEmpty(configuration);
Expand All @@ -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);
}

/// <summary>
/// Asynchronously pushes all produced NuGet packages to the appropriate NuGet server.
/// </summary>
/// <param name="artifactsPath">The path of the directory containing the produced <c>*.nupkg</c> files.</param>
/// <param name="cancellationToken">A token that, when signalled, terminates the spawned <c>dotnet</c> child process.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
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();
Expand All @@ -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);
}
}
Expand All @@ -244,6 +251,6 @@ private string ContinuousIntegrationBuildArg(bool dotnetTest)
return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}";
}

private Task<ProcessResult> RunDotNetAsync(IEnumerable<string> args)
=> _processRunner.RunAsync(DotNetMuxer, args);
private Task<ProcessResult> RunDotNetAsync(IEnumerable<string> args, CancellationToken cancellationToken = default)
=> _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken);
}
2 changes: 1 addition & 1 deletion src/Buildvana.Tool/Subcommands/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal sealed class BuildCommand(BuildPipeline pipeline) : IBvCommand
{
public async Task<int> ExecuteAsync(CancellationToken cancellationToken)
{
await pipeline.RunThroughAsync(BuildStep.Build).ConfigureAwait(false);
await pipeline.RunThroughAsync(BuildStep.Build, cancellationToken: cancellationToken).ConfigureAwait(false);
return 0;
}
}
2 changes: 1 addition & 1 deletion src/Buildvana.Tool/Subcommands/CleanCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal sealed class CleanCommand(BuildPipeline pipeline) : IBvCommand
{
public async Task<int> ExecuteAsync(CancellationToken cancellationToken)
{
await pipeline.RunThroughAsync(BuildStep.Clean).ConfigureAwait(false);
await pipeline.RunThroughAsync(BuildStep.Clean, cancellationToken: cancellationToken).ConfigureAwait(false);
return 0;
}
}
Loading