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.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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user