Masks and Control Frames · capstone
Capstone project: Project: A Complete Frame Codec
Every rule of this chapter now lives in ws_frames.py, but in separate encode_frame and decode_frame, the control-decode_frame, the close payload in parse_close_payload, and fragments are refused nowhere yet. A program that reads from a connection does not want to call five functions and check read_message, and gives every refusal in the codec the
What read_message returns
read_message(read_exact, *, role, max_size=MAX_SIZE) reads exactly one frame through read_exact and returns one of five events, each a
| The frame | The event |
|---|---|
| text | {"type": "text", "data": ...}, the payload decoded from str |
| binary | {"type": "binary", "data": ...}, the payload as bytes |
{"type": "ping", "data": ...}, the payload as bytes | |
| pong | {"type": "pong", "data": ...}, the payload as bytes |
| close | {"type": "close", "code": ..., "reason": ...}, as parse_close_payload reads them |
role says which side is reading. A "server" reads its client’s frames, so it calls decode_frame with require_mask=True; a "client" reads its server’s frames, with require_mask=False. max_size goes on to decode_frame unchanged.
Not everything that goes wrong is the peer breaking a rule. When the connection ends in the middle of a frame, read_exact raises ConnectionError, and read_message lets it through unchanged. Chapter 5 reports that ending as 1006, the code for a connection that ended without a close frame, which is a different story from a protocol error.
Give every refusal its code
When a peer breaks a rule, the reader closes the connection with a code that says why. Until now every refusal in the codec has been a
try:
raise ValueError(1002, "opcode 3 is reserved")
except ValueError as exc:
print(exc.args == (1002, "opcode 3 is reserved"))
print(exc.args[0], exc)
An exception keeps the values it was created with in its args attribute, a as exc finds the code in exc.args[0] and the reason in exc.args[1]. This block prints True, then 1002 (1002, 'opcode 3 is reserved'): printing a ValueError with two values shows the whole tuple. Chapter 5 reads exc.args[0] to choose the close frame it sends.
Here is every refusal on the read path, eleven already in the codec and three that read_message adds:
| # | Function | Refusal | Code |
|---|---|---|---|
| 1, 2 | parse_header_bytes | a reserved (RSV) bit set; a reserved opcode | 1002 |
| 3, 4, 5 | read_length | a 16-bit length below 126; a 64-bit length with its top bit set; a 64-bit length below 65,536 | 1002 |
| 6 | decode_frame | MASK not what the role requires (the direction rule) | 1002 |
| 7, 8 | decode_frame | a control frame over 125 bytes; a control frame with FIN clear (one raise may cover both) | 1002 |
| 9 | decode_frame | a payload over max_size | 1009 |
| 10, 11 | parse_close_payload | a one-byte payload; a code that may not travel | 1002 |
| new | read_message | FIN clear or opcode 0; text that is not UTF-8; a close reason that is not UTF-8 | 1003; 1007; 1007 |
Most of the codec’s refusals are 1002, protocol error: the peer broke a framing rule. A frame over the size limit breaks no rule, so it gets 1009, message too big. A fragment breaks no rule either: as Lesson 5 explained, the learning codec does not reassemble fragmented messages, and 1003, unsupported data, is the least wrong code for that limit of the codec. The errors on the encoding side, in encode_frame, reply_to_ping and close_payload, keep their one message. They report a mistake in your own program, not something a peer sent, so no close code belongs to them.
What does this print?
exc = ValueError(1009, "too big")
print(exc.args[0], exc.args[1])
Specific before general
Invalid UTF-8 shows up in two places: a text payload, and the reason of a close. In both, .decode("utf-8") raises UnicodeDecodeError, and read_message turns it into 1007, invalid payload data. For a text frame, put the decode in a try block with except UnicodeDecodeError: and raise ValueError(1007, ...) from the handler. For a close frame, do the same around the call to parse_close_payload.
Name UnicodeDecodeError in that handler, not ValueError. UnicodeDecodeError is a kind of ValueError, so a handler for ValueError would also catch the 1002 refusals that parse_close_payload raises for a one-byte payload or a code that may not travel, and they would come out as 1007. Naming the specific exception lets those refusals pass through untouched with their own code.
A server reads a masked text frame whose payload is c3 28. Which code does read_message raise?
Two roles, one function
The same frame can be fine for one role and a protocol error for the other. A masked frame is exactly what a server expects and exactly what a client must refuse, and the role
read_message(read_exact, role="client") reads a masked frame. What happens?
Fragments before content
read_message adds its checks after decode_frame has returned a frame, so by then every framing rule has been applied: the direction rule, the control-frame limits and the size limit. The fragment check comes next, before the opcode decides which event to build. A continuation frame has opcode 0, which belongs to none of the five events. And a text frame with FIN clear holds only part of a message, which may stop in the middle of a character that takes several bytes in UTF-8; decoding it first would refuse it with 1007, blaming the peer’s text, when the real reason is the codec’s own limit, 1003.
Your task
In ws_frames.py:
Work through the table above: change each refusal in
parse_header_bytes,read_length,decode_frameandparse_close_payloadfromValueError(message)toValueError(code, message)with the code the table gives. Rows 7 and 8 may share oneraise.Write
read_message(read_exact, *, role, max_size=MAX_SIZE): calldecode_framewithrequire_maskset for the role andmax_sizepassed on; raiseValueError(1003, ...)for a frame with FIN clear or opcode 0; return the event for each opcode, raisingValueError(1007, ...)for text or a close reason that is not UTF-8. LetConnectionErrorthrough.
Press Run. The Lesson 6 section feeds a server the RFC’s masked “Hello”, an empty masked ping and a masked close 1000, and prints the three events your read_message returns:
Lesson 6: what a server reads
[{'type': 'text', 'data': 'Hello'}, {'type': 'ping', 'data': b''}, {'type': 'close', 'code': 1000, 'reason': ''}]
Then press Submit. The check first runs a few unseen cases from Lessons 1 to 4. Then it walks the table row by row, sending read_message one frame that breaks each rule, and names the first function whose refusal reaches the caller without the right code. Then it reads every kind of event for both roles, delivered whole, one byte and two bytes at a time, two frames back to back, broken frames that must be refused with their code and a str reason, an oversized frame that must be refused before its payload is read, and a connection that ends mid-frame.
The learning codec is now complete for messages that fit in one frame, and nothing more: it does not reassemble fragments, and it opens no
Task
Finish the learning codec in ws_frames.py.
Give every refusal on the read path its
, as ValueError(code, message)with the code first:# Refusal Code 1, 2 parse_header_bytesa reserved (RSV) bit set; a reserved 1002 3, 4, 5 read_lengtha 16-bit length below 126; a 64-bit length with its top bit set; a 64-bit length below 65,536 1002 6 decode_framenot what the role requires 1002 7, 8 decode_framea control over 125 bytes; a control frame with FIN clear 1002 9 decode_framea payload over max_size1009 10, 11 parse_close_payloada one-byte payload; a code that may not travel 1002 Leave the errors of
encode_frame,reply_to_pingandclose_payloadas they are.Write
read_message(read_exact, *, role, max_size=MAX_SIZE). Decode one frame withrequire_mask=Truewhenroleis"server"andFalsewhen it is"client", passingmax_sizeon. RaiseValueError(1003, ...)for a frame with FIN clear or opcode 0. Return{"type": "text", "data": str},{"type": "binary", "data": bytes},{"type": "ping", "data": bytes},{"type": "pong", "data": bytes}or{"type": "close", "code": int, "reason": str}. RaiseValueError(1007, ...)when a text payload or a close reason is not. Let a ConnectionErrorfromread_exactthrough unchanged.
Press Run: the Lesson 6 section should print [{'type': 'text', 'data': 'Hello'}, {'type': 'ping', 'data': b''}, {'type': 'close', 'code': 1000, 'reason': ''}]. Then press Submit. The check observes only what read_message returns and raises, with the code as exc.args[0] and a str reason as exc.args[1]. It reruns unseen cases from Lessons 1 to 4, walks the table in order with one frame per row and names the first function whose refusal arrives without the right code, then reads every event for both roles whole, one byte and two bytes at a time, two frames back to back, the refusals with their codes (an oversized frame before any read of its payload), and a frame cut off mid-way, which must raise ConnectionError. Events may hold extra keys.