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
36 changes: 36 additions & 0 deletions NoSleep.Test/ChildProcessHoldTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,42 @@
StringAssert.Contains(exception.Message, "exited immediately", StringComparison.Ordinal);
}

[TestMethod]
public void Start_WhenTheHelperExitsImmediately_IncludesItsStandardErrorInTheMessage()
{
if (!IsUnix)
{
Assert.Inconclusive("The helper paths used by this test only exist on Unix.");
return;
}

SleepBlockException exception = Assert.ThrowsExactly<SleepBlockException>(
() => ChildProcessHold.Start(ImmediateExitHelper, ["-c", "echo boom >&2; exit 1"]));

StringAssert.Contains(exception.Message, "exited immediately with code 1", StringComparison.Ordinal);

Check warning on line 85 in NoSleep.Test/ChildProcessHoldTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGhn_JFYxNqvR82oSVo&open=AaGhn_JFYxNqvR82oSVo&pullRequest=40
StringAssert.Contains(exception.Message, "boom", StringComparison.Ordinal);

Check warning on line 86 in NoSleep.Test/ChildProcessHoldTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGhn_JFYxNqvR82oSVp&open=AaGhn_JFYxNqvR82oSVp&pullRequest=40
}

[TestMethod]
public void Start_WhenTheHelperFloodsStandardError_KeepsOnlyTheLastOfItInTheMessage()
{
if (!IsUnix)
{
Assert.Inconclusive("The helper paths used by this test only exist on Unix.");
return;
}

// 600 lines of filler (about 9 KB), then the line that explains the failure.
const string Script = "i=0; while [ $i -lt 600 ]; do echo filler-line-$i >&2; i=$((i+1)); done; echo the-real-reason >&2; exit 3";

SleepBlockException exception = Assert.ThrowsExactly<SleepBlockException>(
() => ChildProcessHold.Start(ImmediateExitHelper, ["-c", Script]));

StringAssert.Contains(exception.Message, "the-real-reason", StringComparison.Ordinal);

Check warning on line 104 in NoSleep.Test/ChildProcessHoldTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_NoSleep&issues=AaGhn_JFYxNqvR82oSVq&open=AaGhn_JFYxNqvR82oSVq&pullRequest=40
Assert.IsFalse(exception.Message.Contains("filler-line-0 ", StringComparison.Ordinal), "The oldest output should have been dropped");
Assert.IsLessThan(4096 + 200, exception.Message.Length, "The message should stay bounded");
}

[TestMethod]
public void Start_WithAMissingHelper_Throws()
{
Expand Down
99 changes: 94 additions & 5 deletions NoSleep/Platforms/ChildProcessHold.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace ktsu.NoSleep.Platforms;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;

/// <summary>
/// A sleep inhibitor that exists for as long as a helper process stays alive.
Expand Down Expand Up @@ -73,10 +74,12 @@ public static ChildProcessHold Start(string fileName, IEnumerable<string> argume

Process process = new() { StartInfo = startInfo };

// Nothing consumes the helper's output, but an unread pipe eventually fills and blocks the writer,
// so drain both streams and drop what arrives.
// An unread pipe eventually fills and blocks the writer, so both streams are drained. Standard output
// is dropped. Standard error is kept while the helper starts up, because a helper that fails at once
// usually says why there, and that reason belongs in the exception.
StartupErrorOutput startupErrors = new();
process.OutputDataReceived += static (_, _) => { };
process.ErrorDataReceived += static (_, _) => { };
process.ErrorDataReceived += (_, e) => startupErrors.Add(e.Data);

try
{
Expand All @@ -92,13 +95,25 @@ public static ChildProcessHold Start(string fileName, IEnumerable<string> argume

if (process.WaitForExit(GetMilliseconds(StartupGrace)))
{
// The timed wait returns as soon as the process ends, possibly before its last lines of standard
// error have been read. The untimed wait also waits for both streams to reach end of input, which
// they do promptly now that the helper is gone.
process.WaitForExit();
int exitCode = process.ExitCode;
string errorOutput = startupErrors.Stop();
process.Dispose();
throw new SleepBlockException(string.Create(

string message = string.Create(
CultureInfo.InvariantCulture,
$"The inhibitor helper \"{fileName}\" exited immediately with code {exitCode}."));
$"The inhibitor helper \"{fileName}\" exited immediately with code {exitCode}.");

throw new SleepBlockException(errorOutput.Length == 0
? message
: $"{message} It reported: {errorOutput}");
}

_ = startupErrors.Stop();

return new ChildProcessHold(process) { Command = fileName, ProcessId = process.Id };
}

Expand Down Expand Up @@ -147,4 +162,78 @@ public void Dispose()
}

private static int GetMilliseconds(TimeSpan timeout) => (int)timeout.TotalMilliseconds;

/// <summary>
/// Keeps the most recent standard error lines a helper writes while it starts up, up to a fixed size.
/// </summary>
/// <remarks>
/// Lines arrive on a thread-pool thread while <see cref="Start"/> waits, so access is locked. Once stopped,
/// later lines are dropped, so a long-running helper cannot grow the buffer.
/// </remarks>
private sealed class StartupErrorOutput
{
/// <summary>
/// The most characters kept. When more arrive, the oldest are dropped, since the last lines a failing
/// helper writes are the ones most likely to say why it failed.
/// </summary>
private const int MaxLength = 4096;

private readonly Lock gate = new();
private readonly LinkedList<string> lines = new();
private int length;
private bool stopped;

/// <summary>
/// Records a line of standard error, unless recording has stopped.
/// </summary>
/// <param name="line">The line, or <see langword="null"/> at end of input.</param>
public void Add(string? line)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}

string trimmed = line.Trim();

lock (gate)
{
if (stopped)
{
return;
}

if (trimmed.Length > MaxLength)
{
trimmed = trimmed[^MaxLength..];
}

lines.AddLast(trimmed);
// Each line after the first costs one more character for the space that joins it to the last.
length += trimmed.Length + (lines.Count > 1 ? 1 : 0);

while (length > MaxLength && lines.First is { } oldest)
{
length -= oldest.Value.Length + (lines.Count > 1 ? 1 : 0);
lines.RemoveFirst();
}
}
}

/// <summary>
/// Stops recording and returns what was recorded.
/// </summary>
/// <returns>The recorded lines joined by spaces, or an empty string when there were none.</returns>
public string Stop()
{
lock (gate)
{
stopped = true;
string text = string.Join(' ', lines);
lines.Clear();
length = 0;
return text;
}
}
}
}
Loading