SSR render hangs forever when a `Resource` first polls during a concurrent `RouteList::generate` (process-global `SuppressResourceLoad`)
Describe the bug
If a Resource's future is first polled while any other thread in the process is inside RouteList::generate (route-list generation / SSG), that resource — and the SSR render awaiting it — hangs forever, even though the generation window closes shortly afterwards.
Mechanism (all in released crates):
IS_SUPPRESSING_RESOURCE_LOADis a process-globalAtomicBoolinleptos_server(resource.rs:37), set/cleared by the RAII guardSuppressResourceLoad(resource.rs:42-60).A resource's fetcher wrapper checks the flag once, when its future is first polled, and commits to the branch for good (
resource.rs:313-323):async move { if IS_SUPPRESSING_RESOURCE_LOAD.load(Ordering::Relaxed) { pending().await } else { fut.await } }pending()never wakes, and the flag is never re-checked — so a resource that lands in this branch is permanently stuck even after the guard is dropped.RouteList::generatetakes that guard around running the whole app once (generate_route_list.rs:237). The suppression is correct for the generation run itself, but because the flag is process-global it also poisons every unrelated SSR render running concurrently in the same process whose resource first-polls inside the window.
Any server that calls generate_route_list* while it can also be rendering (on-demand SSG regeneration, route-list generation at startup overlapping early traffic, or a test suite running generation and renders in one process) can permanently hang those renders. The hang is timing-dependent, so in practice it shows up as rare, hard-to-diagnose stuck requests / flaky CI.
Leptos Dependencies
leptos = { version = "0.8.19", features = ["ssr"] }
# harness only:
any_spawner = { version = "0.3", features = ["tokio"] }
hydration_context = "0.3"
futures = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }(leptos_server resolves to 0.8.7. No integration crate involved — this reproduces with leptos alone.)
To Reproduce
The thread spawned below does exactly what RouteList::generate does internally (takes SuppressResourceLoad for the duration of a run); it could equally be a real generate_route_list_with_ssg call running on another thread:
#[cfg(test)]
mod tests {
use futures::StreamExt;
use leptos::prelude::*;
#[tokio::test]
async fn suppression_window_permanently_hangs_render() {
_ = any_spawner::Executor::init_tokio();
// What any concurrent `generate_route_list` / `RouteList::generate`
// call does internally (leptos_router/src/generate_route_list.rs):
std::thread::spawn(|| {
let _g = leptos::server::SuppressResourceLoad::new();
std::thread::sleep(std::time::Duration::from_secs(1));
});
std::thread::sleep(std::time::Duration::from_millis(100));
let owner = Owner::new_root(Some(std::sync::Arc::new(
hydration_context::SsrSharedContext::new(),
)));
let stream = owner.with(|| {
view! {
<Suspense fallback=|| "loading">
{move || Suspend::new(async move {
let res = Resource::new(|| (), |_| async { 42 });
res.await
})}
</Suspense>
}
.to_html_stream_in_order()
});
// The suppression guard above is dropped after 1s, but the resource
// committed to `pending().await` at its first poll: hangs forever.
tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.collect::<String>(),
)
.await
.expect("SSR render permanently hung by a concurrent SuppressResourceLoad window");
}
#[tokio::test]
async fn control_without_suppression_completes() {
_ = any_spawner::Executor::init_tokio();
let owner = Owner::new_root(Some(std::sync::Arc::new(
hydration_context::SsrSharedContext::new(),
)));
let stream = owner.with(|| {
view! {
<Suspense fallback=|| "loading">
{move || Suspend::new(async move {
let res = Resource::new(|| (), |_| async { 42 });
res.await
})}
</Suspense>
}
.to_html_stream_in_order()
});
let html = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.collect::<String>(),
)
.await
.expect("control render must complete");
assert!(html.contains("42"), "{html}");
}
}Observed (macOS, leptos 0.8.19):
cargo test control_without→ passes in 0.00s.cargo test suppression_window→ render outlives the 1-second suppression window and is still stuck at the 5-second timeout:thread 'tests::suppression_window_permanently_hangs_render' panicked: SSR render permanently hung by a concurrent SuppressResourceLoad window: Elapsed(())Incidentally, plain
cargo test(both tests in parallel) makes the control test fail too — the global flag from one test poisons the unrelated render in the other, which is the blast radius this issue is about, demonstrated within one process.
Screenshots
n/a — see test output above.
Next Steps
- I will make a PR
- I would like to make a PR, but need help getting started
- I want someone else to take the time to fix this
- This is a low priority for me and is just shared for your information
Additional context
Originally diagnosed while investigating rare hangs of SsrMode::InOrder/Async suspense renders in the test suite of an ntex integration port (leptos_ntex), where generate_route_list_with_ssg calls overlap SSR renders in one process. The executor/integration was ruled out: the minimal reproduction above uses leptos only.
Possible directions, in case they help triage:
- have the suppressed branch await a wakeable signal (e.g. a
watch/Notifyflipped bySuppressResourceLoad::drop) and re-check the flag, instead of committing topending().awaitforever; - or scope suppression to the generating owner/reactive graph (e.g. a context or task-local) instead of a process-global flag;
- or, at minimum, document that
RouteList::generate/generate_route_list*must not overlap any SSR render in the same process.
Not related to #4749 (different bug, same general SSG area).
Source: leptos-rs/leptos