mxaccesscli: clean up diag output
Rewrites DiagCommand to match the structure of read/write/subscribe:
- Output goes through CliFx's IConsole instead of System.Console
(kills the 6 CliFx_SystemConsoleShouldBeAvoided build warnings).
- Single concise summary line at the top: `diag <tag> (<sec>s, <status>)`.
- Status now correctly distinguishes ok / error / no-events. A bogus
reference produces a DataChange event with MxCategoryConfigurationError;
that reports as "error", not the previous (incorrect) "ok".
- Adds --llm-json mode mirroring read/write: { query, ok, results: [...] }
with register/add_item/events/totals fields.
- Captures both DataChange and OperationComplete events with status
detail rendered consistently.
- Drops the duplicate "Done. Total events: N" / "diag result: events=N"
trailing lines.
Also fixes exit-code propagation from soft-failure commands. Environment
.ExitCode set inside ExecuteAsync was being overwritten by CliFx's own
return from RunAsync. Program.Main now folds the two:
return cliFxExit != 0 ? cliFxExit : Environment.ExitCode
This restores correct exit-on-failure behavior for both `diag` (error
status) and `write` (timeout / non-Ok ack) without resorting to
CommandException, which would print "ERROR\n<stack trace>" even for an
empty message.
Verified:
diag TestChildObject.TestInt -> EXIT 0, "ok"
diag NoSuchObject.NoSuchAttr -> EXIT 1, "error"
diag --llm-json -> structured envelope, ok flag honors statuses
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
@@ -6,15 +7,18 @@ using ArchestrA.MxAccess;
|
|||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
|
using MxAccess.Cli.Mx;
|
||||||
|
using MxAccess.Cli.Output;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace MxAccess.Cli.Commands
|
namespace MxAccess.Cli.Commands
|
||||||
{
|
{
|
||||||
/// Minimal MxAccess smoke test — bypasses MxSession, MxItem and the
|
/// Minimal MxAccess smoke test on a fresh STA thread. Bypasses MxSession,
|
||||||
/// SyncContext shenanigans. Spins a fresh STA thread, registers, advises
|
/// the dispatched-from-CliFx STA thread, and the DoEvents-in-WaitForUpdate
|
||||||
/// one tag, and pumps the message loop with Application.DoEvents until
|
/// pump used by read/write/subscribe — everything is inline and explicit.
|
||||||
/// either an event arrives or the timeout fires. Used to isolate whether
|
/// Used to isolate whether the COM stack is reaching this process at all
|
||||||
/// COM event dispatch is reaching this process at all.
|
/// when the higher-level commands time out for unclear reasons.
|
||||||
[Command("diag", Description = "Diagnostic: minimal MxAccess smoke test on a private STA thread.")]
|
[Command("diag", Description = "Diagnostic smoke test on a private STA thread: register / add-item / advise / pump.")]
|
||||||
public sealed class DiagCommand : ICommand
|
public sealed class DiagCommand : ICommand
|
||||||
{
|
{
|
||||||
[CommandParameter(0, Name = "tag", Description = "Tag reference to probe.")]
|
[CommandParameter(0, Name = "tag", Description = "Tag reference to probe.")]
|
||||||
@@ -23,76 +27,171 @@ namespace MxAccess.Cli.Commands
|
|||||||
[CommandOption("seconds", 's', Description = "How long to pump the message loop. Default 5.")]
|
[CommandOption("seconds", 's', Description = "How long to pump the message loop. Default 5.")]
|
||||||
public double Seconds { get; init; } = 5.0;
|
public double Seconds { get; init; } = 5.0;
|
||||||
|
|
||||||
|
[CommandOption("llm-json", Description = "Emit a JSON envelope { query, ok, result } instead of human-readable phases.")]
|
||||||
|
public bool LlmJson { get; init; }
|
||||||
|
|
||||||
public ValueTask ExecuteAsync(IConsole console)
|
public ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var done = new ManualResetEventSlim(false);
|
var report = new DiagReport();
|
||||||
string result = null;
|
|
||||||
|
|
||||||
var t = new Thread(() =>
|
var t = new Thread(() => RunOnSta(report));
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var proxy = new LMXProxyServerClass();
|
|
||||||
int eventCount = 0;
|
|
||||||
LMXProxyServer __ = null; // keep delegate alive
|
|
||||||
proxy.OnDataChange += (int hSrv, int hItem, object value, int quality, object ts, ref MXSTATUS_PROXY[] vars) =>
|
|
||||||
{
|
|
||||||
eventCount++;
|
|
||||||
Console.WriteLine($" [DataChange] hItem={hItem} value={value} quality={quality}");
|
|
||||||
if (vars != null)
|
|
||||||
foreach (var v in vars)
|
|
||||||
Console.WriteLine($" status: cat={v.category} success={v.success} detectedBy={v.detectedBy} detail={v.detail}");
|
|
||||||
};
|
|
||||||
proxy.OperationComplete += (int hSrv, int hItem, ref MXSTATUS_PROXY[] vars) =>
|
|
||||||
{
|
|
||||||
Console.WriteLine($" [OperationComplete] hItem={hItem}");
|
|
||||||
if (vars != null)
|
|
||||||
foreach (var v in vars)
|
|
||||||
Console.WriteLine($" status: cat={v.category} success={v.success} detectedBy={v.detectedBy} detail={v.detail}");
|
|
||||||
};
|
|
||||||
|
|
||||||
int hSrv = proxy.Register("mxa-diag");
|
|
||||||
Console.WriteLine($"Register hServer = {hSrv}");
|
|
||||||
|
|
||||||
int hItem = proxy.AddItem(hSrv, Tag);
|
|
||||||
Console.WriteLine($"AddItem hItem = {hItem}");
|
|
||||||
|
|
||||||
proxy.Advise(hSrv, hItem);
|
|
||||||
Console.WriteLine($"Advise sent.");
|
|
||||||
|
|
||||||
// Pump WinForms-style for the duration. Application.DoEvents
|
|
||||||
// empties COM/window messages on each iteration.
|
|
||||||
var deadline = DateTime.UtcNow.AddSeconds(Seconds);
|
|
||||||
while (DateTime.UtcNow < deadline)
|
|
||||||
{
|
|
||||||
Application.DoEvents();
|
|
||||||
Thread.Sleep(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Done. Total events: {eventCount}");
|
|
||||||
proxy.UnAdvise(hSrv, hItem);
|
|
||||||
proxy.RemoveItem(hSrv, hItem);
|
|
||||||
proxy.Unregister(hSrv);
|
|
||||||
result = $"events={eventCount}";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
result = $"EXCEPTION: {ex.GetType().Name}: {ex.Message}";
|
|
||||||
Console.Error.WriteLine(result);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
done.Set();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
t.SetApartmentState(ApartmentState.STA);
|
t.SetApartmentState(ApartmentState.STA);
|
||||||
t.IsBackground = false;
|
t.IsBackground = false;
|
||||||
t.Start();
|
t.Start();
|
||||||
done.Wait();
|
|
||||||
t.Join();
|
t.Join();
|
||||||
|
|
||||||
console.Output.WriteLine($"diag result: {result}");
|
var query = new
|
||||||
|
{
|
||||||
|
command = "diag",
|
||||||
|
tag = Tag,
|
||||||
|
seconds = Seconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (LlmJson)
|
||||||
|
{
|
||||||
|
Envelope.Write(console, query, ok: report.Ok, results: new object[] { report.ToJsonObject() });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteHuman(console, report);
|
||||||
|
}
|
||||||
|
if (!report.Ok) Environment.ExitCode = 1;
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RunOnSta(DiagReport report)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proxy = new LMXProxyServerClass();
|
||||||
|
|
||||||
|
proxy.OnDataChange += (int hSrv, int hItem, object value, int quality, object ts, ref MXSTATUS_PROXY[] vars) =>
|
||||||
|
{
|
||||||
|
report.Events.Add(new DiagEvent
|
||||||
|
{
|
||||||
|
Kind = "DataChange",
|
||||||
|
Value = value,
|
||||||
|
Quality = quality,
|
||||||
|
Statuses = MxStatusInfo.From(vars),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
proxy.OperationComplete += (int hSrv, int hItem, ref MXSTATUS_PROXY[] vars) =>
|
||||||
|
{
|
||||||
|
report.Events.Add(new DiagEvent
|
||||||
|
{
|
||||||
|
Kind = "OperationComplete",
|
||||||
|
Statuses = MxStatusInfo.From(vars),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
report.HServer = proxy.Register("mxa-diag");
|
||||||
|
report.HItem = proxy.AddItem(report.HServer, Tag);
|
||||||
|
proxy.Advise(report.HServer, report.HItem);
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(Seconds);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
Application.DoEvents();
|
||||||
|
Thread.Sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.UnAdvise(report.HServer, report.HItem);
|
||||||
|
proxy.RemoveItem(report.HServer, report.HItem);
|
||||||
|
proxy.Unregister(report.HServer);
|
||||||
|
|
||||||
|
// Ok = at least one event arrived AND no event reported a
|
||||||
|
// non-Ok / non-Pending status. A DataChange with a
|
||||||
|
// ConfigurationError (bogus tag) counts as a failure.
|
||||||
|
report.Ok = report.Events.Count > 0;
|
||||||
|
foreach (var e in report.Events)
|
||||||
|
{
|
||||||
|
if (e.Statuses == null) continue;
|
||||||
|
foreach (var s in e.Statuses)
|
||||||
|
{
|
||||||
|
if (s.Category != MxStatusCategory.MxCategoryOk &&
|
||||||
|
s.Category != MxStatusCategory.MxCategoryPending)
|
||||||
|
{
|
||||||
|
report.Ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
report.Ok = false;
|
||||||
|
report.Error = $"{ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteHuman(IConsole console, DiagReport report)
|
||||||
|
{
|
||||||
|
string status;
|
||||||
|
if (report.Error != null) status = "error";
|
||||||
|
else if (report.Ok) status = "ok";
|
||||||
|
else if (report.Events.Count == 0) status = "no-events";
|
||||||
|
else status = "error";
|
||||||
|
console.Output.WriteLine($"diag {Tag} ({Seconds:F1}s, {status})");
|
||||||
|
|
||||||
|
if (report.HServer != 0) console.Output.WriteLine($" register hServer={report.HServer}");
|
||||||
|
if (report.HItem != 0) console.Output.WriteLine($" add-item hItem={report.HItem}");
|
||||||
|
|
||||||
|
for (int i = 0; i < report.Events.Count; i++)
|
||||||
|
{
|
||||||
|
var e = report.Events[i];
|
||||||
|
if (e.Kind == "DataChange")
|
||||||
|
{
|
||||||
|
console.Output.WriteLine($" event #{i + 1,-2} {e.Kind,-18} value={e.Value} quality={e.Quality}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
console.Output.WriteLine($" event #{i + 1,-2} {e.Kind,-18}");
|
||||||
|
}
|
||||||
|
foreach (var s in e.Statuses ?? Array.Empty<MxStatusInfo>())
|
||||||
|
{
|
||||||
|
console.Output.WriteLine($" status {s.Category} success={s.Success} detail={s.Detail} detectedBy={s.DetectedBy}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int dataChanges = 0, opCompletes = 0;
|
||||||
|
foreach (var e in report.Events) { if (e.Kind == "DataChange") dataChanges++; else opCompletes++; }
|
||||||
|
console.Output.WriteLine($" totals DataChange={dataChanges}, OperationComplete={opCompletes}");
|
||||||
|
|
||||||
|
if (report.Error != null)
|
||||||
|
console.Error.WriteLine($" error {report.Error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal collectors ----
|
||||||
|
|
||||||
|
private sealed class DiagReport
|
||||||
|
{
|
||||||
|
public int HServer;
|
||||||
|
public int HItem;
|
||||||
|
public bool Ok;
|
||||||
|
public string Error;
|
||||||
|
public List<DiagEvent> Events { get; } = new List<DiagEvent>();
|
||||||
|
|
||||||
|
public object ToJsonObject()
|
||||||
|
{
|
||||||
|
int dc = 0, op = 0;
|
||||||
|
foreach (var e in Events) { if (e.Kind == "DataChange") dc++; else op++; }
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
register = new { hServer = HServer },
|
||||||
|
add_item = new { hItem = HItem },
|
||||||
|
events = Events,
|
||||||
|
totals = new { data_change = dc, operation_complete = op },
|
||||||
|
error = Error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DiagEvent
|
||||||
|
{
|
||||||
|
public string Kind { get; init; }
|
||||||
|
public object Value { get; init; }
|
||||||
|
public int Quality { get; init; }
|
||||||
|
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public MxStatusInfo[] Statuses { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ namespace MxAccess.Cli
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
int exitCode = 0;
|
int cliFxExit = 0;
|
||||||
var thread = new Thread(() =>
|
var thread = new Thread(() =>
|
||||||
{
|
{
|
||||||
exitCode = new CliApplicationBuilder()
|
cliFxExit = new CliApplicationBuilder()
|
||||||
.SetTitle("mxa")
|
.SetTitle("mxa")
|
||||||
.SetExecutableName("mxa")
|
.SetExecutableName("mxa")
|
||||||
.SetDescription("Read / write / subscribe AVEVA System Platform tags via MxAccess.")
|
.SetDescription("Read / write / subscribe AVEVA System Platform tags via MxAccess.")
|
||||||
@@ -33,7 +33,12 @@ namespace MxAccess.Cli
|
|||||||
thread.IsBackground = false;
|
thread.IsBackground = false;
|
||||||
thread.Start();
|
thread.Start();
|
||||||
thread.Join();
|
thread.Join();
|
||||||
return exitCode;
|
|
||||||
|
// CliFx returns its own exit code from RunAsync; commands signal
|
||||||
|
// soft failures (e.g. timeout, non-Ok MxStatusCategory) via
|
||||||
|
// Environment.ExitCode. Fold both so a failure from either path
|
||||||
|
// surfaces as a non-zero process exit.
|
||||||
|
return cliFxExit != 0 ? cliFxExit : Environment.ExitCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user