From 4403be6411b4dc50570a7b645d4399575e7cfbe1 Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sun, 1 Mar 2026 03:14:47 +0100 Subject: [PATCH] parallelize the project builds to improve snapshot generation execution time --- .../Commands/SnapshotCommand.cs | 123 +++++++++++++----- 1 file changed, 91 insertions(+), 32 deletions(-) diff --git a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs index 9702143..0fe4a13 100644 --- a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs +++ b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs @@ -99,8 +99,11 @@ public override int Execute(CommandContext context, Settings settings, Cancellat if (projects == null || projects.Count == 0) return 1; - var succeeded = 0; - var failed = 0; + // ── Phase 1: Build and resolve assemblies (sequential) ── + // Shared project dependencies (ApiBase, Core, Models, etc.) can cause file lock + // conflicts if multiple dotnet build invocations run concurrently. + var resolved = new List<(string name, string assemblyPath, string assemblyDir, string outputDir)>(); + var buildFailures = 0; foreach (var projectPath in projects) { @@ -110,33 +113,83 @@ public override int Execute(CommandContext context, Settings settings, Cancellat if (projects.Count > 1) AnsiConsole.MarkupLine($"\n[bold]── {projectName.EscapeMarkup()} ──[/]"); - // Build and resolve the assembly DLL var assemblyPath = BuildAndResolveAssembly(projectPath, settings); if (assemblyPath == null) { - failed++; + buildFailures++; continue; } - // Output directory is relative to the project's directory - var outputDir = Path.Combine(projectDir, settings.Output); - var assemblyDir = Path.GetDirectoryName(assemblyPath)!; + resolved.Add((projectName, assemblyPath, Path.GetDirectoryName(assemblyPath)!, Path.Combine(projectDir, settings.Output))); + } - var exitCode = RunSnapshotSubprocess(assemblyPath, assemblyDir, outputDir, settings.DocName); - if (exitCode == 0) - succeeded++; - else - failed++; + if (resolved.Count == 0) + return 1; + + // ── Phase 2: Generate snapshots ── + var succeeded = 0; + var failed = buildFailures; + + if (resolved.Count == 1) + { + // Single project — run directly (no buffering overhead) + var p = resolved[0]; + var exitCode = RunSnapshotSubprocess(p.assemblyPath, p.assemblyDir, p.outputDir, settings.DocName); + if (exitCode == 0) succeeded++; + else failed++; + } + else + { + // Multiple projects — run subprocesses concurrently. + // Each subprocess is a fully independent OS process (own deps.json, runtimeconfig, + // working directory) so there are no shared resources or ordering constraints. + var sw = Stopwatch.StartNew(); + AnsiConsole.MarkupLine($"\n[grey]Generating {resolved.Count} snapshots concurrently...[/]"); + + var results = new SnapshotResult[resolved.Count]; + var tasks = resolved.Select((p, i) => Task.Run(() => + { + try + { + results[i] = RunSnapshotSubprocessBuffered(p.assemblyPath, p.assemblyDir, p.outputDir, settings.DocName); + } + catch (Exception ex) + { + results[i] = new SnapshotResult(1, "", $"Error: {ex.Message}\n"); + } + }, cancellationToken)).ToArray(); + + Task.WhenAll(tasks).GetAwaiter().GetResult(); + sw.Stop(); + + // Print buffered output in project order + for (var i = 0; i < resolved.Count; i++) + { + var p = resolved[i]; + var result = results[i]; + + AnsiConsole.MarkupLine($"\n[bold]── {p.name.EscapeMarkup()} ──[/]"); + + if (!string.IsNullOrWhiteSpace(result.Stdout)) + Console.Write(result.Stdout); + if (!string.IsNullOrWhiteSpace(result.Stderr)) + Console.Error.Write(result.Stderr); + + if (result.ExitCode == 0) succeeded++; + else failed++; + } + + AnsiConsole.MarkupLine($"\n[grey]Completed in {sw.Elapsed.TotalSeconds:F1}s[/]"); } // Print summary for multi-project runs if (projects.Count > 1) { - AnsiConsole.WriteLine(); + var total = succeeded + failed; if (failed == 0) - AnsiConsole.MarkupLine($"[green]Snapshots complete:[/] {succeeded}/{projects.Count} succeeded"); + AnsiConsole.MarkupLine($"[green]Snapshots complete:[/] {succeeded}/{total} succeeded"); else - AnsiConsole.MarkupLine($"[yellow]Snapshots complete:[/] {succeeded}/{projects.Count} succeeded, {failed} failed"); + AnsiConsole.MarkupLine($"[yellow]Snapshots complete:[/] {succeeded}/{total} succeeded, {failed} failed"); } return failed > 0 ? 1 : 0; @@ -293,26 +346,23 @@ private static bool IsWebProject(string csprojPath) // Subprocess execution (Stage 2) // ───────────────────────────────────────────────────────────────── + private sealed record SnapshotResult(int ExitCode, string Stdout, string Stderr); + /// /// Launches the Stage 2 subprocess via dotnet exec with the target app's dependency context. + /// Returns buffered output without writing to the console. /// - private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir, string outputDir, string docName) + private static SnapshotResult RunSnapshotSubprocessBuffered(string assemblyPath, string assemblyDir, string outputDir, string docName) { var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath); var depsFile = Path.Combine(assemblyDir, $"{assemblyName}.deps.json"); var runtimeConfig = Path.Combine(assemblyDir, $"{assemblyName}.runtimeconfig.json"); if (!File.Exists(depsFile)) - { - AnsiConsole.MarkupLine($"[red]Error:[/] deps.json not found: {depsFile.EscapeMarkup()}"); - return 1; - } + return new SnapshotResult(1, "", $"Error: deps.json not found: {depsFile}\n"); if (!File.Exists(runtimeConfig)) - { - AnsiConsole.MarkupLine($"[red]Error:[/] runtimeconfig.json not found: {runtimeConfig.EscapeMarkup()}"); - return 1; - } + return new SnapshotResult(1, "", $"Error: runtimeconfig.json not found: {runtimeConfig}\n"); // Resolve tool paths for subprocess invocation. // We intentionally do NOT use --additional-deps because it causes eager resolution @@ -337,14 +387,12 @@ private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir "--doc-name", docName }; - var processArgs = string.Join(" ", args); - var process = new Process { StartInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = processArgs, + Arguments = string.Join(" ", args), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -362,12 +410,23 @@ private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir process.WaitForExit(); - if (!string.IsNullOrWhiteSpace(stdout)) - Console.Write(stdout); - if (!string.IsNullOrWhiteSpace(stderr)) - Console.Error.Write(stderr); + return new SnapshotResult(process.ExitCode, stdout, stderr); + } - return process.ExitCode; + /// + /// Launches the Stage 2 subprocess and writes output directly to the console. + /// Used for single-project runs where buffering isn't needed. + /// + private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir, string outputDir, string docName) + { + var result = RunSnapshotSubprocessBuffered(assemblyPath, assemblyDir, outputDir, docName); + + if (!string.IsNullOrWhiteSpace(result.Stdout)) + Console.Write(result.Stdout); + if (!string.IsNullOrWhiteSpace(result.Stderr)) + Console.Error.Write(result.Stderr); + + return result.ExitCode; } // ─────────────────────────────────────────────────────────────────