sqlinsight-pro/tests/SQLInsight.Servers.Tests/ProcessRunnerTests.cs
Søren Eggert Lundsteen Olsen f980bd3a4a Review 1.5: synthetic check runner: network surprises are results, AX process never outlives the agent
- An HTTP answer that breaks off while it is read (HttpIOException/IOException) is a
  failed check ("Svaret blev afbrudt, mens det blev læst: …"), not an exception that
  made the check rerun every tick. Other unforeseen network errors (IOException,
  SocketException, HttpRequestException, AuthenticationException) become
  "Forbindelsen fejlede: …"; one that hits the check's deadline is "Intet svar inden
  for N sekunder". DNS: an invalid name (ArgumentException) is a failed lookup.
- The HTTP "Adresse" detail (a URL may carry query secrets) and the AX "Object server"
  detail are Sensitive, so the API redacts them.
- SyntheticCheckRun's compiler-generated ToString no longer prints the SQL password.
- ProcessRunner (AX Business Connector logon): the PowerShell process tree is killed in
  a finally whenever it has not exited, also when the agent stops (before: only on the
  check's timeout, leaving an orphaned process and AX session). Output and errors are
  capped at 64 K characters each; the rest is read and dropped so the process never
  blocks on a full pipe.
- Tests: broken body, password redaction, bounded output, killed on agent stop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:01:47 +02:00

77 lines
2.9 KiB
C#

using System.Diagnostics;
using SQLInsight.Servers.Synthetic;
namespace SQLInsight.Servers.Tests;
/// <summary>The process the AX logon runs in: its output is bounded, and it never outlives the agent.</summary>
public class ProcessRunnerTests
{
[Fact]
public async Task Process_output_is_capped_but_read_to_the_end()
{
using var reader = new StringReader(new string('x', ProcessRunner.MaxOutputChars * 3) + "end");
var text = await ProcessRunner.ReadBoundedAsync(reader, ProcessRunner.MaxOutputChars);
Assert.Equal(ProcessRunner.MaxOutputChars, text.Length);
Assert.Equal(-1, reader.Peek()); // everything was read, so a process never blocks on a full pipe
}
[Fact]
public async Task A_process_is_killed_when_the_agent_stops()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var marker = Path.Combine(Path.GetTempPath(), "sqlinsight-kill-" + Guid.NewGuid().ToString("N") + ".txt");
var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), @"WindowsPowerShell\v1.0\powershell.exe");
using var stop = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var started = Stopwatch.StartNew();
try
{
// Writes its process id, then would wait for a minute; the agent stops as soon as it has started.
var stopWhenStarted = Task.Run(async () =>
{
while (!File.Exists(marker) && started.Elapsed < TimeSpan.FromMinutes(1))
{
await Task.Delay(100);
}
await Task.Delay(500);
await stop.CancelAsync();
});
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => new ProcessRunner().RunAsync(powershell,
["-NoProfile", "-NonInteractive", "-Command", $"Set-Content -LiteralPath '{marker}' -Value $PID; Start-Sleep -Seconds 60"],
new Dictionary<string, string>(), TimeSpan.FromMinutes(5), stop.Token));
await stopWhenStarted;
Assert.True(started.Elapsed < TimeSpan.FromSeconds(55), $"The process was waited for ({started.Elapsed}).");
var pid = int.Parse((await File.ReadAllTextAsync(marker)).Trim(), System.Globalization.CultureInfo.InvariantCulture);
var gone = Stopwatch.StartNew();
while (IsRunning(pid))
{
Assert.True(gone.Elapsed < TimeSpan.FromSeconds(10), $"PowerShell ({pid}) was left running.");
await Task.Delay(100);
}
}
finally
{
File.Delete(marker);
}
static bool IsRunning(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
}
}
}