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:
Joseph Doherty
2026-05-03 20:19:40 -04:00
parent ab202a1fa1
commit 7cbe2756d0
2 changed files with 174 additions and 70 deletions
@@ -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<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; }
}
}
}
+8 -3
View File
@@ -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;
}
}
}