fix(collab): 两处会让协作「看起来正常、其实各写各的」的缺陷

都是实测复现出来的,不是推测。

1. 教师第二次接单,看到的是上一个学生的代码拼在自己文档里
   CollabModal 的 code ref 跨会话不清。n-modal 默认 display-directive="if",
   每次打开都重挂 CodeMirror,拿这个 ref 当初始文档;而 yCollab 只观察 ytext、
   从不反过来用 ytext 覆盖编辑器。于是上一轮的代码留在文档里,新学生的内容
   作为 delta 插到位置 0,两边偏移从此对不上。
   实测三轮会累积成 CCC/BBB/AAA/XXX,且教师敲一行之后,教师看到
   「BBB/AAA/XXX」、学生看到「BBB/XXX」——两份不同的文档。
   改法:不绑 v-model,文档完全交给 Yjs;并把 start 的起点从「房间打开 +
   await nextTick」换成「编辑器 ready」,顺带修掉绑到已 destroy 的旧 view。

2. 学生换连接后房间静默死掉
   handleCollabOpen 迁移 socket 时只补发了 help_status,没补 room_open,
   而前端每次连接建立都会把 room 清成 null —— 页面显示「老师正在帮你」,
   编辑器却早把 yCollab 摘了,老师敲的字一个也到不了。
   补发 room_open 也修不好:CRDT 状态跟着旧连接没了,新建 Y.Doc 再 seed
   会和老师那份合并成重复文本。所以改成直接拆房、请求退回排队,
   老师再点一次 —— 和老师掉线走同一条路子。

顺带修掉验证时挖出来的第三个:握手帧会被丢。
两端挂 yCollab 的时刻不同步(教师等模态框、学生等 chunk),先挂好的那端发的
SyncStep1 到对面时还没有 binaryHandler,服务端转发是成功的、客户端却静静丢掉。
丢的偏偏是握手——y-protocols 里 A 的内容靠 B 发的 Step1 换回来,教师的 Step1
一丢,学生的代码就永远到不了教师那边(单向同步,同样看着像在协作)。
CollabWebSocket 改成 handler 装上之前先把二进制帧缓着,装上再按序放行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
This commit is contained in:
2026-08-30 09:37:41 -06:00
parent bb33e0f0e5
commit 66a564710f
3 changed files with 114 additions and 14 deletions

View File

@@ -40,12 +40,34 @@ function serializeRequest(request: HelpRequest) {
}
}
/**
* 把最新的排队位置推给每个还在等的学生。
*
* queueAhead 原来只在「建请求 / 重连 / 退回排队」这三处推过,前面的人被接走或被
* 取消之后不重算 —— 五个人排队、前四个都处理完了,第五个还一直显示「前面还有 4 人」。
*/
function broadcastQueuePositions() {
for (const request of listRequests()) {
if (request.status !== "pending") continue
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(request.studentId),
})
}
}
/**
* 队列变了:老师端收全量列表,排队中的学生各自收自己的新位置。
*
* 两件事捏在一起是因为它们永远同时发生 —— 拆成两个函数分别调,迟早会在某条
* 路径上漏掉一个。
*/
export function broadcastRequests() {
const payload = JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
})
for (const ws of teacherSockets()) ws.send(payload)
broadcastQueuePositions()
}
function sendHelpStatus(
@@ -77,12 +99,32 @@ export function handleCollabOpen(ws: CollabSocket) {
const room = getRoom(ws.data.userId)
if (room && room.studentSocket !== ws) {
// 旧 socket 不再是这间房的主人:清掉它的 roomOwnerId不然它稍后触发的
// close 会经 roomOf 认出这间刚刚转移出去的房间,把它拆了——一条早该
// 死透的连接反而有权拆掉正在用的新连接的房间
// 协作中的学生换了一条连接。**不迁移房间,直接拆掉。**
//
// 原来这里是把 studentSocket 换成新连接就算完,转发确实转到新连接了,
// 但客户端接不住:前端每次连接建立都会把 room 清成 null旧连接的状态
// 不该越过重连活下来),而这里只补发了 help_status没补 room_open ——
// 于是学生页面显示「老师正在帮你」、编辑器却早就把 yCollab 摘了,
// 老师照常敲字、一个字也到不了对面。正是 handleCollabBinary 注释里说的
// 「看起来在协作、其实各看各的」,比老实断开更糟。
//
// 而补发 room_open 也修不好Yjs 的文档状态跟着旧连接一起没了,新连接
// 只能新建 Y.Doc再拿学生编辑器里的内容当种子插进去就会和老师那份
// 已有内容合并成重复文本(两份 doc 的 item 身份不同CRDT 不去重)。
// 续接一个 CRDT 会话不是哑转发层做得到的事。
//
// 所以退回排队,老师再点一次 —— 和老师掉线走的是同一条路子。学生的代码
// 一直在他自己的编辑器里,不受影响。
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.studentSocket = ws
ws.data.roomOwnerId = ws.data.userId
room.teacherSocket.data.roomOwnerId = undefined
room.teacherSocket.send(
JSON.stringify({ type: "room_closed", reason: "peer_offline" }),
)
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
broadcastRequests()
}
if (request.status === "pending") {