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
@@ -985,6 +985,12 @@ public class ScriptRuntimeContext
/// would never resolve at all. Holding the trigger until the echo lands makes the existing
/// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger
/// step, so a traced run shows all four.
/// <para>
/// The two waits are ADDITIVE: the call can run for <paramref name="resetTimeout"/> +
/// <paramref name="timeout"/> plus Ask slack, so size the script's
/// <c>ExecutionTimeoutSeconds</c> above that sum — a script deadline hit mid-leg propagates
/// as cancellation (spec §4.3), not as a <see cref="HandshakeResult"/>.
/// </para>
/// </remarks>
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
/// <param name="triggerAttr">Scope-resolved trigger attribute written AFTER the batch.</param>
@@ -993,17 +999,22 @@ public class ScriptRuntimeContext
/// <param name="responseEncoded">Codec-encoded response target (null ⇒ any change).</param>
/// <param name="timeout">Bound for the response wait.</param>
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
/// <param name="resetEncoded">Codec-encoded reset value the device must echo before anything else is written.</param>
/// <param name="resetEncoded">
/// Codec-encoded reset value the device must echo before anything else is written. NON-NULL by
/// contract: a null would write an empty string on leg 1 and then give the leg-2 waiter a null
/// target — "any change" — which fast-paths on the stale cached value and reinstates the very
/// race this overload exists to close. The accessor rejects a null reset value before it gets here.
/// </param>
/// <param name="resetTimeout">Bound for the reset-echo wait.</param>
/// <returns>A phase-aware result; never throws on either timeout.</returns>
public async Task<HandshakeResult> WriteBatchResetAndWait(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout,
string resetAttr, string? resetEncoded, TimeSpan resetTimeout)
string resetAttr, string resetEncoded, TimeSpan resetTimeout)
{
// Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then
// fast-paths); a data-sourced one goes to the device and the cache waits for the echo.
await SetAttribute(resetAttr, resetEncoded ?? string.Empty).ConfigureAwait(false);
await SetAttribute(resetAttr, resetEncoded).ConfigureAwait(false);
// Leg 2 — reset echo. Quality-agnostic, like the response wait.
var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false)
@@ -1084,7 +1095,7 @@ public class ScriptRuntimeContext
if (!batchResp.Success)
throw new InvalidOperationException(
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
$"Batch write failed: {batchResp.ErrorMessage}");
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
.ConfigureAwait(false);