乱序流式下晚设的状态码和响应头丢了
主线位置:首屏主线「等 suspense」之后、「
dioxus_ssr::Renderer写 HTML」之前的那一刻:render_to把状态码和响应头交给 axum,之后响应头就发出去了。截止时刻在两种流式模式下不同,机制见一次请求的渲染的「状态码和响应头:在首块之前定下」和流式渲染。
sequenceDiagram participant R as render_to participant B as SuspenseBoundary participant C as 浏览器 R->>R: rebuild,边界挂起 R->>R: Router 之上的 suspense 完成 R->>C: 状态码、响应头、首块 B->>B: 300ms 后数据到 B->>B: commit_http_status 201 B->>C: 替换块 Note over C: 状态码仍是 200
本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建。同一个二进制,main 里读到环境变量 PIT3_STREAMING 就给 ServeConfig 加 enable_out_of_order_streaming(),否则是默认的 StreamingMode::Disabled。
现象
页面在第一次渲染时加一个头 x-early;一个 SuspenseBoundary 里的 use_server_future 等 300 毫秒后再加 x-late、把状态码改成 201。另一个页面里,边界里的 server function 300 毫秒后返回错误,页面上没有 ErrorBoundary:
#[cfg(feature = "server")]
fn add_header(name: &'static str) {
if let Some(cx) = dioxus::fullstack::FullstackContext::current() {
cx.add_response_header(
dioxus::fullstack::http::HeaderName::from_static(name),
dioxus::fullstack::http::HeaderValue::from_static("1"),
);
}
}
#[component]
fn Hdr() -> Element {
#[cfg(feature = "server")]
add_header("x-early");
rsx! {
p { "header page" }
SuspenseBoundary { fallback: |_| rsx! { "loading" },
Late {}
}
}
}
#[component]
fn Late() -> Element {
let v = use_server_future(|| async move {
let out = sleep_ms(300).await;
// runs after the component suspended once
#[cfg(feature = "server")]
{
add_header("x-late");
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::http::StatusCode::CREATED,
None,
);
}
out
})?;
rsx! { p { "late {v():?}" } }
}
#[component]
fn LateErr() -> Element {
rsx! {
p { id: "top", "page with a late error" }
SuspenseBoundary { fallback: |_| rsx! { p { "loading..." } },
Fail {}
}
}
}
#[component]
fn Fail() -> Element {
let r = use_server_future(|| fail_after(300))?;
let text = r().unwrap()?;
rsx! { p { "{text}" } }
}
curl -i(去掉 date),先是默认模式,再是乱序流式:
##### default (StreamingMode::Disabled)
== curl -i /hdr
HTTP/1.1 201 Created
content-type: text/html; charset=utf-8
age: 0
x-early: 1
x-late: 1
transfer-encoding: chunked
...
== curl -i /late-err
HTTP/1.1 500 Internal Server Error
content-length: 18
== body of /late-err
failed after 300ms
##### PIT3_STREAMING=1 (OutOfOrder)
== curl -i /hdr
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
age: 0
x-early: 1
transfer-encoding: chunked
...
== curl -i /late-err
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
age: 0
transfer-encoding: chunked
默认模式下 x-late 和 201 都在;换成乱序流式,二者都不见了,没有警告。出错的页面在默认模式下是 500,乱序流式下是 200。按块看(curl --raw 的分块,时间从读取脚本启动算起,每块只显示首尾):
--- chunks /hdr
+ 0ms 742 '<!DOCTYPE html>\n<html>\n <head>\n <title>dioxus | ⛺</title>\n ... hydrate_queue.push([id,bytes,debug_types,debug_locations])};\n</script>'
+ 0ms 272 '<p data-node-hydration="0">header page</p><!--node-id1-->loading<!--#- ... sets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js"></script>\n '
+ 293ms 169 '<div id="ds-0-r" hidden><p data-node-hydration="0"><!--node-id1-->late ... iv><script>window.dx_hydrate([0], "goEY9ocYoRhiGE8YaxgZARgs")</script>'
+ 293ms 16 '</body>\n</html>\n'
+ 293ms 0 (end)
...
--- chunks /late-err
+ 0ms 742 '<!DOCTYPE html>\n<html>\n <head>\n <title>dioxus | ⛺</title>\n ... hydrate_queue.push([id,bytes,debug_types,debug_locations])};\n</script>'
+ 0ms 315 '<p id="top" data-node-hydration="0">page with a late error</p><p data- ... sets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js"></script>\n '
+ 292ms 420 '<div id="ds-0-r" hidden><!--placeholder0--></div><script>window.dx_hyd ... GwYZRhkGCAYYRhmGHQYZRhyGCAYMxgwGDAYbRhzGGQYYxhvGGQYZRgZARj0")</script>'
+ 292ms 16 '</body>\n</html>\n'
+ 292ms 0 (end)
首块带着 fallback(loading)立刻发出,Late 在约 300 毫秒后才设头,那时响应头早已发走。/late-err 的错误装在替换块的水合数据里送到浏览器;在 headless Chrome 里打开:
...
[console.log] ERROR ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_arena.rs:111 Error while rendering component `exp_dioxus_pit3_fs::Fail`: error running server function: failed after 300ms (details: None)
--- innerText of body ---
error running server function: failed after 300ms (details: None)
HTTP 说 200,用户看到的整页只剩一行错误:客户端的错误冒到根错误边界,整页换成了它的默认渲染,连 #top 也没了。
出处
render_to 里决定截止时刻的是这一段(ssr.rs):
if streaming_mode == StreamingMode::Disabled {
virtual_dom.wait_for_suspense().await;
} else {
// Otherwise, just wait for the streaming context to signal the initial chunk is ready
loop {
// Check if the router has finished and set the streaming context to finished
let streaming_context_finished = virtual_dom
.in_scope(ScopeId::ROOT, || streaming_context.streaming_state())
== StreamingStatus::InitialChunkCommitted;
// Or if this app isn't using the router and has finished suspense
let suspense_finished = !virtual_dom.suspended_tasks_remaining();
if streaming_context_finished || suspense_finished {
break;
}
// ...
}
}
循环之后先看根错误边界,再读 current_http_status()、take_response_headers(),把它们送给 axum。OutOfOrder 下循环在 InitialChunkCommitted 时退出,而这个状态是 Router 设的:
#[cfg(feature = "streaming")]
dioxus_hooks::use_after_suspense_resolved(|| {
dioxus_fullstack_core::commit_initial_chunk();
});
use_after_suspense_resolved 等的是 Router 自己所在的那个 suspense 边界。Late 被一个 SuspenseBoundary 包着,它挂起的是内层边界,Router 所在的边界不受影响,第一次渲染完就算「已解决」,首块立即提交。之后:
take_response_headers是Option::take,取走之后add_response_header找不到HeaderMap,什么也不做;commit_http_status写进去的状态再也没人读。两个调用都没有返回值,也不打日志。- 错误也是这样。默认模式下
Fail的错误在定状态码之前冒到根错误边界,变成 500;乱序流式下首块已经发了,挂起的边界在服务端由start_capturing_errors截住错误,随替换块送到客户端,在客户端继续往上冒(流式渲染的「freeze:之后的错误只能送到客户端」)。
0.6.3 的 fullstack/src/render.rs 里,rebuild_in_place 之后只有 Disabled 模式会 wait_for_suspense;乱序流式下紧接着就是 // Render the initial frame with loading placeholders,首帧在第一次渲染之后立刻发出,路由组件里等的数据完全来不及进首帧。commit 84221f55a(PR #3891,「Wait for the suspense boundary above the router to resolve before sending the first streaming chunk」)在提交说明里列了「Change the suspense streaming context after suspense is resolved in the router」「Wait for the suspense context before streaming the first chunk in ssr」「forward more response parts」:Router 里多了上面那段 use_after_suspense_resolved,render_to 多了上面那个循环。仓库里第一个包含它的 tag 是 v0.7.0-alpha.0。下面的修法依赖的正是这个改动。
修法
决定状态码或响应头的数据,放在 Router 和第一个 SuspenseBoundary 之间等。 同一个 Late,去掉外面的 SuspenseBoundary,它挂起的就是 Router 所在的边界:
#[component]
fn HdrFix() -> Element {
#[cfg(feature = "server")]
add_header("x-early");
rsx! {
p { "header page" }
// no SuspenseBoundary between the router and Late
Late {}
}
}
##### PIT3_STREAMING=1 (OutOfOrder)
...
== curl -i /hdr-fix
HTTP/1.1 201 Created
content-type: text/html; charset=utf-8
age: 0
x-early: 1
x-late: 1
transfer-encoding: chunked
...
--- chunks /hdr-fix
+ 290ms 742 '<!DOCTYPE html>\n<html>\n <head>\n <title>dioxus | ⛺</title>\n ... hydrate_queue.push([id,bytes,debug_types,debug_locations])};\n</script>'
+ 290ms 342 '<p data-node-hydration="0">header page</p><p data-node-hydration="1">< ... sets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js"></script>\n '
+ 290ms 16 '</body>\n</html>\n'
+ 290ms 0 (end)
代价也看得出来:这一页的首块要等这 300 毫秒,没有 fallback,整页一次发完。只有决定「这一页是 404 / 301 / 带什么缓存头」的数据才值得这样等;其余的放进 SuspenseBoundary 继续流式。
其他几种做法:
- 能在第一次渲染时同步决定的头(上面的
x-early),在挂起之前就设,两种模式都能送达。 - 状态码依赖慢数据、又不需要流式的应用,不开
enable_out_of_order_streaming。默认的Disabled等所有 suspense 完成才定状态码(/hdr是 201)。 - 边界里的错误要在页面上局部显示,就在那个
SuspenseBoundary外面包一个ErrorBoundary,两种模式下都只坏这一块,做法和实验见流式渲染的/stream-eb。它改变不了状态码:乱序流式下这一页仍是 200。
怎么确认没中招
- 对每个会设状态码或响应头的页面,用打开了流式的构建
curl -s -o /dev/null -D - <url>,看状态码和头是不是预期的那些。不要只在默认配置下测:同一个页面两种模式的结果不同。 - 搜代码里
commit_http_status、add_response_header、set_current_http_status的调用点,看它们是不是在use_server_future的 future 里,或者在某个SuspenseBoundary的子树里。在的话,乱序流式下它们大概率不生效。 - 乱序流式下,服务端渲染出错的页面状态码是 200。监控里不能只看 5xx,要配合客户端的错误上报。
小结
- 乱序流式下,
render_to在Router所在的 suspense 边界解决后就送出状态码和响应头;之后的add_response_header、commit_http_status静默无效。 - 被
SuspenseBoundary包住的组件挂起不会推迟首块;它等完数据再设的头和状态码全部丢失。 - 首块之后的错误不再变成 500:HTTP 是 200,错误在浏览器里冒到根错误边界,整页换成一行错误。
- 修法:决定响应头的数据放在
Router和第一个边界之间等,或同步设好,或不开乱序流式;局部错误用ErrorBoundary接住。