fix(site-runtime): trace sink is guarded — a throwing observer can neither fail a call nor skip the audit write

Review of Task 3: the sink invoke sat before WriteAsync/ForwardAsync inside the
best-effort try, so a throwing sink would have silently dropped the durable
row. GuardTraceSink wraps the delegate in every helper ctor (and Collect
guards EmitTrace); pinned by a throwing-sink test. ScriptRunLauncher no longer
allocates a collector for a traced run with no reply target (Nobody) — the
list would never be read. FlowTraceCollector.Complete pinned at unit level;
Snapshot documented as a non-closing peek.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:57:09 -04:00
parent 69feb40e65
commit ec9011429c
5 changed files with 92 additions and 8 deletions
@@ -306,7 +306,39 @@ public class ScriptRuntimeContext
/// outbound rows the helpers emit — passes through here; a no-op when untraced or once
/// the launcher has <see cref="FlowTraceCollector.Complete"/>d the run.
/// </summary>
private void Collect(AuditEvent evt) => _traceCollector?.Add(evt);
private void Collect(AuditEvent evt)
{
try
{
_traceCollector?.Add(evt);
}
catch (Exception ex)
{
// The collector is a debug-only observer: it must never fail a script call, and it
// must never keep the durable audit row from reaching the writer (review, 2026-09-06).
_logger.LogWarning(ex, "Flow trace collector rejected {Action} ({EventId})", evt.Action, evt.EventId);
}
}
/// <summary>
/// Wraps a trace sink so a throwing observer can neither fail the script call nor skip the
/// durable audit write that follows it. Null in, null out — an untraced run allocates nothing.
/// </summary>
internal static Action<AuditEvent>? GuardTraceSink(Action<AuditEvent>? sink, ILogger logger)
{
if (sink is null) return null;
return evt =>
{
try
{
sink(evt);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Flow trace sink rejected {Action} ({EventId})", evt.Action, evt.EventId);
}
};
}
/// <summary>Flow Debugger (design 2026-09-05 §5.2): whether this run emits <c>ScriptTrace</c> rows.</summary>
public bool IsTraced => _isTraced;
@@ -1146,7 +1178,7 @@ public class ScriptRuntimeContext
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
_traceSink = GuardTraceSink(traceSink, logger);
}
/// <summary>
@@ -1850,7 +1882,7 @@ public class ScriptRuntimeContext
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
_traceSink = GuardTraceSink(traceSink, logger);
}
/// <summary>
@@ -2279,7 +2311,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
_traceSink = GuardTraceSink(traceSink, logger);
}
/// <summary>
@@ -2455,7 +2487,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = traceSink;
_traceSink = GuardTraceSink(traceSink, logger);
}
/// <summary>