fix(scripts): legacy WriteBatchAndWaitAsync waits for the run's own reset echo (ScadaBridge#36)
When the run reset the response flag via SetAttribute before the six-argument handshake, wait for that reset to echo (shared timeout) before writing batch + trigger, so the previous run's cached CompleteFlag can't satisfy it. Covers the unported DB-owned MES/receiver scripts. Pending reset consumed per handshake. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -87,10 +87,11 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
|
||||
|
||||
/// <summary>
|
||||
/// Pins the PERMANENT legacy behaviour, not a bug awaiting a fix: the six-argument overload is
|
||||
/// deliberately unchanged (deployed scripts keep their semantics) and answers from the stale
|
||||
/// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in
|
||||
/// place; delete this test only if the legacy overload itself is removed.
|
||||
/// Pins the legacy behaviour when the run did NOT write a reset itself: the six-argument
|
||||
/// overload answers from the cache (level-triggered, the caller owns the reset). The waiter's
|
||||
/// fast-path is not "fixed" in place — the implicit reset echo (Gitea #36, 2026-11-10) engages
|
||||
/// only when this run's own <c>SetAttribute</c> reset the response attribute; see
|
||||
/// <see cref="LegacyOverload_AfterTheScriptsOwnReset_WaitsForTheEcho_ThenMatchesTheFreshTrue"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -109,6 +110,131 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
Assert.True(await call);
|
||||
}
|
||||
|
||||
private async Task ResetThroughScript(TestProbe dcl, ScriptRuntimeContext ctx)
|
||||
{
|
||||
// The MES script's own reset: `rx.Attributes["MoveOutCompleteFlag"] = false;` — a device
|
||||
// write the DCL acks, with NO echo yet (the cache still holds the previous run's True).
|
||||
var set = ctx.SetAttribute("MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!);
|
||||
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(CompleteTag, reset.TagPath);
|
||||
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
await set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36 regression (MesMoveOut, QA 2026-09-10): the script resets CompleteFlag and then
|
||||
/// calls the SIX-argument overload. Before the fix the response wait fast-pathed on the previous
|
||||
/// run's cached True (0 ms) and the script read stale results. Now the batch is held until the
|
||||
/// reset echoes, and only the device's fresh True completes the call.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_AfterTheScriptsOwnReset_WaitsForTheEcho_ThenMatchesTheFreshTrue()
|
||||
{
|
||||
var (actor, dcl, ctx) = Boot();
|
||||
await ResetThroughScript(dcl, ctx);
|
||||
|
||||
var call = ctx.WriteBatchAndWait(
|
||||
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10));
|
||||
|
||||
// Stale True in the cache, reset not echoed → no batch, no completion.
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
Assert.False(call.IsCompleted);
|
||||
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(TriggerTag, batch.TriggerTagPath);
|
||||
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
// The cache holds the echoed False — only THIS request's completion may finish the call.
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
Assert.False(call.IsCompleted);
|
||||
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
Assert.True(await call);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36: a reset that never echoes makes the legacy handshake report a timeout and write
|
||||
/// NOTHING — it must not complete from the stale cached True, nor send the trigger.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_AfterTheScriptsOwnReset_NoEcho_ReturnsFalse_AndNeverWritesTheBatch()
|
||||
{
|
||||
var (_, dcl, ctx) = Boot();
|
||||
await ResetThroughScript(dcl, ctx);
|
||||
|
||||
var matched = await ctx.WriteBatchAndWait(
|
||||
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromMilliseconds(700));
|
||||
|
||||
Assert.False(matched);
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36: the pending reset is consumed by the handshake that observed it — a second legacy
|
||||
/// handshake in the same run (no new reset) keeps the plain level-triggered behaviour instead
|
||||
/// of waiting for an echo of a reset that already happened.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_ResetIsConsumedByTheHandshakeThatObservedIt()
|
||||
{
|
||||
var (actor, dcl, ctx) = Boot();
|
||||
await ResetThroughScript(dcl, ctx);
|
||||
|
||||
var first = ctx.WriteBatchAndWait(
|
||||
Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10));
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
var b1 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
Assert.True(await first);
|
||||
|
||||
var second = ctx.WriteBatchAndWait(
|
||||
Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
|
||||
var b2 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
Assert.True(await second);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36: the reset-aware overload's own reset write is observed by its reset leg, so it
|
||||
/// must not be left pending for a later six-argument handshake in the same run (which would
|
||||
/// otherwise wait for an echo of a reset that already happened and time out).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetAwareOverload_ConsumesItsReset_SoALaterLegacyHandshakeIsNotGated()
|
||||
{
|
||||
var (actor, dcl, ctx) = Boot();
|
||||
|
||||
var first = ctx.WriteBatchResetAndWait(
|
||||
Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10));
|
||||
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||||
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
var b1 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
Assert.True((await first).Matched);
|
||||
|
||||
var second = ctx.WriteBatchAndWait(
|
||||
Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true),
|
||||
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(3));
|
||||
var b2 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(2));
|
||||
dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow));
|
||||
Assert.True(await second);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user