Rust in Everything
基于 topcoat-cookie 0.10.0

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。

💬 关联讨论

发起讨论
加载中...