修复销假/请假 SSE 崩溃:stream writer 内误用已回收的 fasthttp RequestCtx

线上 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()。
This commit is contained in:
becomer
2026-10-17 18:07:34 +08:00
parent e7c9b06056
commit 00bd63cfdf
4 changed files with 137 additions and 6 deletions
+23 -3
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strings"
"time"
@@ -470,6 +471,25 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
@@ -489,7 +509,7 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(c.Context(), user)
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
@@ -502,7 +522,7 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(c.Context(), req.ProjectID, "CANCELLATION")
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
if ffErr != nil {
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
@@ -524,7 +544,7 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
return
}
body, err := sess.XsgzSubmitCancel(c.Context(), cpd.XsgzSubmitPayload{
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
+23 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -552,6 +553,25 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
@@ -572,7 +592,7 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(c.Context(), user)
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
@@ -586,7 +606,7 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
// 写回各字段的 value 后作为 fields 提交。
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
formFields, ffErr := sess.XsgzFetchFormFields(c.Context(), req.XsgzProjectID, "LEAVE")
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
if ffErr != nil {
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
@@ -614,7 +634,7 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
Latitude: req.Latitude,
Fields: formFields,
}
body, err := sess.XsgzSubmitLeave(c.Context(), payload)
body, err := sess.XsgzSubmitLeave(ctx, payload)
if err != nil {
msg := "提交请假失败: " + err.Error()
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
+15
View File
@@ -247,3 +247,18 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
}
return out
}
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
//
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
//
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
func streamCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 3*time.Minute)
}
@@ -0,0 +1,76 @@
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 未发生")
}