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:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -226,6 +227,16 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private int _logSeq;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36: scope-resolved attribute → codec-encoded value THIS run last wrote through
|
||||
/// <see cref="SetAttribute"/> and has not yet had a handshake consume. The six-argument
|
||||
/// <see cref="WriteBatchAndWait"/> reads it to recognise the script's own reset of the response
|
||||
/// flag (<c>CompleteFlag = false</c> right before the call) and waits for that reset to ECHO
|
||||
/// before writing the trigger, so the previous run's cached completion value can never satisfy
|
||||
/// the new handshake. Concurrent because a script may run writes on parallel tasks.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, string> _pendingResetWrites = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Step logs (design 2026-09-09 D4/D5): the <c>Log</c> global site scripts call —
|
||||
/// <c>Log.Debug/Info/Warn/Error(message, exception?)</c>.
|
||||
@@ -951,6 +962,10 @@ public class ScriptRuntimeContext
|
||||
throw new InvalidOperationException(
|
||||
$"SetAttribute('{attributeName}') failed: {response.ErrorMessage}");
|
||||
}
|
||||
|
||||
// Remember the write so a following legacy handshake on this attribute can wait for its
|
||||
// echo (Gitea #36). Last write wins; a handshake consumes the entry.
|
||||
_pendingResetWrites[attributeName] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -970,11 +985,48 @@ public class ScriptRuntimeContext
|
||||
/// <param name="responseEncoded">Codec-encoded target value (null ⇒ any change).</param>
|
||||
/// <param name="timeout">How long to wait for the response before returning false.</param>
|
||||
/// <returns><c>true</c> on response match within the timeout; <c>false</c> on timeout.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Implicit reset echo (Gitea #36, 2026-11-10).</b> When THIS run wrote
|
||||
/// <paramref name="responseAttr"/> through <see cref="SetAttribute"/> to a value other than
|
||||
/// <paramref name="responseEncoded"/> — the MES/Delmia pattern
|
||||
/// <c>CompleteFlag = false; WriteBatchAndWaitAsync(…, "CompleteFlag", true, …)</c> — the call
|
||||
/// first waits event-driven for the device to echo that reset and only then writes the batch and
|
||||
/// trigger. Without it the response wait fast-paths on the previous run's cached <c>true</c>
|
||||
/// (the reset has not echoed yet) and the script reads results the device has not written. The
|
||||
/// echo wait shares <paramref name="timeout"/> with the response wait, so the call never runs
|
||||
/// longer than before; a reset that never echoes returns <c>false</c> (timeout) and writes
|
||||
/// nothing. A call with no such prior write in this run behaves exactly as it always did —
|
||||
/// the caller still owns the reset, and the nine-argument reset-aware overload remains the
|
||||
/// explicit, phase-reporting form.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<bool> WriteBatchAndWait(
|
||||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||||
=> (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
|
||||
{
|
||||
if (_pendingResetWrites.TryRemove(responseAttr, out var resetEncoded)
|
||||
&& !string.Equals(resetEncoded, responseEncoded, StringComparison.Ordinal))
|
||||
{
|
||||
var budget = Stopwatch.StartNew();
|
||||
var echo = await WaitInternal(responseAttr, resetEncoded, null, timeout, requireGoodQuality: false)
|
||||
.ConfigureAwait(false);
|
||||
if (!echo.Matched)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Handshake on {Instance}.{Attribute}: the script's reset write ({Reset}) did not echo within {Timeout}s; "
|
||||
+ "trigger '{Trigger}' NOT written (a stale cached completion must not satisfy the handshake)",
|
||||
_instanceName, responseAttr, resetEncoded, timeout.TotalSeconds, triggerAttr);
|
||||
return false;
|
||||
}
|
||||
|
||||
var remaining = timeout - budget.Elapsed;
|
||||
timeout = remaining > TimeSpan.Zero ? remaining : TimeSpan.FromMilliseconds(1);
|
||||
}
|
||||
|
||||
return (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
|
||||
.ConfigureAwait(false)).Matched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset-aware handshake (design 2026-09-10 §5) backing the nine-argument
|
||||
@@ -1038,6 +1090,10 @@ public class ScriptRuntimeContext
|
||||
: $"Reset wait for '{resetAttr}' did not match"));
|
||||
}
|
||||
|
||||
// The reset leg's own SetAttribute recorded a pending write; it has now been observed,
|
||||
// so a later legacy handshake in this run must not wait for it again (Gitea #36).
|
||||
_pendingResetWrites.TryRemove(resetAttr, out _);
|
||||
|
||||
// Legs 3 + 4 — unchanged batch/trigger write and response wait.
|
||||
var response = await WriteBatchAndWaitCore(
|
||||
encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user