00bd63cfdf
线上 v1.2.158 销假必崩,前端显示「连接失败,请检查网络」,用户误判为网络问题。
根因(162 服务器日志实证):
SetBodyStreamWriter 的回调运行在 fasthttp 的独立写协程中,此时 *RequestCtx
已被归还复用。回调内调用 newXsgzSession(c.Context(), ...) 把该 ctx 交给
resty 的 SetContext,resty 内部走 context.WithDeadline → propagateCancel
→ parent.Done(),而 RequestCtx.Done() 直接解引用 ctx.s(server.go:2726):
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV] fasthttp.(*RequestCtx).Done server.go:2726
此时 s 已为 nil,触发 SIGSEGV。
panic 发生在协程里,fiber 的 recover 中间件捕获不到,直接杀死整个进程,
前端 XHR 连接被掐断 → 只剩「连接失败,请检查网络」这条误导性提示。
修复:
1. 新增 streamCtx():返回 context.WithTimeout(Background, 3min),
脱离请求生命周期,回调内一律使用它(销假与请假两条链路);
2. 两个回调都加 recover 兜底,即便再出现协程内 panic 也只返回
task_error 事件,不会拖垮进程。
回归测试:TestStreamCtxIsUsable 保证 ctx 具备 Done/deadline;
TestRequestCtxDonePanicsAfterReset 用零值 RequestCtx 固化崩溃机制,
解释为什么不能用 c.Context()。
77 lines
2.5 KiB
Go
77 lines
2.5 KiB
Go
package handler
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/valyala/fasthttp"
|
||
)
|
||
|
||
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
|
||
//
|
||
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
|
||
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
|
||
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
|
||
// context.WithDeadline → propagateCancel → parent.Done(),
|
||
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
|
||
//
|
||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||
// [signal SIGSEGV: ... addr=0x560]
|
||
// fasthttp.(*RequestCtx).Done(...) server.go:2726
|
||
//
|
||
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
|
||
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
|
||
|
||
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
|
||
func TestStreamCtxIsUsable(t *testing.T) {
|
||
ctx, cancel := streamCtx()
|
||
defer cancel()
|
||
|
||
if ctx == nil {
|
||
t.Fatal("streamCtx 不应返回 nil")
|
||
}
|
||
|
||
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
|
||
done := ctx.Done()
|
||
if done == nil {
|
||
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
|
||
}
|
||
|
||
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
|
||
dl, ok := ctx.Deadline()
|
||
if !ok {
|
||
t.Fatal("streamCtx 应带 deadline")
|
||
}
|
||
if time.Until(dl) <= 0 {
|
||
t.Fatal("deadline 应在未来")
|
||
}
|
||
|
||
// 取 Done 不应 panic
|
||
select {
|
||
case <-done:
|
||
t.Fatal("新建的 context 不应已取消")
|
||
default:
|
||
}
|
||
}
|
||
|
||
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
|
||
//
|
||
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
|
||
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
|
||
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
|
||
//
|
||
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
|
||
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
|
||
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
|
||
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
|
||
|
||
defer func() {
|
||
if r := recover(); r == nil {
|
||
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
|
||
}
|
||
}()
|
||
|
||
_ = rctx.Done() // 线上就是这样崩的
|
||
t.Fatal("预期 panic 未发生")
|
||
}
|