fix(central-ui,cli): Flow Debugger client waits derive from the method timeout / wait window instead of a fixed 2 minutes (arch-review F4)

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:13:02 -04:00
parent cccef7b2f4
commit 97b0a4ba16
5 changed files with 111 additions and 17 deletions
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -23,21 +24,46 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// the run/replay commands go to the per-node <c>ManagementActor</c> through
/// <see cref="ManagementActorHolder"/>, exactly as <see cref="DataConnectionMoveService"/>
/// does, with the current Blazor principal projected to an <see cref="AuthenticatedUser"/>
/// so the server's Deployer gate runs against the real identity. The Live-run Ask waits up
/// to <see cref="RunAskTimeout"/> (the server bounds a run by the method timeout + grace and
/// a value-change by its wait window; both fit inside two minutes).
/// so the server's Deployer gate runs against the real identity. The Live-run Ask wait is
/// derived from the server's own bound (<see cref="RunAskTimeoutFor"/> /
/// <see cref="ValueChangeAskTimeoutFor"/>) rather than a fixed duration (arch-review F4) —
/// a run against a method with a long <c>TimeoutSeconds</c> used to be cut off client-side
/// well before the server's own grace period expired.
/// Retry / Discard of a parked outbound node (follow-ups plan 2026-09-06 D4) goes through
/// <see cref="CommunicationService"/> to the SAME central-local proxies the Site Calls and
/// Notification report pages use, so the page and the report never disagree on what a relay does.
/// </summary>
public sealed class FlowDebugService : IFlowDebugService
{
/// <summary>Ask timeout for the two Live-run commands.</summary>
public static readonly TimeSpan RunAskTimeout = TimeSpan.FromMinutes(2);
/// <summary>Ask timeout for Replay (a central DB read).</summary>
public static readonly TimeSpan ReplayAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>Slack the client adds over the server's own bound (Ask hop + serialization).</summary>
public static readonly TimeSpan ClientSlack = TimeSpan.FromSeconds(15);
/// <summary>Floor for a method-run Ask wait, regardless of how short the method's own timeout is.</summary>
public static readonly TimeSpan MinRunAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Client-side Ask wait for <see cref="RunMethodAsync"/>: the method's own
/// <c>TimeoutSeconds</c> plus the server's <see cref="FlowDebugRunner.RunGrace"/> plus
/// <see cref="ClientSlack"/>, floored at <see cref="MinRunAskTimeout"/> — the server bounds
/// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>),
/// so the client must wait at least that long or it cuts the run off before the server does.
/// </summary>
public static TimeSpan RunAskTimeoutFor(int timeoutSeconds)
{
var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack;
return t < MinRunAskTimeout ? MinRunAskTimeout : t;
}
/// <summary>
/// Client-side Ask wait for <see cref="RunValueChangeAsync"/>: the trigger-wait window
/// plus <see cref="ClientSlack"/>.
/// </summary>
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
/// <summary>Upper bound on the template parent chain the script resolver walks (cycle guard).</summary>
private const int MaxTemplateChain = 16;
@@ -176,12 +202,19 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <inheritdoc/>
public Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default)
=> SendAndBuildAsync(new FlowDebugRunMethodCommand(apiMethodId, parameters), RunAskTimeout, cancellationToken);
public async Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default)
{
var method = await _inboundApi.GetApiMethodByIdAsync(apiMethodId, cancellationToken);
var timeout = RunAskTimeoutFor(method?.TimeoutSeconds ?? 30);
return await SendAndBuildAsync(new FlowDebugRunMethodCommand(apiMethodId, parameters), timeout, cancellationToken);
}
/// <inheritdoc/>
public Task<FlowDebugOutcome> RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default)
=> SendAndBuildAsync(new FlowDebugRunValueChangeCommand(instanceId, attributeName, value), RunAskTimeout, cancellationToken);
{
var command = new FlowDebugRunValueChangeCommand(instanceId, attributeName, value);
return SendAndBuildAsync(command, ValueChangeAskTimeoutFor(command.WaitSeconds), cancellationToken);
}
/// <inheritdoc/>
public Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default)