重建破碎包裹的边界
目标
实现分帧、期限和半关闭,并在真实 TCP 上验证。
为什么重要
如果相信收到的片段就是一条消息,在本地可能成功,但遇到真实输入就会出错。用代码表达长度、总时间预算和套接字所有权,以区分数据丢失和无限等待。只使用标准库,不需要访问互联网或安装任何东西。
步骤
- 信封上写的是字节数,而不是字符数——在 codec.py 中创建 MAX_PAYLOAD=4096 和 encode(payload)。在 bytes 正文前加上 unsigned 4 字节 big-endian 长度,并返回 bytes。允许空正文,如果不是 bytes 或超过 4096 个字节,则抛出 ValueError。
- 头部也可能被切成两半——在 codec.py 中添加 Decoder。每个新实例的接收状态相互独立,feed(chunk: bytes) 返回已完成正文的 list[bytes]。即使把一个帧在任意字节处切开分两次提供,也能还原,未完成的数据保留到下一次调用。
- 三份包裹装进了一个袋子——改进 Decoder.feed,如果一个 chunk 中有多个帧,就按顺序全部返回。如果下一个帧还剩一部分,就保留。空正文也是一条消息,空 chunk 不是 EOF。
- 送达完毕与运输途中损坏——创建 Decoder.finish()。有未完成的头部或正文时为 EOFError,没有剩余数据时返回 None。不要把正常解析出的长度为 0 的正文误认为中途结束。
- 拒绝 4 GiB 的包裹预告单——Decoder 一收到大于 4096 的长度头部,就必须立即抛出 ValueError。不要等到真实正文到达,也不要先分配该长度的缓冲区。4096 个字节的正文仍然允许。出错之后废弃该连接。
- 一次发一个字符,会永远等下去吗——在 transport.py 中实现 recv_frame(sock, timeout, clock=None)。一个帧的头部和正文整体共用同一个时间预算。clock 是不带参数、以秒为单位的时钟函数,默认为 time.monotonic。只使用 sock 的 gettimeout/settimeout/recv。正常的帧返回 bytes,在一个字节的新头部都没收到之前遇到 EOF 则返回 None,中途 EOF 为 EOFError,超长长度为 ValueError,超过期限为 TimeoutError。只允许正的有限 timeout,并且无论成功还是失败,都要恢复之前的套接字 timeout。
- “已经发送”只有一半是真的——在 transport.py 中添加 send_frame(sock, payload)。发送 encode 后的完整帧,并且不要隐藏发送错误。即使 send 只处理了一部分字节,也不能漏掉帧。由调用方决定套接字的发送 timeout。
- 通过真实连接收到回执单——在 server.py 中创建 handle_connection(sock, timeout)。接管所收到套接字的所有权,对每个帧回显同样的 bytes。在 recv_frame 的正常 EOF(None)处结束,但要回显 b"" 正文。在所有结束路径上都关闭套接字,并把协议错误传给调用方。检查器会在 127.0.0.1 的临时端口上建立真实的 TCP 连接,客户端只半关闭发送方向之后,也必须能收到剩余的响应。
参考
所有文件都放在 /root/tcp-parcel 下。示例文件中有的是函数框架,而不是正确答案。之前步骤的函数也要继续保留。评分会运行提交的代码,超过 12 秒就会中断。send_frame 的发送 timeout 由调用方决定。实验会话结束后文件不会保留,请另行保存。
信封上写的是字节数,而不是字符数
在 codec.py 中创建 MAX_PAYLOAD=4096 和 encode(payload)。在 bytes 正文前加上 unsigned 4 字节 big-endian 长度,并返回 bytes。允许空正文,如果不是 bytes 或超过 4096 个字节,则抛出 ValueError。
struct.pack 的 !I 是网络字节序的 32 位无符号整数。UTF-8 转换由调用方先做。
头部也可能被切成两半
在 codec.py 中添加 Decoder。每个新实例的接收状态相互独立,feed(chunk: bytes) 返回已完成正文的 list[bytes]。即使把一个帧在任意字节处切开分两次提供,也能还原,未完成的数据保留到下一次调用。
在收集到至少 4 个字节之前不要解析头部。即使知道了长度,完整的正文也可能还没有到。
三份包裹装进了一个袋子
改进 Decoder.feed,如果一个 chunk 中有多个帧,就按顺序全部返回。如果下一个帧还剩一部分,就保留。空正文也是一条消息,空 chunk 不是 EOF。
即使移除了已完成的帧,也要用 while 检查下一个头部。如果每次 feed 都新建 Decoder,剩余数据就会消失。
送达完毕与运输途中损坏
创建 Decoder.finish()。有未完成的头部或正文时为 EOFError,没有剩余数据时返回 None。不要把正常解析出的长度为 0 的正文误认为中途结束。
连接结束这个外部事件,与单纯“这次没有收到字节”这一输入,是两回事。finish 要在确实知道 EOF 时调用。
拒绝 4 GiB 的包裹预告单
Decoder 一收到大于 4096 的长度头部,就必须立即抛出 ValueError。不要等到真实正文到达,也不要先分配该长度的缓冲区。4096 个字节的正文仍然允许。出错之后废弃该连接。
读取头部之后立即检查长度。“可以用 32 位整数表示”,与“服务允许”是两回事。
一次发一个字符,会永远等下去吗
在 transport.py 中实现 recv_frame(sock, timeout, clock=None)。一个帧的头部和正文整体共用同一个时间预算。clock 是不带参数、以秒为单位的时钟函数,默认为 time.monotonic。只使用 sock 的 gettimeout/settimeout/recv。正常的帧返回 bytes,在一个字节的新头部都没收到之前遇到 EOF 则返回 None,中途 EOF 为 EOFError,超长长度为 ValueError,超过期限为 TimeoutError。只允许正的有限 timeout,并且无论成功还是失败,都要恢复之前的套接字 timeout。
开始时只确定一次 deadline=clock()+timeout,并在每次 recv 之前把剩余时间交给 settimeout。recv(n) 并不承诺把 n 个字节全部给出。
“已经发送”只有一半是真的
在 transport.py 中添加 send_frame(sock, payload)。发送 encode 后的完整帧,并且不要隐藏发送错误。即使 send 只处理了一部分字节,也不能漏掉帧。由调用方决定套接字的发送 timeout。
sendall 要么全部发送,要么抛出异常。如果自己循环,就只按 send 返回的长度向前移动,并把返回 0 当作终止错误处理。
通过真实连接收到回执单
在 server.py 中创建 handle_connection(sock, timeout)。接管所收到套接字的所有权,对每个帧回显同样的 bytes。在 recv_frame 的正常 EOF(None)处结束,但要回显 b"" 正文。在所有结束路径上都关闭套接字,并把协议错误传给调用方。检查器会在 127.0.0.1 的临时端口上建立真实的 TCP 连接,客户端只半关闭发送方向之后,也必须能收到剩余的响应。
用 with sock 把生命周期绑定起来。if not payload 会混淆 None 和空正文。对只关闭了发送方向的对方,仍然可以响应。