fix(site-runtime): trace arm is cleared when the traced DCL write fails (arch-review F6)

The Flow Debugger trace arm was recorded when a traced write was ISSUED, not
when the DCL acknowledged it. A write the device rejected (or that timed out)
left the arm standing for the whole TraceArmWindowSeconds (30 s), so the next
unrelated subscription update on that attribute was stamped with the debugger's
execution id and spawned a traced, parented run — the debugger drew a "device
echo" of a write that never landed.

The write's continuation runs off the actor's dispatcher, so it cannot touch
_traceArms directly; it Tells self a new DisarmTrace message instead, handled in
a Receive<>. The disarm is keyed on the writer's execution id, so a late failure
cannot steal an arm a newer traced write has since taken.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:12:09 -04:00
parent cccef7b2f4
commit 50bf9ac74a
2 changed files with 95 additions and 1 deletions
@@ -89,6 +89,17 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.True(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5)).Success);
}
/// <summary>Same as <see cref="Write"/> but the DCL rejects the write, so the arm must be dropped.</summary>
private void FailingWrite(IActorRef actor, TestProbe dcl, string instance, Guid? source, bool trace)
{
actor.Tell(new SetStaticAttributeCommand("c", instance, "Processed", "true", DateTimeOffset.UtcNow,
SourceExecutionId: source, Trace: trace));
var write = ExpectWrite(dcl);
Assert.Equal(Tag, write.TagPath);
dcl.Reply(new WriteTagResponse(write.CorrelationId, false, "rejected", DateTimeOffset.UtcNow));
Assert.False(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5)).Success);
}
private static AttributeValueChanged Echo(IActorRef actor, TestProbe subscriber, object? value)
{
actor.Tell(new TagValueUpdate("PLC", Tag, value, QualityCode.Good, DateTimeOffset.UtcNow));
@@ -123,6 +134,54 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
Assert.False(published.Trace);
}
/// <summary>
/// Arch-review F6: the arm is recorded when the write is ISSUED, not when the DCL acknowledges
/// it. A write the device rejects must drop the arm, otherwise the next unrelated subscription
/// update on that attribute is stamped with the debugger's execution id for the rest of the
/// 30 s window — the Flow Debugger would draw a "device echo" of a write that never landed.
/// </summary>
[Fact]
public void TracedDataWrite_ThatFails_DisarmsTheAttribute_SoTheNextEchoIsUnstamped()
{
var (actor, dcl, sub) = Create("P6");
FailingWrite(actor, dcl, "P6", Guid.NewGuid(), trace: true);
var published = Echo(actor, sub, "unrelated");
Assert.Null(published.SourceExecutionId);
Assert.False(published.Trace);
}
/// <summary>
/// A failed write must only drop ITS OWN arm — a newer traced write that succeeded in the
/// meantime owns the arm and keeps it (the disarm is keyed on the writer's execution id).
/// </summary>
[Fact]
public void FailedWrite_DoesNotDisarm_AnArmTakenByALaterWriter()
{
var (actor, dcl, sub) = Create("P7");
var loser = Guid.NewGuid();
var winner = Guid.NewGuid();
// Issue the traced write that will fail, but hold its DCL reply back. The reply goes to the
// Ask's temporary ref, so capture it now — a later Reply() would answer the second write.
actor.Tell(new SetStaticAttributeCommand("c1", "P7", "Processed", "true", DateTimeOffset.UtcNow,
SourceExecutionId: loser, Trace: true));
var first = ExpectWrite(dcl);
var firstAsker = dcl.LastSender;
// A second traced write lands and succeeds — it now owns the arm.
Write(actor, dcl, "P7", winner, trace: true);
// Only now does the first write fail; its disarm must not steal the winner's arm.
firstAsker.Tell(new WriteTagResponse(first.CorrelationId, false, "rejected", DateTimeOffset.UtcNow), dcl.Ref);
Assert.False(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5)).Success);
var published = Echo(actor, sub, "echo");
Assert.Equal(winner, published.SourceExecutionId);
Assert.True(published.Trace);
}
[Fact]
public void Arm_IsConsumedOnce()
{