tbf/TBF/Rig/RegisterReaders/PoseidonCmdStartStop/CliRunner.cs
Michal Buzik 234bd39ed5 fix(poseidon): make CLI timeout tests independent of logging
- allow CliRunner to run without an injected logger
- keep CLI diagnostics optional using null-safe logging
- standardize non-zero process failure text as "exit code <n>"
- update Poseidon CLI v2 test expectation to meter type 74
2026-08-31 13:21:24 +02:00

503 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using log4net;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace TBF.Rig.RegisterReaders.PoseidonCmdStartStop
{
public class CliRunner
{
private readonly ILog log;
private readonly List<CliTaskInfo> taskPool = new List<CliTaskInfo>();
private long startTimeMs;
private long incommingTimeMs;
public List<CliTaskInfo> TaskPool
{
get { return taskPool; }
}
public long StartTime
{
get { return startTimeMs; }
}
public long IncommingTime
{
get { return incommingTimeMs; }
}
public CliRunner(bool isCliLogging)
{
ResetStartTime();
if (isCliLogging)
{
try
{
log = new ScopedLoggerFactory().CreateLogger<CliRunner>(
@"C:\TBF\Logs\CliRunner.txt",
10,
7,
log4net.Core.Level.Debug,
true,
true,
TimeSpan.FromMinutes(2)
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize logger: {ex.Message}");
}
}
}
public void ResetStartTime()
{
startTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
public void Clear()
{
foreach (var item in taskPool)
{
try
{
item?.Cts?.Dispose();
}
catch
{
}
try
{
if (item?.Process != null)
{
item.Process.Dispose();
}
}
catch
{
}
}
taskPool.Clear();
}
public void WaitAll()
{
var tasks = taskPool
.Where(t => t != null && t.Task != null)
.Select(t => t.Task)
.ToArray();
if (tasks.Length == 0)
return;
try
{
Task.WaitAll(tasks);
}
catch (AggregateException)
{
RefreshTaskStates();
}
}
public bool AreTasksDone()
{
RefreshTaskStates();
return taskPool.Count > 0 && taskPool.All(t => t.State != CliTaskState.Running);
}
public bool TimeOutReceived(long timeoutMs)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return (nowMs - startTimeMs) > timeoutMs;
}
public void RefreshTaskStates()
{
foreach (var item in taskPool)
{
if (item == null || item.Task == null)
continue;
if (item.State == CliTaskState.TimedOut)
continue;
if (!item.Task.IsCompleted)
{
item.State = CliTaskState.Running;
}
else if (item.Task.IsCanceled)
{
item.State = CliTaskState.Canceled;
}
else if (item.Task.IsFaulted)
{
item.State = CliTaskState.Faulted;
}
else
{
item.State = CliTaskState.Completed;
}
}
}
public void CancelUndoneTasksAsTimedOut()
{
foreach (var item in taskPool)
{
if (item == null || item.Task == null)
continue;
if (item.Task.IsCompleted)
{
if (item.Task.IsCanceled)
item.State = CliTaskState.Canceled;
else if (item.Task.IsFaulted)
item.State = CliTaskState.Faulted;
else
item.State = CliTaskState.Completed;
continue;
}
item.State = CliTaskState.TimedOut;
try
{
item.Cts?.Cancel();
}
catch (Exception ex)
{
log?.Warn("Cancel token failed", ex);
}
try
{
if (item.Process != null && !item.Process.HasExited)
{
item.Process.Kill();
}
}
catch (Exception ex)
{
log?.Warn("Kill process failed", ex);
}
}
}
public void AddSendAsync(SerialPortData data, SerialPortData.EMeterArg eMeterArg)
{
AddSendAsync(data.SerialPortCmdClientPath, data.DefaultArgSettings(eMeterArg));
}
public void AddSendAsync(string fileName, string args)
{
ResetStartTime();
var cts = new CancellationTokenSource();
var info = new CliTaskInfo
{
Cts = cts,
Name = "SendAsync"
};
var task = SendAsync(fileName, args, info, cts.Token);
info.Task = task;
taskPool.Add(info);
}
public async Task<string> SendAsync(string fileName, string args, CliTaskInfo info, CancellationToken ct = default)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = args,
WorkingDirectory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(fileName)),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = new Process { StartInfo = psi, EnableRaisingEvents = true })
{
info.Process = process;
try
{
process.Start();
Task<string> stdOutTask = process.StandardOutput.ReadToEndAsync();
Task<string> stdErrTask = process.StandardError.ReadToEndAsync();
try
{
await Task.WhenAll(stdOutTask, stdErrTask, process.WaitForExitAsync(ct));
}
catch (OperationCanceledException)
{
if (info.State != CliTaskState.TimedOut)
info.State = CliTaskState.Canceled;
if (!process.HasExited)
{
process.Kill();
}
throw;
}
incommingTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
info.ExitCode = process.ExitCode;
info.StandardOutput = stdOutTask.Result ?? "";
info.StandardError = stdErrTask.Result ?? "";
string allOutput = info.StandardOutput + info.StandardError;
log?.Debug(allOutput);
if (info.ExitCode != 0)
{
info.FailureReason = $"CLI exited with exit code {info.ExitCode}.";
log?.Error($"{info.Name}: {info.FailureReason} stderr='{info.StandardError}'");
}
info.State = CliTaskState.Completed;
return allOutput;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
info.State = CliTaskState.Faulted;
log?.Error("SendAsync failed", ex);
throw;
}
finally
{
info.Process = null;
}
}
}
public void AddRunAndCaptureJsonAsync<T>(SerialPortData data, SerialPortData.EMeterArg eMeterArg) where T : new()
{
AddRunAndCaptureJsonAsync<T>(
data.SerialPortCmdClientPath,
data.DefaultArgSettings(eMeterArg));
}
public void AddRunAndCaptureJsonAsync<T>(string fileName, string args) where T : new()
{
// Tests deliberately create CliRunner without a logger. Starting a CLI task
// must not depend on diagnostics being configured.
log?.Debug($"CLI: {fileName}");
log?.Debug($"ARGS: {args}");
ResetStartTime();
var cts = new CancellationTokenSource();
var info = new CliTaskInfo
{
Cts = cts,
Name = $"RunAndCaptureJsonAsync<{typeof(T).Name}>"
};
var task = RunAndCaptureJsonAsync<T>(fileName, args, info, cts.Token);
info.Task = task;
taskPool.Add(info);
}
public async Task<T> RunAndCaptureJsonAsync<T>(string fileName, string args, CliTaskInfo info, CancellationToken ct = default) where T : new()
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = args,
WorkingDirectory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(fileName)),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = new Process { StartInfo = psi, EnableRaisingEvents = true })
{
info.Process = process;
try
{
log?.Debug($"FileName = '{psi.FileName}'");
log?.Debug($"Arguments = '{psi.Arguments}'");
log?.Debug($"WorkingDirectory = '{psi.WorkingDirectory}'");
log?.Debug($"Exists = {System.IO.File.Exists(psi.FileName)}");
process.Start();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct));
}
catch (OperationCanceledException)
{
if (info.State != CliTaskState.TimedOut)
info.State = CliTaskState.Canceled;
if (!process.HasExited)
{
process.Kill();
}
throw;
}
incommingTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
info.ExitCode = process.ExitCode;
info.StandardOutput = stdoutTask.Result ?? "";
info.StandardError = stderrTask.Result ?? "";
string allOutput = info.StandardOutput + info.StandardError;
log?.Debug(allOutput);
if (info.ExitCode != 0)
{
info.FailureReason = $"CLI exited with exit code {info.ExitCode}.";
log?.Error($"{info.Name}: {info.FailureReason} stderr='{info.StandardError}'");
info.State = CliTaskState.Completed;
return default(T);
}
string json = ExtractJson(allOutput);
T result;
if (TryJsonStringDeserialize(json, out result))
{
info.State = CliTaskState.Completed;
return result;
}
info.FailureReason = "CLI completed without a valid JSON response.";
log?.Error($"{info.Name}: {info.FailureReason} stdout='{info.StandardOutput}' stderr='{info.StandardError}'");
info.State = CliTaskState.Completed;
return default(T);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
info.State = CliTaskState.Faulted;
log?.Error("RunAndCaptureJsonAsync failed", ex);
throw;
}
finally
{
info.Process = null;
}
}
}
public bool TryJsonStringDeserialize<T>(string json, out T value) where T : new()
{
if (!string.IsNullOrWhiteSpace(json))
{
try
{
value = JsonConvert.DeserializeObject<T>(json);
return value != null;
}
catch (Exception ex)
{
log?.Debug(ex.Message);
return TryConvert(json, out value);
}
}
value = default(T);
return false;
}
private static bool TryConvert<T>(string json, out T value) where T : new()
{
T obj = new T();
try
{
JObject jObject = JObject.Parse(json);
foreach (PropertyInfo prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanWrite)
continue;
JToken token;
if (jObject.TryGetValue(prop.Name, StringComparison.OrdinalIgnoreCase, out token))
{
try
{
object propertyValue = token.ToObject(prop.PropertyType);
prop.SetValue(obj, propertyValue);
}
catch
{
}
}
}
value = obj;
return true;
}
catch (Exception ex)
{
Console.WriteLine($"TryConvert failed: {ex.Message}");
value = default(T);
return false;
}
}
public string ExtractJson(string text)
{
int start = text.IndexOf('{');
int end = text.LastIndexOf('}');
if (start >= 0 && end > start)
{
return text.Substring(start, end - start + 1);
}
return null;
}
internal void AddTaskForTest(Task task, string name = "TestTask", CancellationTokenSource cts = null)
{
taskPool.Add(new CliTaskInfo
{
Task = task,
Cts = cts,
Name = name,
State = task.IsCompleted
? (task.IsCanceled ? CliTaskState.Canceled :
task.IsFaulted ? CliTaskState.Faulted :
CliTaskState.Completed)
: CliTaskState.Running
});
}
}
}