From 7cbe2756d0d652f98ae8cdfb77f184c52ebabadf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 3 May 2026 20:19:40 -0400 Subject: [PATCH] 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 (s, )`. - 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" 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) --- .../src/MxAccess.Cli/Commands/DiagCommand.cs | 233 +++++++++++++----- mxaccesscli/src/MxAccess.Cli/Program.cs | 11 +- 2 files changed, 174 insertions(+), 70 deletions(-) diff --git a/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs b/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs index a66037a..c4c8db4 100644 --- a/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs +++ b/mxaccesscli/src/MxAccess.Cli/Commands/DiagCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; @@ -6,15 +7,18 @@ using ArchestrA.MxAccess; using CliFx; using CliFx.Attributes; using CliFx.Infrastructure; +using MxAccess.Cli.Mx; +using MxAccess.Cli.Output; +using Newtonsoft.Json; namespace MxAccess.Cli.Commands { - /// Minimal MxAccess smoke test — bypasses MxSession, MxItem and the - /// SyncContext shenanigans. Spins a fresh STA thread, registers, advises - /// one tag, and pumps the message loop with Application.DoEvents until - /// either an event arrives or the timeout fires. Used to isolate whether - /// COM event dispatch is reaching this process at all. - [Command("diag", Description = "Diagnostic: minimal MxAccess smoke test on a private STA thread.")] + /// Minimal MxAccess smoke test on a fresh STA thread. Bypasses MxSession, + /// the dispatched-from-CliFx STA thread, and the DoEvents-in-WaitForUpdate + /// pump used by read/write/subscribe — everything is inline and explicit. + /// Used to isolate whether the COM stack is reaching this process at all + /// when the higher-level commands time out for unclear reasons. + [Command("diag", Description = "Diagnostic smoke test on a private STA thread: register / add-item / advise / pump.")] public sealed class DiagCommand : ICommand { [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.")] 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) { - var done = new ManualResetEventSlim(false); - string result = null; + var report = new DiagReport(); - var t = new Thread(() => - { - 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(); - } - }); + var t = new Thread(() => RunOnSta(report)); t.SetApartmentState(ApartmentState.STA); t.IsBackground = false; t.Start(); - done.Wait(); 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; } + + 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()) + { + 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 Events { get; } = new List(); + + 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; } + } } } diff --git a/mxaccesscli/src/MxAccess.Cli/Program.cs b/mxaccesscli/src/MxAccess.Cli/Program.cs index 034abd3..1ffd922 100644 --- a/mxaccesscli/src/MxAccess.Cli/Program.cs +++ b/mxaccesscli/src/MxAccess.Cli/Program.cs @@ -16,10 +16,10 @@ namespace MxAccess.Cli [STAThread] public static int Main(string[] args) { - int exitCode = 0; + int cliFxExit = 0; var thread = new Thread(() => { - exitCode = new CliApplicationBuilder() + cliFxExit = new CliApplicationBuilder() .SetTitle("mxa") .SetExecutableName("mxa") .SetDescription("Read / write / subscribe AVEVA System Platform tags via MxAccess.") @@ -33,7 +33,12 @@ namespace MxAccess.Cli thread.IsBackground = false; thread.Start(); 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; } } }