2023-08-07 | #guppy #upd #protocols | @Acidus
@Dima wrote up a neat idea for a "lighter" protocol, in the vein of Gemini and Spartan, and called it Guppy.
The Guppy Protocol Specification v0.1
Guppy is most like Spartan, or @solderpunk's proposed but never implemented Mercury protocol, since it doesn't use TLS.
What makes Guppy most interesting is it uses UDP instead of TCP.
I think it's awesome that people are playing with protocols, and even more so, publishing their thoughts for the community. It's easy to poop on someone's work and I don't want to do that. I have a follow up gemlog to post with suggestions I would make to the Guppy spec without changing its UDP principles. However in this post I want discuss the challenges I would have as a developer implementing Guppy due to its use of UDP, and those limitations.
@Slodr posted some feedback on Guppy as well. Their feedback is similar to mine, and worth the read:
TCP is a marvel that abstracts away a ton of problems us, of which the most relevant to transmitting small text documents are:
Guppy's use of UDP pushes the burden of solving these problems into the application protocol. Guppy does have a primitive acknowledgement system, and a system to determine the order. Making these part of the Guppy protocol increases the complexity of clients and servers. But how complex?
Here is the pseudo code to make a request and read a response for a TCP-based protocol like Gemini or Spartan. This ignores all handling of errors or content types:
tcpSocket = CreateTcpSocket(hostname, 1965) tcpSocket.Write(requestBuffer, 0, requestBuffer.Length); //send request do { readCount = tcpSocket.Read(buffer, 0, buffer.Length); //read some of the response if(readCount > 0) { Console.Write(buffer[0..readCount]); //write it to console } } while(readCount > 0) // Nothing left to read, entire response received, we are done
Here is what that looks like for Guppy:
udpSocket = CreateUdpSocket(hostname, 6775) lastStatus = null; udpSocket.Write(requestBuffer, 0, requestBuffer.Length); //send request while(true) { udpSocket.Read(buffer, 0, buffer.Lenth); //read some chunk statusLine = ExtractStatusLine(buffer); if(statusLine == lastStatus) { // we have seen this status line before, so the server // is sending data we already have. Probably because our ACK // got lost and this is a needless retransmission. ACK it again udpSocket.Write(statusLine); } else { //We haven't seen this status line before, it's new data data = ExtractData(buffer); if(data.Length == 0) { //got an EOL packet, need to ACK and then I can exit udpSocket.Write(statusLine); break; } Console.Write(data); udpSocket.Write(statusLine); //send the ACK } } // Nothing left to read, entire response received, we are done
Notice I'm now handling ACKing manually. I need functions that can extract out the status line of each packet and it's data, I need state to make sure I'm not getting duplicate, retransmitted data. This is a little more verbose than it needs to be, but I wanted to be super clear about the cases a Guppy client has to handle.
And this is just for the client. A Guppy server would be more complicated, especially to deal with EOF. What happens if the server doesn't get an ACK for its EOF? Was the EOF lost? Was the ACK for the EOF lost? How long should the server wait until it sends the EOF again? How long should the server wait for ACKs in general anyway? How many times should the EOF get sent without a reply for the server gives up?
There is a reason why the state diagram for closing a TCP connection is so complex. With TCP, this complexity gets handled by someone else's code and all the strange edge cases are handled as well. With Guppy, each client and server implementation has to deal with it.
So less complexity is first reason to use TCP instead of Guppy's UDP implementation.
Ignore the added complexity caused by using UDP for a moment. Is Guppy fast? No. As designed Guppy v0.1:
Guppy's solution to reliable delivery of data and guaranteed order of data is primitive. Packets can't arrive out of order because multiple packets aren't allow, and each packet must be acknowledged before the next can be sent. Being primitive keeps the spec small, which is nice, but as a consequence: data is delivered, sequentially, 512 bytes (at most) at a time, with (at least) 1 delay between each packet for the client to acknowledge that it got some data.
The latest Lupa stats show the median size of a gemtext document is ~ 2600 bytes.
Lupa Statistics on the Gemini space
To deliver that document over Guppy would require 6 packets from the server to client (a Success packet, and then 4 data packets, and then a EOL packet). Each packet takes 2x the latency between the client and the server (since the client has to ACK it before the next packet is sent). If the client is the US and the server is in Europe, that ~100ms, each direction. This is tremendously inefficient. With Guppy, a significant amount of time is spent waiting.
TCP, on the other hand, is very efficient in how it handles ACKing and ordering!
Which makes sense. TCP has had 40+ years of real-world use and optimization.
So slow throughput is the second reason to use TCP instead of Guppy's UDP implementation.
So what if Guppy is slow! That's not the point. It's not a design goal. Ok, so what are the design goals?
Dima talks about wanting to have something simple for microcontrollers (hence no TLS). However the microcontrollers listed (Raspberry Pi Pico W, ESP32 or ESP8266) all have readily available, free, and libre TCP/IP stacks. I would wager that any microcontroller that can do UDP can do TCP, so dropping TCP for UDP doesn't make sense. Furthermore, I would suggest that if the point is to target a limited environment like a microcontroller, a poorly optimized UDP-based protocol would use more resources and be slower than a TCP-based one. Which doesn't make sense.
(If we were talking about bit-banging I/O pins on a 8051, UDP over TCP might make more sense, but we aren't).
Furthermore, TCP stacks for constrained environments can be made simple (slower start speeds, smaller windows, few un-ACKed in-flight packets, etc). So TCP can adjust to whatever environment it is in, whereas Guppy's behavior is defined in the standard and cannot.
So efficiency and constrained environments is the third reason to use TCP instead of Guppy's UDP implementation.
One reason people may be thinking about UDP is that some application protocols, most notably HTTP/3 (and QUIC on which it is based), switched from using TCP to using UDP with custom retransmission logic. It's important to understand what problems informed that choice. Replacing TCP was just the latest (and I would say the most extreme) example of a major driving force in the the evolution of HTTP: improving the efficiency of making multiple requests in parallel. Without getting too deep into the details:
Basically, in the quest for optimal efficiency, HTTP/3 replaced TCP with custom logic. All in service of improving client efficiency when making multiple parallel requests.
Which is not a problem that Gemini, Spartan, or Guppy has. None of these protocols need to make multiple requests in parallel. In fact "One request per document, no requests unless initiated by the user" is a core tenet of these protocols that the very idea of needing to make multiple requests is anathema. The decision to use UDP for HTTP/3 and QUIC is just not revelvant to smolweb protocols.
I really appreciate what Dima has done. I too like to create things for fun, and many times the final product really isn't the point: Just doing it is fun. So it's quite possible that while everything above is valid, none of it actually matters 😆 😎. However, I would humbly suggest that the choice of UDP over TCP runs against Guppy's stated design goals:
As such, I believe Dima should strongly consider using TCP instead of UDP for Guppy.
Of course, that would remove what I think is the most interesting part of Guppy: that it uses UDP! So, I'll post a follow up gemlog on Guppy with suggestion for the specification with it still being UDP based.