From aef687bcfcaf82ea0b0b813807e45b7d10dcc585 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 28 Aug 2026 05:28:14 -0600 Subject: [PATCH] =?UTF-8?q?fix(api):=20collab=20=E4=BA=8C=E8=BF=9B?= =?UTF-8?q?=E5=88=B6=E8=BD=AC=E5=8F=91=E7=9A=84=E8=83=8C=E5=8E=8B=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=E4=B8=8E=E5=8F=91=E9=80=81=E5=A4=B1=E8=B4=A5=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E5=B9=BD=E7=81=B5=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- apps/api/src/collab/handler.ts | 67 ++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 47d25f9..603567f 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -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) { if (isTeacher(ws)) removeTeacher(ws) @@ -78,16 +92,7 @@ export function handleCollabClose(ws: CollabSocket) { peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" })) if (ws === room.teacherSocket) { - // 老师掉线:请求退回排队,学生不必重新点 —— 可能只是网络抖了一下 - const request = getRequest(room.studentId) - if (request) { - request.status = "pending" - request.teacherId = undefined - request.teacherName = undefined - sendHelpStatus(request.socket, "pending", { - queueAhead: queueAheadOf(room.studentId), - }) - } + requeueAfterTeacherGone(room.studentId) } else { // 学生掉线:请求随人走 removeRequest(room.studentId) @@ -203,7 +208,9 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) { function handleHelpCancel(ws: CollabSocket) { 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) broadcastRequests() } @@ -311,16 +318,29 @@ function handleLeave(ws: CollabSocket) { /** * 拆房间。reason 决定两端看到什么: * 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) room.studentSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined const frame = JSON.stringify({ type: "room_closed", reason }) room.studentSocket.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() } @@ -331,17 +351,26 @@ function teardownRoom(room: Room, reason: "done" | "peer_offline") { * 权限由 accept 时的库查询决定,与帧里装的是什么无关。 */ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) { + // 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值, + // 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。 + // 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉 + if (data.length === 0) return + const room = roomOf(ws) if (!room) return const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket const sent = peer.send(data) - if (sent <= 0) { - // <= 0:背压丢帧或者对端事实上已经断了。这一帧丢了,两边的 Yjs 文档从此 - // 悄悄分叉——教学工具里"看起来在协作、其实各看各的代码"比老实断开更糟, - // 不做续传,直接拆房间让双方收到 room_closed、自己决定要不要重新连 + // Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB + // 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0 + // 当成失败,慢网/大粘贴一触发背压就把正常房间拆掉,是本该保护的场景反而先死 + if (sent === 0) { + // 真丢帧:两边的 Yjs 文档会从此悄悄分叉——教学工具里"看起来在协作、其实 + // 各看各的代码"比老实断开更糟,不做续传,直接拆房间。和教师断线走同一条 + // 收尾路径:老师那侧消失就把请求退回排队,不让学生卡死在 active 出不来 console.error("Collab binary forward failed, tearing down room", { studentId: room.studentId, }) - teardownRoom(room, "peer_offline") + const offlineSide = peer === room.teacherSocket ? "teacher" : "student" + teardownRoom(room, "peer_offline", offlineSide) } }