Start Debugging

Solución: Attempting to reconnect to the server tras desconectarse un circuito de Blazor Server

El modal de reconexión significa que el circuito de SignalR se cayó, no que tu aplicación falló. Averigua si el reintento terminó en failed o en rejected y arregla la afinidad de sesión, la ventana de retención de 3 minutos, el límite de 32 KB o persiste el estado con [PersistentState].

El modal no es un error, es Blazor avisándote de que el circuito de SignalR se cayó y el cliente está reintentando. Lo que importa es cómo termina el reintento. Si termina en failed (“Reconnection failed”, “Failed to rejoin”), el navegador nunca llegó al servidor: revisa la ruta del WebSocket a través de tu proxy, los tiempos de keep-alive y el límite de 32 KB de MaximumReceiveMessageSize. Si termina en rejected (“Could not reconnect to the server”, “Failed to resume the session”), sí se llegó al servidor y este rechazó la conexión: el circuito ya no existe porque la aplicación se reinició, porque el balanceador te mandó a otra instancia sin afinidad de sesión, o porque venció el DisconnectedCircuitRetentionPeriod de 3 minutos. En .NET 10 y .NET 11, la respuesta duradera para ese último grupo es dejar de preocuparte por la identidad del circuito y marcar tu estado con [PersistentState].

Attempting to reconnect to the server: 3 of 8
Reconnection failed. Try reloading the page if you're unable to reconnect.
Could not reconnect to the server. Reload the page to restore functionality.

Esos son los textos de .NET 8 y anteriores, y son los que la mayoría pega en el buscador. En .NET 9 y posteriores los mismos estados tienen otra redacción, y por eso los resultados de búsqueda parecen hablar de otro problema:

Rejoining the server...
Rejoin failed... trying again in 5 seconds.
Failed to rejoin. Please retry or reload the page.
The session has been paused by the server.
Failed to resume the session. Please retry or reload the page.

Todo lo que sigue está verificado contra .NET 11 Preview 6 (SDK 11.0.100-preview.6.26359.118) con la plantilla Blazor Web App en renderizado Interactive Server, y señala dónde se comportan distinto .NET 8, 9 y 10. Blazor WebAssembly no tiene circuito, así que si ves este modal tus componentes se están renderizando con InteractiveServer o con InteractiveAuto resuelto por ahora al servidor.

Por qué un WebSocket caído produce un modal y no una excepción

Una aplicación Blazor de servidor mantiene el árbol de componentes, cada campo de cada instancia de componente y cada servicio de DI con ámbito de circuito en la memoria del servidor. Ese conjunto es el circuito. El navegador solo guarda un DOM renderizado y una conexión SignalR; cada clic es una llamada remota al servidor y cada render es un diff que vuelve. Si se rompe la conexión, el navegador no tiene con qué renderizar, así que el framework cubre la página e intenta volver a engancharse al mismo circuito por su ID.

Nadie tiene que escribir esa interfaz. Si tu aplicación define un elemento con id="components-reconnect-modal", Blazor le aplica y le quita clases CSS. Si no existe, Blazor inyecta su propio modal integrado, y de ahí sale el texto clásico. Esa es la parte importante para depurar: el mensaje que ves se genera por completo en el cliente, a partir de estado del cliente. No te dice nada sobre lo que el servidor cree que pasó. La versión del servidor está en tus registros.

Los tres estados finales, y cuál tienes en realidad

Desde .NET 10 el framework lanza un evento components-reconnect-state-changed sobre el elemento del modal y aplica la clase CSS correspondiente, así que puedes leer el resultado en vez de adivinarlo:

Clase CSSdetail.state del eventoSignificado
components-reconnect-showshowConexión perdida, reintentando.
components-reconnect-retryingretryingHay un intento de reconexión en curso.
components-reconnect-pausedpausedEl circuito se pausó (por el cliente o por el servidor).
components-reconnect-hidehideReconectado. No se perdió nada.
components-reconnect-failedfailedNunca se llegó al servidor. Llama a Blazor.reconnect().
components-reconnect-rejectedrejectedSe llegó al servidor y rechazó la conexión. Llama a location.reload().

En .NET 9 y anteriores solo tienes las clases CSS, sin evento. En cualquier caso, failed y rejected son la bifurcación del diagnóstico, y casi no comparten causas. Registra cuál te toca antes de cambiar cualquier configuración:

// .NET 10 or .NET 11, wwwroot or a collocated ReconnectModal.razor.js
const modal = document.getElementById("components-reconnect-modal");
modal.addEventListener("components-reconnect-state-changed", e => {
  console.log("[circuit]", e.detail.state, new Date().toISOString());
});

La reproducción mínima

No necesitas una aplicación rota para verlo. Basta con cualquier componente Interactive Server y un proceso terminado:

// .NET 11 preview 6, C# 14. Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

var app = builder.Build();
app.MapRazorComponents<App>()
   .AddInteractiveServerRenderMode();
app.Run();

Ejecútala, abre la página del contador, haz unos clics y detén el proceso con Ctrl+C. El modal aparece en algo así como medio segundo. Vuelve a iniciar el proceso y observa qué pasa: la conexión se establece, pero el ID del circuito es desconocido para el proceso nuevo, así que obtienes rejected y no hide, y tu contador vuelve a cero. Compáralo con desconectar la red (DevTools, Network, Offline): los reintentos no llegan a ninguna parte, obtienes failed, y al restaurar la red un reintento aterriza en el circuito original con el contador intacto, siempre que estés dentro de la ventana de retención.

Esa diferencia es todo el diagnóstico en miniatura. failed es un problema de transporte. rejected es un problema de tiempo de vida.

Arreglo 1: afinidad de sesión, si tienes más de una instancia

Esta es la causa número uno en producción y produce rejected en prácticamente cada reconexión. El circuito vive en la memoria de un proceso. Una reconexión que aterriza en otra instancia no encuentra el ID del circuito y la rechaza. Dos servidores detrás de un balanceador round-robin significa que alrededor de la mitad de las reconexiones fallan de forma permanente, y parece intermitente, que es justo por lo que sobrevive a las pruebas.

Activa la afinidad de sesión (sticky sessions) en el balanceador: afinidad ARR en Azure App Service, sessionAffinity en tu ingress, ip_hash o una cookie sticky en nginx. El síntoma asociado que puedes buscar en tus registros es Invocation canceled due to the underlying connection being closed. Si no puedes usar afinidad, tampoco puedes mantener circuitos en memoria entre instancias, y lo que quieres es la persistencia distribuida del Arreglo 5.

Arreglo 2: alinea el calendario de reintentos con la ventana de retención

El servidor conserva un circuito desconectado durante DisconnectedCircuitRetentionPeriod, 3 minutos por defecto, y guarda como mucho DisconnectedCircuitMaxRetained de ellos, 100 por defecto. Pasado eso el circuito se libera y cualquier reconexión posterior es rejected por definición.

El calendario del cliente cambió en .NET 9 y ahora sobrevive habitualmente a esa ventana:

Así que en .NET 9 y posteriores, quien se ausenta 4 minutos recibe un modal que sigue con la cuenta atrás y después rechaza. Es el comportamiento previsto, pero es una mala experiencia, y vale la pena hacer que los dos números concuerden. O amplías el servidor:

// .NET 11 preview 6. Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(options =>
    {
        options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(6);
        options.DisconnectedCircuitMaxRetained = 100;
        options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(30);
    });

o acortas el cliente para que falle rápido y recargue en vez de disimular:

<!-- .NET 10 or .NET 11, App.razor. Requires autostart="false" on the Blazor script. -->
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
  Blazor.start({
    circuit: {
      reconnectionOptions: {
        maxRetries: 8,
        retryIntervalMilliseconds:
          Array.prototype.at.bind([0, 0, 1000, 2000, 5000, 10000, 15000, 30000])
      }
    }
  });
</script>

Devolver null o undefined desde retryIntervalMilliseconds detiene los reintentos, que es lo que hace Array.prototype.at en cuanto te sales del final del arreglo. Ten en cuenta el costo de memoria antes de subir el número del servidor: cada circuito retenido es un árbol de componentes vivo más sus servicios con ámbito, y 100 de ellos es una cifra real en una aplicación con carga.

Arreglo 3: el límite de 32 KB, cuando el modal se repite sin fin

Si el modal aparece una y otra vez durante el uso normal, sobre todo justo después de subir un archivo, enviar un formulario grande o pasar una carga útil grande por interoperabilidad con JS, casi seguro estás chocando con HubOptions.MaximumReceiveMessageSize, que por defecto son 32 KB. Superarlo cierra el circuito con error, el cliente reconecta, el usuario repite la acción y vuelve a cerrarse.

La consola del navegador muestra un cierre genérico:

Error: Connection disconnected with error 'Error: Server returned an error on close: Connection closed with an error.'

El mensaje real solo aparece con el registro de Microsoft.AspNetCore.SignalR en Debug o Trace:

System.IO.InvalidDataException: The maximum message size of 32768B was exceeded.

Subir el tope funciona y te cuesta margen frente a ataques de denegación de servicio:

// .NET 11 preview 6. Program.cs
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddHubOptions(options =>
    {
        options.MaximumReceiveMessageSize = 64 * 1024;
    });

El mejor arreglo para cualquier cosa realmente grande es la interoperabilidad con JS por streaming, que trocea por debajo del límite en vez de subirlo. Deja MaximumParallelInvocationsPerClient en su valor por defecto de 1: Blazor depende de ello y subirlo rompe las subidas con InputFile.

Hay una segunda variante del mismo problema que ocurre en la primera carga y no al interactuar. Si el estado prerenderizado enviado por PersistentComponentState supera el límite, el circuito nunca arranca y el registro dice Circuit host not initialized. Persiste menos, o sube el tope.

Arreglo 4: tiempos de espera y proxies que matan WebSockets inactivos

Un failed que solo ocurre tras un rato de inactividad, en móvil o detrás de un proxy inverso es un tiempo de espera de transporte. Tres números tienen que concordar:

// .NET 11 preview 6. Program.cs. These are the framework defaults, stated explicitly.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddHubOptions(options =>
    {
        options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
        options.KeepAliveInterval = TimeSpan.FromSeconds(15);
        options.HandshakeTimeout = TimeSpan.FromSeconds(15);
    });

La regla es que el tiempo de espera del servidor debe ser al menos el doble del intervalo de keep-alive. Si subes uno, sube el otro. Después asegúrate de que tu infraestructura tolere una conexión inactiva entre keep-alives: proxy_read_timeout en nginx, el tiempo de espera de WebSocket inactivo en Application Gateway, y webSocket enabled="true" más un pingInterval razonable en IIS. Un proxy que cierra a los 20 segundos producirá un modal de reconexión cada 20 segundos para siempre, y ninguna configuración de Blazor lo va a arreglar.

Los navegadores móviles y las pestañas en segundo plano son la otra mitad de esto. Una pestaña estrangulada deja de ejecutar temporizadores, el keep-alive se detiene y el servidor descarta el circuito. .NET 9 y posteriores reconectan de inmediato cuando la pestaña vuelve a ser visible en vez de esperar al siguiente reintento programado, y el ReconnectModal.razor.js de la plantilla de .NET 10 también reintenta en visibilitychange tras un fallo, así que actualizar es un arreglo de verdad para el reporte de “volví a mi pestaña y todo había desaparecido”.

Arreglo 5: en .NET 10 y 11, persiste el estado y deja de pelear con el circuito

Todo lo anterior intenta mantener vivo un circuito. .NET 10 añadió la opción de renunciar a eso y conservar el estado en su lugar. Marca propiedades de componentes o de servicios con ámbito usando [PersistentState], y Blazor las serializa cuando el circuito se desaloja y luego las rehidrata en el circuito nuevo cuando la misma pestaña se reconecta:

@* .NET 10 or .NET 11, Counter.razor *@
@page "/counter"
@rendermode InteractiveServer

<p role="status">Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    [PersistentState]
    public int CurrentCount { get; set; }

    private void IncrementCount() => CurrentCount++;
}

Esto está activado por defecto cuando se llama a AddInteractiveServerComponents. El proveedor en memoria guarda hasta 1 000 circuitos persistidos durante dos horas, ambos configurables:

// .NET 11 preview 6. Program.cs
builder.Services.Configure<CircuitOptions>(options =>
{
    options.PersistedCircuitInMemoryMaxRetained = 2_000;
    options.PersistedCircuitInMemoryRetentionPeriod = TimeSpan.FromHours(3);
});

Para varias instancias, asigna un HybridCache y el estado persistido pasa a ser distribuido, con su propio PersistedCircuitDistributedRetentionPeriod de ocho horas por defecto. Esa es la salida de emergencia cuando no hay afinidad de sesión disponible:

// .NET 11 preview 6. Program.cs
builder.Services.AddHybridCache()
    .AddRedis("{CONNECTION STRING}");

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

Restricciones que conviene conocer antes de confiar en esto: solo funciona con renderizado Interactive Server, el estado debe ser serializable a JSON (las entidades de EF Core con ciclos no van a sobrevivir), una recarga completa de página lo descarta, y no hay garantía de recuperación, así que la aplicación vuelve a la experiencia normal de desconexión si la persistencia falla. Usa @key cuando renderices componentes persistidos en un bucle.

La misma maquinaria alimenta la pausa. Blazor.pauseCircuit() y Blazor.resumeCircuit() te permiten soltar el circuito de una pestaña oculta y reconstruirlo al volver, y .NET 11 añade el lado del servidor con Circuit.RequestCircuitPauseAsync(CancellationToken), de modo que una implementación puede pedir a los clientes conectados que pausen y persistan antes de que el proceso se detenga, en lugar de entregarle a cada usuario una reconexión rechazada. Los clientes pueden aplazarlo con el callback onPauseRequested en Blazor.start.

Trampas que llevan al arreglo equivocado

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver