Skip to content

Commit 9d5b786

Browse files
committed
docs: clarify ack direction and report client failures
1 parent cb34d22 commit 9d5b786

File tree

3 files changed

+44
-10
lines changed

3 files changed

+44
-10
lines changed

packages/loro-websocket/src/client/index.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface PendingRoom {
4141
}
4242

4343
interface InternalRoomHandler {
44-
handleDocUpdate(updates: Uint8Array[]): void;
44+
handleDocUpdate(updates: Uint8Array[], refId?: HexString): void;
4545
handleAck(ack: Ack): void;
4646
handleRoomError(error: RoomError): void;
4747
}
@@ -166,7 +166,7 @@ export class LoroWebsocketClient {
166166
private pendingRooms: Map<string, PendingRoom> = new Map();
167167
private activeRooms: Map<string, ActiveRoom> = new Map();
168168
// Buffer for %ELO only: backfills can arrive immediately after JoinResponseOk
169-
private preJoinUpdates: Map<string, Uint8Array[]> = new Map();
169+
private preJoinUpdates: Map<string, Array<{ updates: Uint8Array[]; refId?: HexString }>> = new Map();
170170
private fragmentBatches: Map<string, FragmentBatch> = new Map();
171171
private roomAdaptors: Map<string, CrdtDocAdaptor> = new Map();
172172
// Track roomId for each active id so we can rejoin on reconnect
@@ -641,12 +641,12 @@ export class LoroWebsocketClient {
641641
case MessageType.DocUpdate: {
642642
const active = this.activeRooms.get(roomId);
643643
if (active) {
644-
active.handler.handleDocUpdate(msg.updates);
644+
active.handler.handleDocUpdate(msg.updates, msg.batchId);
645645
} else {
646646
const pending = this.pendingRooms.get(roomId);
647647
if (pending) {
648648
const buf = this.preJoinUpdates.get(roomId) ?? [];
649-
buf.push(...msg.updates);
649+
buf.push({ updates: msg.updates, refId: msg.batchId });
650650
this.preJoinUpdates.set(roomId, buf);
651651
}
652652
}
@@ -763,12 +763,12 @@ export class LoroWebsocketClient {
763763
const active = this.activeRooms.get(id);
764764
if (active) {
765765
// Treat reassembled data as a single update
766-
active.handler.handleDocUpdate([reassembledData]);
766+
active.handler.handleDocUpdate([reassembledData], batch.header.batchId);
767767
} else {
768768
const pending = this.pendingRooms.get(id);
769769
if (pending) {
770770
const buf = this.preJoinUpdates.get(id) ?? [];
771-
buf.push(reassembledData);
771+
buf.push({ updates: [reassembledData], refId: batch.header.batchId });
772772
this.preJoinUpdates.set(id, buf);
773773
}
774774
}
@@ -791,7 +791,9 @@ export class LoroWebsocketClient {
791791
const buf = this.preJoinUpdates.get(id);
792792
if (buf && buf.length) {
793793
try {
794-
handler.handleDocUpdate(buf);
794+
for (const entry of buf) {
795+
handler.handleDocUpdate(entry.updates, entry.refId);
796+
}
795797
} finally {
796798
this.preJoinUpdates.delete(id);
797799
}
@@ -1457,8 +1459,29 @@ class LoroWebsocketClientRoomImpl
14571459
return this.crdtAdaptor.waitForReachingServerVersion();
14581460
}
14591461

1460-
handleDocUpdate(updates: Uint8Array[]) {
1461-
this.crdtAdaptor.applyUpdate(updates);
1462+
handleDocUpdate(updates: Uint8Array[], refId?: HexString) {
1463+
try {
1464+
this.crdtAdaptor.applyUpdate(updates);
1465+
} catch (error) {
1466+
// Surface to adaptor for custom handling
1467+
this.crdtAdaptor.handleUpdateError?.(error);
1468+
// Inform server that the update failed if we can reference the batch ID
1469+
if (refId && this.client.socket?.readyState === WebSocket.OPEN) {
1470+
try {
1471+
this.client.socket.send(
1472+
encode({
1473+
type: MessageType.Ack,
1474+
crdt: this.crdtType,
1475+
roomId: this.roomId,
1476+
refId,
1477+
status: UpdateStatusCode.InvalidUpdate,
1478+
} as Ack)
1479+
);
1480+
} catch (err) {
1481+
console.error("Failed to send failure Ack", err);
1482+
}
1483+
}
1484+
}
14621485
}
14631486

14641487
handleAck(ack: Ack) {

packages/loro-websocket/src/server/simple-server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ export class SimpleServer {
265265
this.handleLeave(client, message);
266266
break;
267267
case MessageType.Ack:
268+
// Clients may report failures when they cannot apply a server update.
269+
if (message.status !== UpdateStatusCode.Ok) {
270+
console.warn(
271+
`Client reported update failure for ${message.crdt}:${message.roomId} ref ${message.refId} status ${message.status}`
272+
);
273+
}
274+
break;
268275
case MessageType.RoomError:
269276
// Server does not expect these from clients; ignore.
270277
break;

protocol.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ When Recv receives updates in the same room from other peers, it broadcasts them
9292

9393
When Req makes local edits on the document, it sends `DocUpdate` (with its Update Batch ID) or `DocUpdateFragment` messages to Recv. Recv MUST reply with an `Ack` referencing that Update Batch ID (or the fragment batch ID when fragments are used). A status of `0x00` confirms acceptance; non-zero statuses follow Update Status Codes. For example, if Req lacks write permission, Recv sends `Ack(status=0x03 permission_denied)` for that batch.
9494

95+
**WebSocket (client–server) directionality:** when Recv (the server) pushes updates to Req (the client), Req SHOULD NOT send `Ack(status=0x00)` back. The server already assumes delivery over the WebSocket. The client MAY send a non‑zero `Ack` (referencing the server’s batch ID) to report that it failed to apply the update (for example `invalid_update` or `fragment_timeout`).
96+
9597
If Recv forces the peer out of the room (permission change, quota enforcement, malicious behavior, etc.), it sends `RoomError`. After receiving `RoomError`, the peer MUST treat the room as closed and will not receive further messages until it rejoins.
9698

9799
Req sends `Leave` if it is no longer interested in updates for the target document.
@@ -111,6 +113,8 @@ If Recv times out waiting for remaining fragments of a batch, it MUST:
111113

112114
Upon receiving `Ack(status=0x07 fragment_timeout)`, Req SHOULD resend the whole batch (header + all fragments) with the same or a new batch ID.
113115

116+
If Req (client) times out while reassembling fragments sent by Recv (server), it MAY send `Ack(status=0x07 fragment_timeout)` with that batch ID to signal the failure. Servers MAY choose to resend the batch or fall back to a snapshot/delta strategy.
117+
114118
## Errors and status
115119

116120
Two message types carry error semantics; update-level results travel in `Ack` status bytes.
@@ -181,7 +185,7 @@ Implementation note: On platforms that support automatic responses (e.g., Cloudf
181185

182186
## Why these changes (v1)
183187

184-
- Reliable delivery semantics: explicit `Ack` for each update batch (or fragment batch) lets applications know whether a change was accepted instead of inferring success from silence.
188+
- Reliable delivery semantics: explicit `Ack` for each client‑originated update batch (or fragment batch) lets applications know whether a change was accepted instead of inferring success from silence; clients only emit `Ack` when a server‑originated update fails.
185189
- Clear eviction signal: `RoomError` distinguishes "your update failed" from "you are no longer in the room", enabling clients to stop syncing and prompt rejoin or escalation.
186190
- Better error mapping: moving `ok` to `0x00` and `unknown` to `0x01` aligns status bytes with common success/failure conventions and leaves room for app-specific errors.
187191
- Debuggability: 64-bit batch IDs make ACK correlation collision-resistant even on long-lived, multiplexed connections while adding negligible overhead versus the 256 KiB frame limit.

0 commit comments

Comments
 (0)