fix(management): FlowDebugRunner.RunScriptAsync — always write the root row after a routed call; timeout + unserializable-return tests

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 01:27:44 -04:00
parent 11d32335af
commit a4e8518e69
2 changed files with 127 additions and 9 deletions
@@ -158,8 +158,7 @@ public sealed class FlowDebugRunner
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
result = new InboundScriptResult(false, null,
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", TimeoutCode);
result = new InboundScriptResult(false, null, TimeoutMessage(timeout), TimeoutCode);
}
}
@@ -316,13 +315,31 @@ public sealed class FlowDebugRunner
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
timedOut = true;
response = new RouteToCallResponse(request.CorrelationId, false, null,
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace", DateTimeOffset.UtcNow);
response = new RouteToCallResponse(
request.CorrelationId, false, null, TimeoutMessage(timeout), DateTimeOffset.UtcNow);
}
}
// Everything from here to WriteCentralAsync runs AFTER the site already executed the
// script — a real side effect. Nothing in this stretch may throw, or the root row is never
// written and the operator sees ManagementActor.MapFault's generic "internal error" for a
// call that actually succeeded. So the trace drain skips a faulting event and the return
// value falls back to a placeholder, the same way InboundScriptExecutor turns a script's
// own faults into a captured error rather than an escaping exception.
foreach (var e in response.TraceEvents ?? [])
collector.Add(e);
{
try
{
collector.Add(e);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Flow Debugger dropped an inline trace event from the run of {Script} on {Instance}",
script.CanonicalName, instance.UniqueName);
}
}
var events = collector.Complete();
var rootRow = ScadaBridgeAuditEventFactory.Create(
@@ -338,7 +355,7 @@ public sealed class FlowDebugRunner
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: response.ErrorMessage,
requestSummary: cmd.Parameters is { } p ? p.GetRawText() : null,
responseSummary: response.ReturnValue is null ? null : JsonSerializer.Serialize(response.ReturnValue, ScriptTraceExtra.JsonOptions),
responseSummary: SerializeReturnValue(response.ReturnValue),
extra: new ScriptTraceExtra { Source = SourceTag, ChildExecutionId = response.ChildExecutionId }.ToJson());
await WriteCentralAsync(sp, rootRow, ct).ConfigureAwait(false);
@@ -477,6 +494,40 @@ public sealed class FlowDebugRunner
return new FlowDebugRunResult(rootId, true, null, null, events, false, startedAt, DateTimeOffset.UtcNow);
}
/// <summary>
/// The timed-out message shared by both Live entry points, so the two paths never drift.
/// </summary>
private static string TimeoutMessage(TimeSpan timeout) =>
$"Flow Debugger run timed out after {timeout.TotalSeconds}s + {RunGrace.TotalSeconds}s grace";
/// <summary>
/// Serializes a routed call's return value for the root row's <c>ResponseSummary</c>, NEVER
/// throwing: <see cref="ScriptTraceExtra.JsonOptions"/> rejects named floating-point literals,
/// so a script returning <c>double.NaN</c> / an infinity — or any other value the serializer
/// cannot represent — would otherwise take down the audit write of a call that already ran.
/// The placeholder keeps the row (and the operator's Live result) intact.
///
/// <para>
/// The catch is deliberately broad (as <see cref="WriteCentralAsync"/>'s already is): the value
/// crossing this seam came from a user-authored script, and the serializer's failure mode varies
/// by value — <c>ArgumentException</c> for a non-finite number, <see cref="JsonException"/> for a
/// cycle, <see cref="NotSupportedException"/> for an unmappable type. A best-effort audit summary
/// must not be the thing that fails a call the site already ran.
/// </para>
/// </summary>
private static string? SerializeReturnValue(object? returnValue)
{
if (returnValue is null) return null;
try
{
return JsonSerializer.Serialize(returnValue, ScriptTraceExtra.JsonOptions);
}
catch (Exception ex)
{
return $"<unserializable return value: {ex.GetType().Name}>";
}
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService<IActiveNodeGate>();