cannot add a cookie after the response headers have been sent
主线位置:请求主线的出口。页面的
Response在首屏就绪时就交给了 hyper,suspense里的内容是 hyper 拉响应体时才产生的(一个请求从监听到 HTML);CookieLayer在next.run返回的那一刻写出Set-Cookie,随后封存 jar(Origin 检查、cookie 与 session)。
sequenceDiagram participant C as 客户端 participant H as hyper participant L as CookieLayer participant P as 页面 participant S as suspense 里的组件 H->>L: next.run L->>P: 渲染首屏 P-->>L: Response(首屏就绪) L->>L: 写 Set-Cookie,seal jar L-->>H: Response H-->>C: 200 + 头 + 首屏 H->>S: poll 响应体 S->>S: cookies(cx).add(...):panic H--xC: 连接被关掉,没有结尾的 0 长度块
现象
依赖:topcoat =0.10.0 的 full、tokio =1.54.1,路由上 .cookies()。一个「记住偏好」的组件放在 suspense 里:
#[component]
async fn remember_theme(cx: &Cx) -> Result<impl View> {
tokio::time::sleep(Duration::from_millis(100)).await;
cookies(cx).add(("theme", "dark"));
Ok(view! { <p>"theme saved"</p> })
}
#[page("./prefs")]
async fn prefs() -> Result<impl View> {
Ok(view! { <h1>"Prefs"</h1> suspense(fallback: view! { <p>"loading"</p> }, remember_theme()) })
}
用一个打印每次 read 时间的小客户端请求 /prefs:
[ 1ms] HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
vary: accept-encoding
connection: close
transfer-encoding: chunked
141
<!DOCTYPE html><html><head>...</head><body><h1>Prefs</h1><!--::topcoat::region::start(80a46140...)--><p>loading</p><!--::topcoat::region::end(80a46140...)--></body></html>
[ 108ms] <EOF>
服务器:
thread 'tokio-rt-worker' (2639557) panicked at .../topcoat-cookie-0.10.0/src/jar.rs:72:9:
cannot add a cookie after the response headers have been sent. Pending cookies are written when the handler returns, so work that outlives it, such as a streaming body or a WebSocket task, can only read cookies
客户端拿到的是状态码 200、首屏和 fallback,108 毫秒时连接关掉,没有结尾的 0 长度块,也没有 set-cookie。浏览器里看到的就是一直停在「loading」。把 cookies(cx).add 换成 session::start(cx),panic 原文一字不差:session 的写入就是 cookie 的写入。
原因
CookieLayer在next.run返回时把 jar 的增量写进响应头,然后 seal。之后再add/remove,assert_open直接 panic,不会悄悄丢掉。- 有
suspense的页面,next.run在首屏就绪时就返回了。这时响应头已经确定,suspense里的组件还没跑完。 - panic 发生在 hyper poll 响应体的时候。状态码早已发出,
catch_unwind再也变不出 500,只能中止这个响应(从连接到分发的catch_unwind一节)。
panic 原文里点名的另外两种场合同理:live! 的后续推送、tokio::spawn 出去的任务、WebSocket 的 run,都活得比 handler 长,只能读 cookie。
修法
把写 cookie 的代码放进 handler 本身,在构造 view! 之前跑完,慢的部分照样流式:
#[page("./prefs-fixed")]
async fn prefs_fixed(cx: &Cx) -> Result<impl View> {
cookies(cx).add(("theme", "dark"));
Ok(view! { <h1>"Prefs"</h1> suspense(fallback: view! { <p>"loading"</p> }, slow_part()) })
}
[ 1ms] HTTP/1.1 200 OK
...
set-cookie: theme=dark
transfer-encoding: chunked
141
...<h1>Prefs</h1><!--::topcoat::region::start(e493ad11...)--><p>loading</p>...
[ 102ms] 404
<script>…swap 脚本…</script><template data-topcoat-swap="e493ad11..."><p>theme saved</p></template><script>topcoat.swap("e493ad11...")</script>
0
[ 102ms] <EOF>
set-cookie 随首屏一起发出,100 毫秒后 <template> 追加,响应正常结束。
其他办法:
- 登录、登出、改偏好这类操作本来就该是
POST:写一个#[route(POST ...)],写完 cookie 返回see_other重定向,页面本身只读。 - 必须在慢组件里决定要不要写 cookie 时,让这个页面不流式:
suspense(mode: SuspenseMode::Wait, ...),或在 layer 里cx.with(SuspenseMode::Wait)(组件、并发渲染与流式)。代价是首屏等最慢的组件。
和 Dioxus 的同类问题
Dioxus fullstack 开了乱序流式之后,首块之后才设置的状态码同样丢失,见 dioxus 卷的 header-after-commit。两边的根源相同:HTTP 的头只能在体之前发。topcoat 选择 panic,问题在开发时就暴露;代价是线上碰到时那个响应会被截断。
小结
- 报错原文:「cannot add a cookie after the response headers have been sent. Pending cookies are written when the handler returns, so work that outlives it, such as a streaming body or a WebSocket task, can only read cookies」。
suspense里写 cookie 或session::start都会触发;客户端看到 200、fallback 和一个被截断的响应。- 写 cookie 放在 handler 里、
view!之前;或者改成POST路由加重定向;实在要在慢组件里决定,就让那一页SuspenseMode::Wait。