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
@@ -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));
}
}