Skip to content

Commit 1134e0d

Browse files
committed
Improve error reporting in ProcessRunner: show full command line, stdout/stderr head and tail
1 parent f46b205 commit 1134e0d

4 files changed

Lines changed: 207 additions & 54 deletions

File tree

‎src/Buildvana.Core.Abstractions/Process/ProcessResult.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ namespace Buildvana.Core.Process;
99
/// <summary>
1010
/// The outcome of a process invocation performed through <see cref="IProcessRunner"/>.
1111
/// </summary>
12+
/// <param name="CommandLine">The full command line that was executed, including the executable and all arguments.</param>
1213
/// <param name="ExitCode">The exit code reported by the process.</param>
1314
/// <param name="StandardOutput">The full text written to the process's standard output stream.</param>
1415
/// <param name="StandardError">The full text written to the process's standard error stream.</param>
1516
/// <param name="Elapsed">The wall-clock time the process took to run.</param>
1617
[PublicAPI]
17-
public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError, TimeSpan Elapsed);
18+
public sealed record ProcessResult(string CommandLine, int ExitCode, string StandardOutput, string StandardError, TimeSpan Elapsed);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (C) Tenacom and Contributors. Licensed under the MIT license.
2+
// See the LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Globalization;
7+
using System.IO;
8+
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using CliWrap;
12+
13+
namespace Buildvana.Core.Process.Internal;
14+
15+
/// <summary>
16+
/// A <see cref="PipeTarget"/> that retains the first <see cref="MaxHeadLines"/> and last <see cref="MaxTailLines"/> lines
17+
/// of a stream, discarding the lines in between.
18+
/// </summary>
19+
internal sealed class HeadTailPipeTarget : PipeTarget
20+
{
21+
private readonly List<string> _head = [];
22+
private readonly Queue<string> _tail = [];
23+
private long _totalLines;
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="HeadTailPipeTarget"/> class.
27+
/// </summary>
28+
/// <param name="maxHeadLines">The maximum number of leading lines to retain.</param>
29+
/// <param name="maxTailLines">The maximum number of trailing lines to retain.</param>
30+
public HeadTailPipeTarget(int maxHeadLines, int maxTailLines)
31+
{
32+
MaxHeadLines = maxHeadLines;
33+
MaxTailLines = maxTailLines;
34+
}
35+
36+
/// <summary>
37+
/// Gets the maximum number of leading lines retained from the stream.
38+
/// </summary>
39+
public int MaxHeadLines { get; }
40+
41+
/// <summary>
42+
/// Gets the maximum number of trailing lines retained from the stream.
43+
/// </summary>
44+
public int MaxTailLines { get; }
45+
46+
/// <summary>
47+
/// Gets the leading lines retained from the stream.
48+
/// </summary>
49+
public IEnumerable<string> Head => _head;
50+
51+
/// <summary>
52+
/// Gets the trailing lines retained from the stream.
53+
/// </summary>
54+
public IEnumerable<string> Tail => _tail;
55+
56+
/// <summary>
57+
/// Gets the total number of lines read from the stream.
58+
/// </summary>
59+
public long TotalLines => _totalLines;
60+
61+
/// <summary>
62+
/// Gets the number of leading lines retained from the stream.
63+
/// </summary>
64+
public long HeadLines => _head.Count;
65+
66+
/// <summary>
67+
/// Gets the number of trailing lines retained from the stream.
68+
/// </summary>
69+
public long TailLines => _tail.Count;
70+
71+
/// <summary>
72+
/// Gets the number of retained lines, i.e. the lines that <see cref="AppendTo"/> outputs, excluding the omission marker.
73+
/// </summary>
74+
public long DisplayedLines => _head.Count + _tail.Count;
75+
76+
/// <summary>
77+
/// Gets a value indicating whether any lines were omitted, i.e. whether <see cref="TotalLines"/> exceeded
78+
/// the sum of <see cref="MaxHeadLines"/> and <see cref="MaxTailLines"/>.
79+
/// </summary>
80+
public bool Truncated => _totalLines > MaxHeadLines + MaxTailLines;
81+
82+
/// <summary>
83+
/// Gets the number of lines omitted between the retained head and tail, or <c>0</c> if no line was omitted.
84+
/// </summary>
85+
public long TruncatedLines => Truncated ? _totalLines - _head.Count - _tail.Count : 0;
86+
87+
/// <inheritdoc/>
88+
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
89+
{
90+
using var reader = new StreamReader(origin);
91+
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
92+
{
93+
_totalLines++;
94+
if (_head.Count < MaxHeadLines)
95+
{
96+
_head.Add(line);
97+
}
98+
else
99+
{
100+
_tail.Enqueue(line);
101+
if (_tail.Count > MaxTailLines)
102+
{
103+
_tail.Dequeue();
104+
}
105+
}
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Appends the retained head lines, an omission marker if <see cref="Truncated"/> is <see langword="true"/>,
111+
/// and the retained tail lines to the specified <see cref="StringBuilder"/>.
112+
/// </summary>
113+
/// <param name="sb">The <see cref="StringBuilder"/> to append to.</param>
114+
/// <returns>The same <see cref="StringBuilder"/> instance passed as <paramref name="sb"/>, to allow call chaining.</returns>
115+
/// <exception cref="ArgumentNullException"><paramref name="sb"/> is <see langword="null"/>.</exception>
116+
public StringBuilder AppendTo(StringBuilder sb)
117+
{
118+
ArgumentNullException.ThrowIfNull(sb);
119+
120+
foreach (var l in _head)
121+
{
122+
sb = sb.AppendLine(l);
123+
}
124+
125+
if (Truncated)
126+
{
127+
sb = sb.AppendLine(CultureInfo.InvariantCulture, $"... [{TruncatedLines} line(s) omitted] ...");
128+
}
129+
130+
foreach (var l in _tail)
131+
{
132+
sb = sb.AppendLine(l);
133+
}
134+
135+
return sb;
136+
}
137+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (C) Tenacom and Contributors. Licensed under the MIT license.
2+
// See the LICENSE file in the project root for full license information.
3+
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using System.Text;
7+
8+
namespace Buildvana.Core.Process.Internal;
9+
10+
/// <summary>
11+
/// Provides extension methods for <see cref="StringBuilder"/>.
12+
/// </summary>
13+
internal static class StringBuilderExtensions
14+
{
15+
extension(StringBuilder @this)
16+
{
17+
public StringBuilder AppendHeadTail(HeadTailPipeTarget capture, string name)
18+
{
19+
if (capture.TotalLines == 0)
20+
{
21+
return @this;
22+
}
23+
24+
return @this
25+
.AppendHeader(capture.Truncated
26+
? $"{name} ({capture.TotalLines} total lines, displaying first {capture.HeadLines} and last {capture.TailLines})"
27+
: name)
28+
.AppendLines(capture.Head)
29+
.AppendHeader(capture.Truncated ? $"... {capture.TruncatedLines} lines truncated ..." : null)
30+
.AppendLines(capture.Tail);
31+
}
32+
33+
public StringBuilder AppendLines(IEnumerable<string> lines)
34+
{
35+
foreach (var line in lines)
36+
{
37+
@this = @this.AppendLine(line);
38+
}
39+
40+
return @this;
41+
}
42+
43+
public StringBuilder AppendHeader(string? header) => header is null ? @this : @this.AppendLine(CultureInfo.InvariantCulture, $"=== {header} ===");
44+
}
45+
}

‎src/Buildvana.Core.Process/ProcessRunner.cs‎

Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Text;
88
using System.Threading;
99
using System.Threading.Tasks;
10+
using Buildvana.Core.Process.Internal;
1011
using CliWrap;
1112
using CommunityToolkit.Diagnostics;
1213

@@ -17,7 +18,8 @@ namespace Buildvana.Core.Process;
1718
/// </summary>
1819
public sealed class ProcessRunner : IProcessRunner
1920
{
20-
private const int TailCapBytes = 4096;
21+
private const int OutputHeadLines = 20;
22+
private const int OutputTailLines = 20;
2123

2224
/// <inheritdoc cref="IProcessRunner.RunAsync"/>
2325
public async Task<ProcessResult> RunAsync(
@@ -35,20 +37,27 @@ public async Task<ProcessResult> RunAsync(
3537

3638
var stdoutBuffer = new StringBuilder();
3739
var stderrBuffer = new StringBuilder();
40+
var stdoutCapture = new HeadTailPipeTarget(maxHeadLines: OutputHeadLines, maxTailLines: OutputTailLines);
41+
var stderrCapture = new HeadTailPipeTarget(maxHeadLines: OutputHeadLines, maxTailLines: OutputTailLines);
3842

39-
// When the caller wants line-by-line stdout, fan the stream out to both the buffer and their callback.
43+
// When the caller wants line-by-line stdout, fan the stream out to their callback too.
4044
var stdoutPipe = onStdout is null
41-
? PipeTarget.ToStringBuilder(stdoutBuffer)
45+
? PipeTarget.Merge(
46+
stdoutCapture,
47+
PipeTarget.ToStringBuilder(stdoutBuffer))
4248
: PipeTarget.Merge(
49+
stdoutCapture,
4350
PipeTarget.ToStringBuilder(stdoutBuffer),
4451
PipeTarget.ToDelegate(onStdout));
4552

53+
var stderrPipe = PipeTarget.Merge(stderrCapture, PipeTarget.ToStringBuilder(stderrBuffer));
54+
4655
var command = Cli.Wrap(executable)
4756

4857
// ReSharper disable once PossibleMultipleEnumeration
4958
.WithArguments(args)
5059
.WithStandardOutputPipe(stdoutPipe)
51-
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stderrBuffer))
60+
.WithStandardErrorPipe(stderrPipe)
5261
.WithValidation(CommandResultValidation.None);
5362

5463
if (workingDirectory is not null)
@@ -59,65 +68,26 @@ public async Task<ProcessResult> RunAsync(
5968
var commandResult = await command.ExecuteAsync(cancellationToken).ConfigureAwait(false);
6069

6170
var result = new ProcessResult(
71+
command.ToString(),
6272
commandResult.ExitCode,
6373
stdoutBuffer.ToString(),
6474
stderrBuffer.ToString(),
6575
commandResult.RunTime);
6676

6777
if (throwOnNonZero && result.ExitCode != 0)
6878
{
69-
throw new BuildFailedException(result.ExitCode, BuildFailureMessage(executable, result));
79+
throw new BuildFailedException(result.ExitCode, BuildFailureMessage(executable, result, stdoutCapture, stderrCapture));
7080
}
7181

7282
return result;
7383
}
7484

75-
private static string BuildFailureMessage(string executable, ProcessResult result)
76-
{
77-
var stdout = FormatTail(result.StandardOutput);
78-
var stderr = FormatTail(result.StandardError);
79-
var header = string.Create(
80-
CultureInfo.InvariantCulture,
81-
$"Command '{executable}' exited with code {result.ExitCode}.");
82-
if (stdout is null && stderr is null)
83-
{
84-
return header;
85-
}
86-
87-
var sb = new StringBuilder(header);
88-
if (stdout is not null)
89-
{
90-
_ = sb.Append("\n--- stdout ---\n").Append(stdout);
91-
}
92-
93-
if (stderr is not null)
94-
{
95-
_ = sb.Append("\n--- stderr ---\n").Append(stderr);
96-
}
97-
98-
return sb.ToString();
99-
}
100-
101-
private static string? FormatTail(string text)
102-
{
103-
var trimmed = text.TrimEnd();
104-
if (trimmed.Length == 0)
105-
{
106-
return null;
107-
}
108-
109-
var bytes = Encoding.UTF8.GetBytes(trimmed);
110-
if (bytes.Length <= TailCapBytes)
111-
{
112-
return trimmed;
113-
}
114-
115-
var start = bytes.Length - TailCapBytes;
116-
while (start < bytes.Length && (bytes[start] & 0xC0) == 0x80)
117-
{
118-
start++;
119-
}
120-
121-
return "…" + Encoding.UTF8.GetString(bytes, start, bytes.Length - start).TrimStart();
122-
}
85+
private static string BuildFailureMessage(string executable, ProcessResult result, HeadTailPipeTarget stdoutCapture, HeadTailPipeTarget stderrCapture)
86+
=> new StringBuilder()
87+
.AppendLine(CultureInfo.InvariantCulture, $"Command '{executable}' exited with code {result.ExitCode}.")
88+
.AppendHeader("full command line")
89+
.AppendLine(result.CommandLine)
90+
.AppendHeadTail(stdoutCapture, "stdout")
91+
.AppendHeadTail(stderrCapture, "stderr")
92+
.ToString();
12393
}

0 commit comments

Comments
 (0)