fix(central-ui): Flow Debugger prediction no longer deadlocks the Blazor circuit
FlowDebugService.PredictMethodAsync resolved route targets with a sync-over-async GetAwaiter().GetResult() inside the predictor's synchronous resolver — on the circuit's single-threaded renderer sync context that blocked the dispatcher forever the moment a method whose script routes to a site script was chosen (every later click and JS-interop reply queued behind it; the page looked inert with a healthy websocket and no error). Misdiagnosed on 2026-09-06 as a login-disabled localStorage stall. Now a resolve-and-rewalk loop: each pass records the targets it could not answer, they are awaited between passes, and the walk converges once a pass asks for nothing new (bounded by MaxPredictionPasses). Regression test drives the service under a single-thread pump sync context (fails in 10 s on the old code, passes now) plus a nested Scripts.Call resolve-across-passes test. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
@@ -199,6 +200,116 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
Assert.Equal("Sim01.Cmd", flow.Nodes[3].Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for the 2026-09-06 circuit deadlock: <see cref="FlowDebugService.PredictMethodAsync"/>
|
||||
/// runs on Blazor Server's single-threaded renderer sync context, and the route-target resolver used
|
||||
/// to block on the repository with <c>GetAwaiter().GetResult()</c> from inside the synchronous
|
||||
/// predictor walk. A repository continuation posted back to that context could then never run —
|
||||
/// the whole circuit hung (every later click / JS-interop reply queued behind the blocked dispatcher).
|
||||
/// The pump below IS that context: one thread, posts queued, a blocked callback starves the queue.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PredictMethod_OnASingleThreadedSyncContext_NeverBlocksTheDispatcher()
|
||||
{
|
||||
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>())
|
||||
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
||||
// The instance lookup completes only after a Yield — i.e. only when the sync context gets to
|
||||
// run the continuation. Blocking on it from the context's own thread is the deadlock.
|
||||
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
||||
.Returns(_ => YieldThen(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }));
|
||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
||||
[
|
||||
new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1);""") { Id = 1, TemplateId = 10 },
|
||||
]));
|
||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
|
||||
var svc = CreateService(reply: null);
|
||||
|
||||
var pump = new SingleThreadPump();
|
||||
PredictedFlow? flow = null;
|
||||
Exception? failure = null;
|
||||
var done = new ManualResetEventSlim();
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { flow = pump.Run(() => svc.PredictMethodAsync(4)); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
finally { done.Set(); }
|
||||
}) { IsBackground = true, Name = "flow-predict-pump" };
|
||||
thread.Start();
|
||||
|
||||
Assert.True(done.Wait(TimeSpan.FromSeconds(10)),
|
||||
"PredictMethodAsync deadlocked the single-threaded sync context (sync-over-async in the route resolver).");
|
||||
Assert.Null(failure);
|
||||
Assert.NotNull(flow);
|
||||
Assert.Contains(flow!.Nodes, n => n.Kind == FlowStepKind.ScriptRun && n.Title == "Sim01.Process");
|
||||
Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses()
|
||||
{
|
||||
// Process calls a second script on the same instance via Scripts.Call — discovered only once
|
||||
// Process itself has been resolved, so the service needs a second resolve-and-rewalk pass.
|
||||
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>())
|
||||
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
||||
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
||||
[
|
||||
new TemplateScript("Process", """await Scripts.Call("Inner", new { });""") { Id = 1, TemplateId = 10 },
|
||||
new TemplateScript("Inner", """await Attributes.SetAsync("Cmd", 1);""") { Id = 2, TemplateId = 10 },
|
||||
]));
|
||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
|
||||
var svc = CreateService(reply: null);
|
||||
|
||||
var flow = await svc.PredictMethodAsync(4);
|
||||
|
||||
var kinds = flow.Nodes.Select(n => n.Kind).ToList();
|
||||
Assert.Contains(FlowStepKind.AttributeWrite, kinds);
|
||||
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Inner");
|
||||
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Cmd");
|
||||
}
|
||||
|
||||
private static async Task<Instance?> YieldThen(Instance instance)
|
||||
{
|
||||
await Task.Yield();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>A Blazor-Server-shaped sync context: a single thread draining a queue of posted callbacks.</summary>
|
||||
private sealed class SingleThreadPump : SynchronizationContext
|
||||
{
|
||||
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
|
||||
|
||||
public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException();
|
||||
|
||||
public T Run<T>(Func<Task<T>> body)
|
||||
{
|
||||
var previous = Current;
|
||||
SetSynchronizationContext(this);
|
||||
try
|
||||
{
|
||||
var task = body();
|
||||
while (!task.IsCompleted)
|
||||
{
|
||||
var (callback, state) = _queue.Take();
|
||||
callback(state);
|
||||
}
|
||||
|
||||
return task.GetAwaiter().GetResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetSynchronizationContext(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictMethod_UnknownMethod_IsEmpty()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user