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:
@@ -61,6 +61,13 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on the resolve-and-rewalk passes of <see cref="PredictMethodAsync"/>. Each pass can
|
||||
/// only discover targets one nesting level deeper than the last, so the predictor's own depth cap
|
||||
/// bounds the useful pass count; the constant is a safety net, not a tuning knob.
|
||||
/// </summary>
|
||||
internal const int MaxPredictionPasses = 8;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<PredictedFlow> PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -68,32 +75,53 @@ public sealed class FlowDebugService : IFlowDebugService
|
||||
if (method == null)
|
||||
return PredictedFlow.Empty;
|
||||
|
||||
// The predictor is synchronous and takes a synchronous resolver, so the instance →
|
||||
// template → script lookups are pre-fetched lazily through a small memo that blocks
|
||||
// on the repository — acceptable for a handful of constant route targets, and the
|
||||
// predictor never asks for anything else.
|
||||
var memo = new Dictionary<(string, string), string?>();
|
||||
string? Resolve(string instanceCode, string scriptName)
|
||||
// The predictor is synchronous and takes a synchronous resolver, and this method runs on the
|
||||
// Blazor circuit's single-threaded renderer sync context — so the instance → template → script
|
||||
// lookups must NEVER block on the repository from inside the resolver. The original
|
||||
// `.GetAwaiter().GetResult()` there deadlocked the whole circuit (every later click and JS
|
||||
// interop reply queued behind the blocked dispatcher forever) the moment a method whose script
|
||||
// routes to a site script was chosen; found 2026-09-06 while chasing a "login-disabled interop
|
||||
// stall" that was really this. Instead the walk is repeated: each pass records the route targets
|
||||
// it asked for and could not answer, those are awaited between passes, and the walk converges
|
||||
// once a pass asks for nothing new. Cheap — a pass is a Roslyn syntax walk over a short script.
|
||||
var memo = new Dictionary<(string Instance, string Script), string?>();
|
||||
var script = method.Script ?? string.Empty;
|
||||
for (var pass = 0; pass < MaxPredictionPasses; pass++)
|
||||
{
|
||||
var key = (instanceCode, scriptName);
|
||||
if (memo.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
string? body;
|
||||
try
|
||||
var requested = new List<(string Instance, string Script)>();
|
||||
string? Resolve(string instanceCode, string scriptName)
|
||||
{
|
||||
body = ResolveSiteScriptAsync(instanceCode, scriptName, cancellationToken).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Flow prediction could not resolve {Instance}.{Script}", instanceCode, scriptName);
|
||||
body = null;
|
||||
var key = (instanceCode, scriptName);
|
||||
if (memo.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
if (!requested.Contains(key))
|
||||
requested.Add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
memo[key] = body;
|
||||
return body;
|
||||
var flow = FlowShapePredictor.PredictInboundMethod(method.Name, script, Resolve);
|
||||
if (requested.Count == 0)
|
||||
return flow;
|
||||
|
||||
foreach (var key in requested)
|
||||
{
|
||||
string? body;
|
||||
try
|
||||
{
|
||||
body = await ResolveSiteScriptAsync(key.Instance, key.Script, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Flow prediction could not resolve {Instance}.{Script}", key.Instance, key.Script);
|
||||
body = null;
|
||||
}
|
||||
|
||||
memo[key] = body;
|
||||
}
|
||||
}
|
||||
|
||||
return FlowShapePredictor.PredictInboundMethod(method.Name, method.Script ?? string.Empty, Resolve);
|
||||
// Pathological: a walk still discovering new targets past the pass cap — predict with what is known.
|
||||
return FlowShapePredictor.PredictInboundMethod(method.Name, script, (i, s) => memo.GetValueOrDefault((i, s)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
Reference in New Issue
Block a user