diff --git a/doc/read-receipts.md b/doc/read-receipts.md new file mode 100644 index 0000000000000000000000000000000000000000..338fb17d53fd20e1235a8ddbaafd16a94efb4f65 --- /dev/null +++ b/doc/read-receipts.md @@ -0,0 +1,85 @@ +# Read Receipts + +We offer splitting messages into chunks in the library itself, so that the library users do not have to track this and can make use of a simple API. +The way this is achieved is we accept messages through `broadcast` or `cast` and cyclically display only chunks of those messages with some metadata. +The messages and their chunks are iterated sequentially, skipping the chunks we already know were received by the counterparty. + +In order to know when it is safe so stop displaying a certain chunk, we need some sort of a confirmation of reception from the counterparty. +Once we know all chunks were received, we can stop displaying the message altogether and let the sender know the recipient has received it. +That's what read receipt portion of the message header is for. + +We a peer A displays a broadcast and peer B notices it, it extends whatever it is displaying currently (broadcast or cast) by read receipt metadata. +From that point on, peer B tells the world it has seen a chunk (of a particular number) or a message (of a particular) number from a particual peer. +This information is ignored by peers it is not directed to, but is processed by the peer it is directed to, the peer A. + +Once the peer A gets a wind of this message chunk read receipt, it marks that chunks as transferred (invoking the send progress event handler) +and no longer shows that chunk of the message. That is, unless it is a broadcast message, in which case it continues showing it for other peers. +But if it is a cast message, it skips that chunk reducing the total amount of chunks it needs to go through in order to transfer the whole message. + +Peer A also needs to let the peer B know that it has seen its read receipt and it no longer needs to keep showing it. +If we didn't faciliate this, peer B would have to display a read receipt for any received chunk and the amount of metadata to transfer would soon +grow so large, exchanging it would go so slow, backups would be caused on the sender side (sender not knowing it can stop displaying some chunks in time). + +In order to resolve that potential problem, peer A starts displaying a read receipt read receipt for the peer B, so that peer B can notice that and stop. +Until now, the peer A was displaying a broadcast with no metadata, so from now on, its same broadcast (its various chunks being cycled through), come with +a metadata header in which read receipts are being cycled and at this time, the read receipt for B's read receipt is the sole read receipt being shown. + +B is still displaying chunks of its own broadcast, but also the read receipt for A's chunk it saw, and once processing the next frame from A, +one with the read receipt read receipt bundled in, it knows that not only did A see one of its chunk (with its read receipt), but also its read receipt. +It marks the seen chunk as seen (unless it is a broadcast chunk) and removes the read receipt it was showing. +From now on B shows a new read receipt for the read receipt from A. + +- [ ] Figure out how to prevent broadcast message chunks from generating read receipts, we probably need to keep a backlog for those +- [ ] Figure out if this has flaws or can be streamlined, because that's a lot of data +- [ ] Figure out if limiting to one message at a time would have benefits (no parallel WebRTC offer and candidates, but ultimately faster?) + +## Alternatives + +### No read receipts + +We could either send stuff slowly enough to be reasonably sure the recipient will have time to parse it, +but there are no guaranteed the devices won't move and lose sight of each other's QR codes. +We need to support that, otherwise the fragility of the system would make it useless. + +### No distinction between messages and chunks + +We could reduce the two numbers (`messageNumber` and `messageChunkNumber`) into one to save space when sending, +but getting rid of this distinction would mean that the recipient would have no information about how to stitch messages back together. + +This could work if we limited ourselves to one message at a time, which is a fair suggestion, but not something we want as this time. + +### Cycling between message numbers with their associated highest chunk number so far + +If we only sent the highest chunk number received so far instead of cycling through all of them, +(this being done in an effort to signal to the other peer they only need to send chunks higher than the one received,) +we would run at risk or not seeing some low chunks, because there is no guarantee we will start seeing the chunks from the first one +as they cycle on the screen we observe. + +### Cycling between message numbers with their associated lowest chunk number so far + +If we only send the lowest chunk number received so far instead of cycling through all of them, +(this being done in an effort to signal to the other peer they don't need to send chunks lower than the one received,) +we would run at risk of causing extreme backups on the sending end, where by missing one or a few of the first chunks, +but then seeing a lot of higher chunks of a long message, we cause the sender to always be sending the high chunks, +even though they are not needed anymore, all until it cycles through all high chunks and gets back to the lowest chunk +we haven't received yet. This would be less of a problem with short messages, but is not a good general solution. + +### Cycling between message numbers with their associated received chunk numbers so far + +That's what we're doing now. +This creates way more read receipts that the alternatives where there is only one associated chunk number for each message number, +but solves all the shortcomings of those. +We don't have hard numbers on this yet, but we expect to see this redundance pay off by not causing backups in any situation. + +### No cycling, concatenating read receipts in the message header + +Avoiding the need to cycle through the metadata by bundling them all into the message header with the data is an insufficient solution. +With multiple peers or even just two peers with multiple messages at one time, the amount of metadata would eclipse the amount of data, +causing the QR code to grow very large (and thus complicate scanning) while providing very low throughput, to no throughout, +if the code were just to grow too large because of all the metadata. + +This would be the fastest if we only ever sent one message to one peer at any one time, but doesn't scale beyond that. + +### Alternating between pure data messages and read receipt metadata messages + +Would this be any good? Who knows. Is the difference against the selected solution only in layout and not in speed? diff --git a/src/Channel.ts b/src/Channel.ts index 6c69383bf85e4cc2234ea366a08b05022a7cf861..068918313d2609b5a165ba9369dd769fd1a72c33 100644 --- a/src/Channel.ts +++ b/src/Channel.ts @@ -39,16 +39,20 @@ export default class Channel extends EventTarget { private name: string; - private sentMessages: SentMessage[] = []; - private sentMessageNumber = 0; - private sentMessageIndex = 0; - private sentMessageChunkIndex = 0; + private messages: SentMessage[] = []; + private messageNumber = 0; + private messageIndex = 0; + private messageChunkIndex = 0; private peers: Peer[] = []; - private readReceipts: ReadReceipt[] = []; - private readReceiptNumber = 0; - private readReceiptIndex = 0; + private messageReadReceipts: MessageReadReceipt[] = []; + private messageReadReceiptNumber = 0; + private messageReadReceiptIndex = 0; + + private readReceiptReadReceipts: ReadReceiptReadReceipt[] = []; + private readReceiptReadReceiptNumber = 0; + private readReceiptReadReceiptIndex = 0; private viewfinderVideo: HTMLVideoElement; private snapshotCanvas: HTMLCanvasElement; @@ -173,9 +177,10 @@ export default class Channel extends EventTarget { return; } - this.readReceiptNumber++; - this.readReceipts.push({ - number: this.readReceiptNumber, + // TODO: Create read receipt for message and read receipt only if they don't already exist + this.messageReadReceiptNumber++; + this.messageReadReceipts.push({ + number: this.messageReadReceiptNumber, recipientName: message.senderName, recipientMessageNumber: message.senderMessageNumber, recipientMessageChunkNumber: message.senderMessageChunkNumber, @@ -191,11 +196,11 @@ export default class Channel extends EventTarget { if (message.readReceipt !== null) { const { recipientMessageNumber, recipientMessageChunkNumber, recipientReadReceiptNumber } = message.readReceipt; - const index = this.readReceipts.findIndex(rr => rr.number === recipientReadReceiptNumber); + const index = this.messageReadReceipts.findIndex(rr => rr.number === recipientReadReceiptNumber); // Ignore not-found read receipts, because we will get a few scans with the read receipt until the counterparty sees ours where we tell it we saw it. if (index !== -1) { - this.readReceipts.splice(index, 1); + this.messageReadReceipts.splice(index, 1); } // TODO: Emit `sentProgress`, `receivedProgress` and `message` events on Peer. @@ -218,12 +223,12 @@ export default class Channel extends EventTarget { * @param message The message to remove. */ public unbroadcast(message: string) { - const index = this.sentMessages.findIndex(m => m.recipientName === null /* Broadcast */ && m.message === message); + const index = this.messages.findIndex(m => m.recipientName === null /* Broadcast */ && m.message === message); if (index === -1) { throw new Error(`There is no broadcast for the message '${message}'.`); } - this.sentMessages.splice(index, 1); + this.messages.splice(index, 1); } /** @@ -232,8 +237,8 @@ export default class Channel extends EventTarget { * @param message The message to cast. */ private cast(recipientName: string | null /* Broadcast */, message: string) { - this.sentMessageNumber++; - this.sentMessages.push({ message, messageNumber: this.sentMessageNumber, recipientName }); + this.messageNumber++; + this.messages.push({ message, messageNumber: this.messageNumber, recipientName }); // Start cycling unless we already are (then we will get to the new message by it). if (this.cyclingHandle === undefined) { @@ -245,37 +250,37 @@ export default class Channel extends EventTarget { * Displays the current message and read receipt and schedules advancing the message and read receipt to the next, or stops if there is nothing to advance to. */ private cycle() { - const message = this.sentMessages[this.sentMessageIndex]; - const chunkIndex = this.sentMessageChunkIndex * Channel.MESSAGE_PAYLOAD_SIZE; + const message = this.messages[this.messageIndex]; + const chunkIndex = this.messageChunkIndex * Channel.MESSAGE_PAYLOAD_SIZE; const chunk = message.message.slice(chunkIndex, chunkIndex + Channel.MESSAGE_PAYLOAD_SIZE); const chunkCount = Math.ceil(message.message.length / Channel.MESSAGE_PAYLOAD_SIZE); - const readReceipt = this.readReceipts.length > 0 ? this.readReceipts[this.readReceiptIndex] : null; - this.display(0, 'H', new Message(this.name, message.messageNumber, this.sentMessageChunkIndex + 1, chunkCount, message.recipientName, readReceipt, chunk)); + const readReceipt = this.messageReadReceipts.length > 0 ? this.messageReadReceipts[this.messageReadReceiptIndex] : null; + this.display(0, 'H', new Message(this.name, message.messageNumber, this.messageChunkIndex + 1, chunkCount, message.recipientName, readReceipt, chunk)); // Move to next chunk (if any), next message (if any) or stop cycling. - if (this.sentMessageChunkIndex === chunkCount - 1) { - this.sentMessageChunkIndex = 0; + if (this.messageChunkIndex === chunkCount - 1) { + this.messageChunkIndex = 0; // Move to next message (if any) or stop cycling. - if (this.sentMessageIndex === this.sentMessages.length - 1) { - this.sentMessageIndex = 0; + if (this.messageIndex === this.messages.length - 1) { + this.messageIndex = 0; // Stop cycling and keep displaying the only chunk unless we have read receipts to keep cycling through on this one message. - if (this.readReceipts.length === 0 && (this.sentMessages.length === 0 || (this.sentMessages.length === 1 && this.sentMessages[0].message.length < Channel.MESSAGE_PAYLOAD_SIZE))) { + if (this.messageReadReceipts.length === 0 && (this.messages.length === 0 || (this.messages.length === 1 && this.messages[0].message.length < Channel.MESSAGE_PAYLOAD_SIZE))) { this.cyclingHandle = undefined; return; } } else { - this.sentMessageIndex++; + this.messageIndex++; } } else { - this.sentMessageChunkIndex++; + this.messageChunkIndex++; } // Move onto the next read receipt to show or wrap around to the first again. - if (this.readReceipts.length > 1 /* Sole read receipt doesn't need advancing. */) { - if (this.readReceiptIndex === this.readReceipts.length - 1) { - this.readReceiptIndex = 0; + if (this.messageReadReceipts.length > 1 /* Sole read receipt doesn't need advancing. */) { + if (this.messageReadReceiptIndex === this.messageReadReceipts.length - 1) { + this.messageReadReceiptIndex = 0; } else { - this.readReceiptIndex++; + this.messageReadReceiptIndex++; } } diff --git a/todo/Document the read receipt approach.md b/todo/Document the read receipt approach.md deleted file mode 100644 index 7069189face27e51f7329d4adee384c0a5b1be3d..0000000000000000000000000000000000000000 --- a/todo/Document the read receipt approach.md +++ /dev/null @@ -1,8 +0,0 @@ -# Document the read receipt approach - -Sailient points: - -- We do bundle all read receipts into one QR code because they would never fit -- We support multiple peers, not just 1:1s -- We rely on fast rotation to showcase all read receipts in a reasonable time -- We acknowledge that a peer will end up showing one chunk many times until it sees the receipt, but there is no alternative