fix(scripts): reset-aware handshake refuses a null reset value; document additive timeouts (#36 review)

A null resetValue encoded to null, which wrote an empty string and then handed the
waiter a null target (= any change), fast-pathing on the stale cache and reinstating
the race the overload exists to close. The context parameter is now non-nullable and
the accessor throws ArgumentException before any write. Also: additive-timeout note on
all surfaces, batch-failure message no longer names the bool overload, and the
error-message/batch-failure branches are pinned.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
Joseph Doherty
2026-09-10 15:35:50 -04:00
parent 637da6c020
commit 17d5d3e639
8 changed files with 138 additions and 12 deletions
@@ -109,7 +109,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromSeconds(10));
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10));
// Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has
// not echoed anything yet.
@@ -152,7 +152,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromMilliseconds(700));
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromMilliseconds(700));
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
@@ -341,4 +341,33 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
// Nothing else was written: no batch, no trigger.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Gitea #36 review: a null <c>resetValue</c> would encode to null, which writes an empty
/// string on leg 1 and then hands the waiter a null target — "any change" — which fast-paths
/// on the stale cached value and reinstates exactly the race this overload exists to close.
/// The accessor rejects it before anything reaches the InstanceActor.
/// </summary>
[Fact]
public void WriteBatchAndWaitAsync_ResetAware_NullResetValue_ThrowsBeforeAnyWrite()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
// Block body (not an expression lambda) so this binds the Action overload: the throw is
// SYNCHRONOUS — a faulted task would leave nothing for Assert.Throws to catch.
var ex = Assert.Throws<ArgumentException>(() =>
{
_ = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", null);
});
Assert.Equal("resetValue", ex.ParamName);
// Thrown before leg 1: the reset write never left the accessor.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}
@@ -24,7 +24,10 @@ public class WriteBatchResetAndWaitTests : TestKit
new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance),
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
// 30s: the legs Ask the InstanceActor with their own bounds (the batch's internal 35s,
// the waits' own timeouts); a short ambient Ask timeout would race those and make the
// reset/response phases indistinguishable from an AskTimeoutException.
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(30),
instanceName: Instance, logger: NullLogger.Instance);
[Fact]
@@ -125,6 +128,64 @@ public class WriteBatchResetAndWaitTests : TestKit
Assert.True(result.TimedOut);
Assert.Equal(HandshakePhase.Response, result.Phase);
Assert.False(result.ResetFailed);
// The message names the attribute that did not answer and the bound it blew.
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
Assert.Contains("25", result.ErrorMessage);
}
/// <summary>
/// A reset wait that fails WITHOUT timing out (a predicate blew up in the waiter) surfaces the
/// actor's own message verbatim rather than a synthesized one, and still writes nothing further.
/// </summary>
[Fact]
public async Task ResetWaitError_PassesTheActorsMessageThrough()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(
resetWait.CorrelationId, Matched: false, null, null, TimedOut: false, ErrorMessage: "predicate threw"));
var result = await call;
Assert.False(result.Matched);
Assert.False(result.TimedOut);
Assert.True(result.ResetFailed);
Assert.Equal("predicate threw", result.ErrorMessage);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// The batch leg keeps throwing after a successful reset — a device-rejected batch is a hard
/// failure, not a <see cref="HandshakeResult"/> the script could mistake for a timeout.
/// </summary>
[Fact]
public async Task BatchFailureAfterASuccessfulReset_StillThrows()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, false, "gateway rejected"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
Assert.Contains("gateway rejected", ex.Message);
Assert.Contains("Batch write failed", ex.Message);
}
[Fact]