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:
@@ -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") {
|
||||
|
||||
@@ -14,7 +14,6 @@ const isDark = useDark()
|
||||
const collabStore = useCollabStore()
|
||||
const { start, stop, getInitialExtension } = useCollabDoc()
|
||||
|
||||
const code = ref("")
|
||||
// shallowRef,理由见 SyncCodeEditor.vue:EditorView 是类实例,ref() 的深度
|
||||
// UnwrapRef 会把它拆成一个丢了原型方法的假类型,vue-tsc 报莫名其妙的类型错。
|
||||
const editorView = shallowRef<EditorView | null>(null)
|
||||
@@ -36,25 +35,44 @@ const extensions = computed(() => [
|
||||
getInitialExtension(),
|
||||
])
|
||||
|
||||
const bind = (view: EditorView) => {
|
||||
if (!collabStore.isTeacher || !collabStore.room) return
|
||||
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
|
||||
start({ editorView: view, seedContent: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* 起点是「编辑器就绪」,不是「房间打开」。
|
||||
*
|
||||
* n-modal 默认 display-directive="if",每次打开都会重挂一个全新的 CodeMirror。
|
||||
* 原来在 watch 里 `await nextTick()` 之后去取 editorView:赶上 teleport + 离场
|
||||
* 过渡没走完,取到的是上一轮那个已经 destroy 的 view,start() 静默绑到死编辑器上,
|
||||
* 老师对着空白框干等,服务端那边房间却是活的。
|
||||
*/
|
||||
const handleEditorReady = (payload: { view: EditorView }) => {
|
||||
editorView.value = payload.view
|
||||
bind(payload.view)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => collabStore.room,
|
||||
async (room) => {
|
||||
(room) => {
|
||||
// 常规路径下开房时编辑器还没挂,这里是 null,由 @ready 接手;
|
||||
// 万一哪天 display-directive 改成 show(编辑器常驻),这条分支才起作用
|
||||
if (room && collabStore.isTeacher) {
|
||||
await nextTick()
|
||||
if (!editorView.value) return
|
||||
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
|
||||
start({ editorView: editorView.value as EditorView, seedContent: null })
|
||||
if (editorView.value) bind(editorView.value)
|
||||
} else {
|
||||
stop()
|
||||
// 编辑器随模态框一起卸载了,留着这个引用下轮就会绑到一个死 view 上
|
||||
editorView.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
editorView.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,8 +93,14 @@ onUnmounted(stop)
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
不绑 v-model:这个编辑器的内容完全由 Yjs 文档接管。
|
||||
原来绑了一个跨会话不清的 code ref —— 模态框重挂时 CodeMirror 拿它当初始
|
||||
文档,而 yCollab 只观察 ytext、从不反过来用 ytext 覆盖编辑器,于是上一个
|
||||
学生的代码留在文档里,新学生的内容作为 delta 插到位置 0,两边的偏移从此
|
||||
对不上,教师和学生显示的是两份不同的文档。
|
||||
-->
|
||||
<Codemirror
|
||||
v-model="code"
|
||||
indentWithTab
|
||||
:extensions="extensions"
|
||||
:tab-size="4"
|
||||
|
||||
@@ -737,6 +737,21 @@ export interface CollabMessage extends WebSocketMessage {
|
||||
export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
|
||||
private binaryHandler: ((data: ArrayBuffer) => void) | null = null
|
||||
private connectHandler: (() => void) | null = null
|
||||
/**
|
||||
* 还没装 handler 时先收着的二进制帧。
|
||||
*
|
||||
* room_open 是两端同时收到的,但两端把 yCollab 挂上去的时刻并不同步:
|
||||
* 教师端要等模态框挂出 CodeMirror,学生端要等 y* 那几个 chunk 下载完。
|
||||
* 谁先挂好谁就先发 SyncStep1,而对面这时还没有 handler ——
|
||||
* 服务端只管转发(peer.send() 是成功的),帧就在客户端这儿被静静丢掉了。
|
||||
*
|
||||
* 丢的偏偏是握手:y-protocols 里 A 的内容是靠 **B 发的 Step1** 换回来的。
|
||||
* 教师的 Step1 一丢,学生的代码就永远到不了教师那边 —— 教师看到空编辑器,
|
||||
* 自己敲的字倒是能同步过去,看着像在协作,实际上只有单向。
|
||||
*
|
||||
* 所以在这儿缓一手,等 setBinaryHandler 装上再按序放行。
|
||||
*/
|
||||
private pendingBinary: ArrayBuffer[] = []
|
||||
|
||||
constructor() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
@@ -745,6 +760,14 @@ export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
|
||||
|
||||
setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
|
||||
this.binaryHandler = handler
|
||||
if (!handler) {
|
||||
// 协作结束:攒下的帧属于上一轮,放到下一轮去只会污染新文档
|
||||
this.pendingBinary = []
|
||||
return
|
||||
}
|
||||
const buffered = this.pendingBinary
|
||||
this.pendingBinary = []
|
||||
for (const data of buffered) handler(data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -757,11 +780,22 @@ export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
|
||||
this.connectHandler = handler
|
||||
}
|
||||
|
||||
/** 一轮握手也就几帧,给个上限纯粹是防呆:真堆到这个数说明哪里不对 */
|
||||
private static readonly MAX_PENDING_BINARY = 64
|
||||
|
||||
protected override onBinary(data: ArrayBuffer) {
|
||||
this.binaryHandler?.(data)
|
||||
if (this.binaryHandler) {
|
||||
this.binaryHandler(data)
|
||||
return
|
||||
}
|
||||
if (this.pendingBinary.length < CollabWebSocket.MAX_PENDING_BINARY) {
|
||||
this.pendingBinary.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
protected override onConnected() {
|
||||
// 新连接,旧连接攒下的帧一概作废
|
||||
this.pendingBinary = []
|
||||
this.connectHandler?.()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user