fix(api): collab 二进制转发的背压误判与发送失败后的幽灵请求

Important 1:handleCollabBinary 把 send() 返回的 -1 当成失败,实测证明
-1 只是背压——消息已排队,最终照样送达(8MB 帧原样完整到达);只有 0
才是真丢。之前 sent <= 0 一触发背压就拆房间,慢网/大粘贴反而先弄死正常
会话。改成只认 sent === 0;同时空 Uint8Array 的 send() 也回 0(送达和
真丢用同一个返回值分不清),先按长度 0 直接忽略,不转发也不参与失败
判定,否则任意一方发一个空二进制帧就能把房间拆了。

Important 2:发送失败后走 teardownRoom("peer_offline") 从不 removeRequest
(只有 reason === "done" 才删),请求卡在 status: "active" 没有房间,
之后 handleReject / handleHelpCancel / handleLeave / handleAccept 全部
因为状态或归属对不上而拒绝处理,那个学生的后续求助永远被静默吞掉。
补上 offlineSide 参数,和教师断线共用同一条收尾路径:老师那侧消失,
请求退回 pending 并清 teacherId/teacherName,学生收到新的 help_status
pending;学生那侧消失,请求整条清掉。两种情况都发 room_closed 并
broadcastRequests()。

Minor:handleHelpCancel 没有 request.socket === ws 校验,同一账号第二个
标签页能取消第一个标签页排队中的请求——和上一轮修的 close 排队分支是
同一类归属漏洞,补上同样的检查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-08-28 05:28:14 -06:00
parent 457df1ef3c
commit aef687bcfc

View File

@@ -66,6 +66,20 @@ export function handleCollabOpen(ws: CollabSocket) {
} }
} }
/** 老师从房间消失(掉线,或发送失败被判定为事实上不可达):请求退回排队,
* 学生不必重新点 —— 可能只是网络抖了一下 */
function requeueAfterTeacherGone(studentId: number) {
const request = getRequest(studentId)
if (request) {
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(studentId),
})
}
}
export function handleCollabClose(ws: CollabSocket) { export function handleCollabClose(ws: CollabSocket) {
if (isTeacher(ws)) removeTeacher(ws) if (isTeacher(ws)) removeTeacher(ws)
@@ -78,16 +92,7 @@ export function handleCollabClose(ws: CollabSocket) {
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" })) peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
if (ws === room.teacherSocket) { if (ws === room.teacherSocket) {
// 老师掉线:请求退回排队,学生不必重新点 —— 可能只是网络抖了一下 requeueAfterTeacherGone(room.studentId)
const request = getRequest(room.studentId)
if (request) {
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(room.studentId),
})
}
} else { } else {
// 学生掉线:请求随人走 // 学生掉线:请求随人走
removeRequest(room.studentId) removeRequest(room.studentId)
@@ -203,7 +208,9 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
function handleHelpCancel(ws: CollabSocket) { function handleHelpCancel(ws: CollabSocket) {
const request = getRequest(ws.data.userId) const request = getRequest(ws.data.userId)
if (!request || request.status === "active") return // 同一账号可能开着两个标签页;只能取消自己这条连接发起的请求,不然 B 标签页
// 能把 A 标签页排队中的求助顶掉 —— 和 handleCollabClose 排队分支同一类归属漏洞
if (!request || request.socket !== ws || request.status === "active") return
removeRequest(ws.data.userId) removeRequest(ws.data.userId)
broadcastRequests() broadcastRequests()
} }
@@ -311,16 +318,29 @@ function handleLeave(ws: CollabSocket) {
/** /**
* 拆房间。reason 决定两端看到什么: * 拆房间。reason 决定两端看到什么:
* done —— 有人主动结束,双方都收到,请求一并清除 * done —— 有人主动结束,双方都收到,请求一并清除
* peer_offline —— 有人断线,见 handleCollabClose * peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
* handleCollabBinary。offlineSide 是消失的那一方:老师消失,
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有
* handleLeave 走 "done")不做这一步,只拆房间
*/ */
function teardownRoom(room: Room, reason: "done" | "peer_offline") { function teardownRoom(
room: Room,
reason: "done" | "peer_offline",
offlineSide?: "student" | "teacher",
) {
closeRoom(room.studentId) closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined
const frame = JSON.stringify({ type: "room_closed", reason }) const frame = JSON.stringify({ type: "room_closed", reason })
room.studentSocket.send(frame) room.studentSocket.send(frame)
room.teacherSocket.send(frame) room.teacherSocket.send(frame)
if (reason === "done") removeRequest(room.studentId) if (reason === "done") {
removeRequest(room.studentId)
} else if (offlineSide === "teacher") {
requeueAfterTeacherGone(room.studentId)
} else if (offlineSide === "student") {
removeRequest(room.studentId)
}
broadcastRequests() broadcastRequests()
} }
@@ -331,17 +351,26 @@ function teardownRoom(room: Room, reason: "done" | "peer_offline") {
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。 * 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/ */
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) { export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
// 空帧Bun.serve 探测过send() 对 0 字节帧也回 0同一个返回值,
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
if (data.length === 0) return
const room = roomOf(ws) const room = roomOf(ws)
if (!room) return if (!room) return
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const sent = peer.send(data) const sent = peer.send(data)
if (sent <= 0) { // Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
// <= 0背压丢帧或者对端事实上已经断了。这一帧丢了两边的 Yjs 文档从此 // 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
// 悄悄分叉——教学工具里"看起来在协作、其实各看各的代码"比老实断开更糟, // 当成失败,慢网/大粘贴一触发背压就把正常房间拆掉,是本该保护的场景反而先死
// 不做续传,直接拆房间让双方收到 room_closed、自己决定要不要重新连 if (sent === 0) {
// 真丢帧:两边的 Yjs 文档会从此悄悄分叉——教学工具里"看起来在协作、其实
// 各看各的代码"比老实断开更糟,不做续传,直接拆房间。和教师断线走同一条
// 收尾路径:老师那侧消失就把请求退回排队,不让学生卡死在 active 出不来
console.error("Collab binary forward failed, tearing down room", { console.error("Collab binary forward failed, tearing down room", {
studentId: room.studentId, studentId: room.studentId,
}) })
teardownRoom(room, "peer_offline") const offlineSide = peer === room.teacherSocket ? "teacher" : "student"
teardownRoom(room, "peer_offline", offlineSide)
} }
} }