mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-07 13:11:30 +08:00
feat(dirty): implement binary protocol
Replace JSON-based protocol with binary format using 16-byte header: - Magic bytes (GD), version, message type, payload length, request ID - TLV-encoded payloads for efficient binary data transfer - No base64 encoding needed for binary data - Backwards compatible API (DirtyProtocol alias, dict-based interface) Header format inspired by OpenBSD msgctl/msgsnd.
This commit is contained in:
parent
0e0dc669c8
commit
1665857c0e
@ -3,89 +3,304 @@
|
|||||||
# See the NOTICE for more information.
|
# See the NOTICE for more information.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Dirty Arbiters Protocol
|
Dirty Worker Binary Protocol
|
||||||
|
|
||||||
Length-prefixed JSON message framing over Unix sockets.
|
Binary message framing over Unix sockets, inspired by OpenBSD msgctl/msgsnd.
|
||||||
Provides both async (primary) and sync (for HTTP workers) APIs.
|
Replaces JSON protocol for efficient binary data transfer.
|
||||||
|
|
||||||
Message Format:
|
Header Format (16 bytes):
|
||||||
+----------------+------------------+
|
+--------+--------+--------+--------+--------+--------+--------+--------+
|
||||||
| 4-byte length | JSON payload |
|
| Magic (2B) | Ver(1) | MType | Payload Length (4B) |
|
||||||
+----------------+------------------+
|
+--------+--------+--------+--------+--------+--------+--------+--------+
|
||||||
|
| Request ID (8 bytes) |
|
||||||
|
+--------+--------+--------+--------+--------+--------+--------+--------+
|
||||||
|
|
||||||
The length field is a 4-byte unsigned integer in network byte order (big-endian).
|
- Magic: 0x47 0x44 ("GD" for Gunicorn Dirty)
|
||||||
|
- Version: 0x01
|
||||||
|
- MType: Message type (REQUEST, RESPONSE, ERROR, CHUNK, END)
|
||||||
|
- Length: Payload size (big-endian uint32, max 64MB)
|
||||||
|
- Request ID: uint64 (replaces UUID string)
|
||||||
|
|
||||||
|
Payload is TLV-encoded (see tlv.py).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import struct
|
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
|
|
||||||
from .errors import DirtyProtocolError
|
from .errors import DirtyProtocolError
|
||||||
|
from .tlv import TLVEncoder
|
||||||
|
|
||||||
|
|
||||||
class DirtyProtocol:
|
# Protocol constants
|
||||||
"""Length-prefixed JSON messages over Unix sockets."""
|
MAGIC = b"GD" # 0x47 0x44
|
||||||
|
VERSION = 0x01
|
||||||
|
|
||||||
# 4-byte unsigned int, network byte order (big-endian)
|
# Message types (1 byte)
|
||||||
HEADER_FORMAT = "!I"
|
MSG_TYPE_REQUEST = 0x01
|
||||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
|
MSG_TYPE_RESPONSE = 0x02
|
||||||
|
MSG_TYPE_ERROR = 0x03
|
||||||
|
MSG_TYPE_CHUNK = 0x04
|
||||||
|
MSG_TYPE_END = 0x05
|
||||||
|
|
||||||
# Maximum message size (64 MB)
|
# Message type names (for backwards compatibility with old API)
|
||||||
MAX_MESSAGE_SIZE = 64 * 1024 * 1024
|
MSG_TYPE_REQUEST_STR = "request"
|
||||||
|
MSG_TYPE_RESPONSE_STR = "response"
|
||||||
|
MSG_TYPE_ERROR_STR = "error"
|
||||||
|
MSG_TYPE_CHUNK_STR = "chunk"
|
||||||
|
MSG_TYPE_END_STR = "end"
|
||||||
|
|
||||||
# Message types for future streaming support
|
# Map int types to string names
|
||||||
MSG_TYPE_REQUEST = "request"
|
MSG_TYPE_TO_STR = {
|
||||||
MSG_TYPE_RESPONSE = "response"
|
MSG_TYPE_REQUEST: MSG_TYPE_REQUEST_STR,
|
||||||
MSG_TYPE_ERROR = "error"
|
MSG_TYPE_RESPONSE: MSG_TYPE_RESPONSE_STR,
|
||||||
MSG_TYPE_CHUNK = "chunk"
|
MSG_TYPE_ERROR: MSG_TYPE_ERROR_STR,
|
||||||
MSG_TYPE_END = "end"
|
MSG_TYPE_CHUNK: MSG_TYPE_CHUNK_STR,
|
||||||
|
MSG_TYPE_END: MSG_TYPE_END_STR,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map string names to int types
|
||||||
|
MSG_TYPE_FROM_STR = {v: k for k, v in MSG_TYPE_TO_STR.items()}
|
||||||
|
|
||||||
|
# Header format: Magic (2) + Version (1) + Type (1) + Length (4) + RequestID (8) = 16
|
||||||
|
HEADER_FORMAT = ">2sBBIQ"
|
||||||
|
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
|
||||||
|
|
||||||
|
# Maximum message size (64 MB)
|
||||||
|
MAX_MESSAGE_SIZE = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class BinaryProtocol:
|
||||||
|
"""Binary message protocol for dirty worker IPC."""
|
||||||
|
|
||||||
|
# Export constants for external use
|
||||||
|
HEADER_SIZE = HEADER_SIZE
|
||||||
|
MAX_MESSAGE_SIZE = MAX_MESSAGE_SIZE
|
||||||
|
|
||||||
|
MSG_TYPE_REQUEST = MSG_TYPE_REQUEST_STR
|
||||||
|
MSG_TYPE_RESPONSE = MSG_TYPE_RESPONSE_STR
|
||||||
|
MSG_TYPE_ERROR = MSG_TYPE_ERROR_STR
|
||||||
|
MSG_TYPE_CHUNK = MSG_TYPE_CHUNK_STR
|
||||||
|
MSG_TYPE_END = MSG_TYPE_END_STR
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode(message: dict) -> bytes:
|
def encode_header(msg_type: int, request_id: int, payload_length: int) -> bytes:
|
||||||
"""
|
"""
|
||||||
Encode a message dict to length-prefixed bytes.
|
Encode the 16-byte message header.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: Dictionary to encode as JSON
|
msg_type: Message type (MSG_TYPE_REQUEST, etc.)
|
||||||
|
request_id: Unique request identifier (uint64)
|
||||||
|
payload_length: Length of the TLV-encoded payload
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bytes: Length-prefixed encoded message
|
bytes: 16-byte header
|
||||||
|
"""
|
||||||
|
return struct.pack(HEADER_FORMAT, MAGIC, VERSION, msg_type,
|
||||||
|
payload_length, request_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_header(data: bytes) -> tuple:
|
||||||
|
"""
|
||||||
|
Decode the 16-byte message header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 16 bytes of header data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (msg_type, request_id, payload_length)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DirtyProtocolError: If encoding fails
|
DirtyProtocolError: If header is invalid
|
||||||
"""
|
"""
|
||||||
try:
|
if len(data) < HEADER_SIZE:
|
||||||
payload = json.dumps(message).encode("utf-8")
|
raise DirtyProtocolError(
|
||||||
if len(payload) > DirtyProtocol.MAX_MESSAGE_SIZE:
|
f"Header too short: {len(data)} bytes, expected {HEADER_SIZE}",
|
||||||
|
raw_data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
magic, version, msg_type, length, request_id = struct.unpack(
|
||||||
|
HEADER_FORMAT, data[:HEADER_SIZE]
|
||||||
|
)
|
||||||
|
|
||||||
|
if magic != MAGIC:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Invalid magic: {magic!r}, expected {MAGIC!r}",
|
||||||
|
raw_data=data[:20]
|
||||||
|
)
|
||||||
|
|
||||||
|
if version != VERSION:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Unsupported protocol version: {version}, expected {VERSION}",
|
||||||
|
raw_data=data[:20]
|
||||||
|
)
|
||||||
|
|
||||||
|
if msg_type not in MSG_TYPE_TO_STR:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Unknown message type: 0x{msg_type:02x}",
|
||||||
|
raw_data=data[:20]
|
||||||
|
)
|
||||||
|
|
||||||
|
if length > MAX_MESSAGE_SIZE:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Message too large: {length} bytes (max: {MAX_MESSAGE_SIZE})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return msg_type, request_id, length
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_request(request_id: int, app_path: str, action: str,
|
||||||
|
args: tuple = None, kwargs: dict = None) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a request message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Unique request identifier (uint64)
|
||||||
|
app_path: Import path of the dirty app
|
||||||
|
action: Action to call on the app
|
||||||
|
args: Positional arguments
|
||||||
|
kwargs: Keyword arguments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete message (header + payload)
|
||||||
|
"""
|
||||||
|
payload_dict = {
|
||||||
|
"app_path": app_path,
|
||||||
|
"action": action,
|
||||||
|
"args": list(args) if args else [],
|
||||||
|
"kwargs": kwargs or {},
|
||||||
|
}
|
||||||
|
payload = TLVEncoder.encode(payload_dict)
|
||||||
|
header = BinaryProtocol.encode_header(MSG_TYPE_REQUEST, request_id,
|
||||||
|
len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_response(request_id: int, result) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a success response message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Request identifier this responds to
|
||||||
|
result: Result value (must be TLV-serializable)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete message (header + payload)
|
||||||
|
"""
|
||||||
|
payload_dict = {"result": result}
|
||||||
|
payload = TLVEncoder.encode(payload_dict)
|
||||||
|
header = BinaryProtocol.encode_header(MSG_TYPE_RESPONSE, request_id,
|
||||||
|
len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_error(request_id: int, error) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode an error response message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Request identifier this responds to
|
||||||
|
error: DirtyError instance, dict, or Exception
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete message (header + payload)
|
||||||
|
"""
|
||||||
|
from .errors import DirtyError
|
||||||
|
|
||||||
|
if isinstance(error, DirtyError):
|
||||||
|
error_dict = error.to_dict()
|
||||||
|
elif isinstance(error, dict):
|
||||||
|
error_dict = error
|
||||||
|
else:
|
||||||
|
error_dict = {
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"message": str(error),
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload_dict = {"error": error_dict}
|
||||||
|
payload = TLVEncoder.encode(payload_dict)
|
||||||
|
header = BinaryProtocol.encode_header(MSG_TYPE_ERROR, request_id,
|
||||||
|
len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_chunk(request_id: int, data) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a chunk message for streaming responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Request identifier this chunk belongs to
|
||||||
|
data: Chunk data (must be TLV-serializable)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete message (header + payload)
|
||||||
|
"""
|
||||||
|
payload_dict = {"data": data}
|
||||||
|
payload = TLVEncoder.encode(payload_dict)
|
||||||
|
header = BinaryProtocol.encode_header(MSG_TYPE_CHUNK, request_id,
|
||||||
|
len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_end(request_id: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode an end-of-stream message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Request identifier this ends
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete message (header + empty payload)
|
||||||
|
"""
|
||||||
|
# End message has empty payload
|
||||||
|
header = BinaryProtocol.encode_header(MSG_TYPE_END, request_id, 0)
|
||||||
|
return header
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_message(data: bytes) -> tuple:
|
||||||
|
"""
|
||||||
|
Decode a complete message (header + payload).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Complete message bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (msg_type_str, request_id, payload_dict)
|
||||||
|
msg_type_str is the string name (e.g., "request")
|
||||||
|
payload_dict is the decoded TLV payload as a dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DirtyProtocolError: If message is malformed
|
||||||
|
"""
|
||||||
|
msg_type, request_id, length = BinaryProtocol.decode_header(data)
|
||||||
|
|
||||||
|
if len(data) < HEADER_SIZE + length:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Incomplete message: expected {HEADER_SIZE + length} bytes, "
|
||||||
|
f"got {len(data)}",
|
||||||
|
raw_data=data[:50]
|
||||||
|
)
|
||||||
|
|
||||||
|
if length == 0:
|
||||||
|
# End message has empty payload
|
||||||
|
payload_dict = {}
|
||||||
|
else:
|
||||||
|
payload_data = data[HEADER_SIZE:HEADER_SIZE + length]
|
||||||
|
try:
|
||||||
|
payload_dict = TLVEncoder.decode_full(payload_data)
|
||||||
|
except DirtyProtocolError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
raise DirtyProtocolError(
|
raise DirtyProtocolError(
|
||||||
f"Message too large: {len(payload)} bytes "
|
f"Failed to decode TLV payload: {e}",
|
||||||
f"(max: {DirtyProtocol.MAX_MESSAGE_SIZE})"
|
raw_data=payload_data[:50]
|
||||||
)
|
)
|
||||||
length = struct.pack(DirtyProtocol.HEADER_FORMAT, len(payload))
|
|
||||||
return length + payload
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
raise DirtyProtocolError(f"Failed to encode message: {e}")
|
|
||||||
|
|
||||||
@staticmethod
|
# Convert to dict format similar to old JSON protocol
|
||||||
def decode(data: bytes) -> dict:
|
msg_type_str = MSG_TYPE_TO_STR[msg_type]
|
||||||
"""
|
|
||||||
Decode bytes (without length prefix) to message dict.
|
|
||||||
|
|
||||||
Args:
|
return msg_type_str, request_id, payload_dict
|
||||||
data: JSON bytes to decode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Decoded message
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DirtyProtocolError: If decoding fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return json.loads(data.decode("utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
raise DirtyProtocolError(f"Failed to decode message: {e}",
|
|
||||||
raw_data=data)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Async API (primary - for DirtyArbiter and DirtyWorker)
|
# Async API (primary - for DirtyArbiter and DirtyWorker)
|
||||||
@ -94,53 +309,62 @@ class DirtyProtocol:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def read_message_async(reader: asyncio.StreamReader) -> dict:
|
async def read_message_async(reader: asyncio.StreamReader) -> dict:
|
||||||
"""
|
"""
|
||||||
Read a complete message from async stream.
|
Read a complete binary message from async stream.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
reader: asyncio StreamReader
|
reader: asyncio StreamReader
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Decoded message
|
dict: Message dict with 'type', 'id', and payload fields
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DirtyProtocolError: If read fails or message is malformed
|
DirtyProtocolError: If read fails or message is malformed
|
||||||
asyncio.IncompleteReadError: If connection closed mid-read
|
asyncio.IncompleteReadError: If connection closed mid-read
|
||||||
"""
|
"""
|
||||||
# Read length header
|
# Read header
|
||||||
try:
|
try:
|
||||||
header = await reader.readexactly(DirtyProtocol.HEADER_SIZE)
|
header = await reader.readexactly(HEADER_SIZE)
|
||||||
except asyncio.IncompleteReadError as e:
|
except asyncio.IncompleteReadError as e:
|
||||||
if len(e.partial) == 0:
|
if len(e.partial) == 0:
|
||||||
# Clean close - no data was read
|
# Clean close - no data was read
|
||||||
raise
|
raise
|
||||||
raise DirtyProtocolError(
|
raise DirtyProtocolError(
|
||||||
f"Incomplete header: got {len(e.partial)} bytes, "
|
f"Incomplete header: got {len(e.partial)} bytes, "
|
||||||
f"expected {DirtyProtocol.HEADER_SIZE}",
|
f"expected {HEADER_SIZE}",
|
||||||
raw_data=e.partial
|
raw_data=e.partial
|
||||||
)
|
)
|
||||||
|
|
||||||
length = struct.unpack(DirtyProtocol.HEADER_FORMAT, header)[0]
|
msg_type, request_id, length = BinaryProtocol.decode_header(header)
|
||||||
|
|
||||||
if length > DirtyProtocol.MAX_MESSAGE_SIZE:
|
|
||||||
raise DirtyProtocolError(
|
|
||||||
f"Message too large: {length} bytes "
|
|
||||||
f"(max: {DirtyProtocol.MAX_MESSAGE_SIZE})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if length == 0:
|
|
||||||
raise DirtyProtocolError("Empty message received")
|
|
||||||
|
|
||||||
# Read payload
|
# Read payload
|
||||||
try:
|
if length > 0:
|
||||||
payload = await reader.readexactly(length)
|
try:
|
||||||
except asyncio.IncompleteReadError as e:
|
payload_data = await reader.readexactly(length)
|
||||||
raise DirtyProtocolError(
|
except asyncio.IncompleteReadError as e:
|
||||||
f"Incomplete message: got {len(e.partial)} bytes, "
|
raise DirtyProtocolError(
|
||||||
f"expected {length}",
|
f"Incomplete payload: got {len(e.partial)} bytes, "
|
||||||
raw_data=e.partial
|
f"expected {length}",
|
||||||
)
|
raw_data=e.partial
|
||||||
|
)
|
||||||
|
|
||||||
return DirtyProtocol.decode(payload)
|
try:
|
||||||
|
payload_dict = TLVEncoder.decode_full(payload_data)
|
||||||
|
except DirtyProtocolError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Failed to decode TLV payload: {e}",
|
||||||
|
raw_data=payload_data[:50]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload_dict = {}
|
||||||
|
|
||||||
|
# Build response dict
|
||||||
|
msg_type_str = MSG_TYPE_TO_STR[msg_type]
|
||||||
|
result = {"type": msg_type_str, "id": request_id}
|
||||||
|
result.update(payload_dict)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def write_message_async(writer: asyncio.StreamWriter,
|
async def write_message_async(writer: asyncio.StreamWriter,
|
||||||
@ -148,15 +372,17 @@ class DirtyProtocol:
|
|||||||
"""
|
"""
|
||||||
Write a message to async stream.
|
Write a message to async stream.
|
||||||
|
|
||||||
|
Accepts dict format for backwards compatibility.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
writer: asyncio StreamWriter
|
writer: asyncio StreamWriter
|
||||||
message: Dictionary to send
|
message: Message dict with 'type', 'id', and payload fields
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DirtyProtocolError: If encoding fails
|
DirtyProtocolError: If encoding fails
|
||||||
ConnectionError: If write fails
|
ConnectionError: If write fails
|
||||||
"""
|
"""
|
||||||
data = DirtyProtocol.encode(message)
|
data = BinaryProtocol._encode_from_dict(message)
|
||||||
writer.write(data)
|
writer.write(data)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
@ -201,27 +427,36 @@ class DirtyProtocol:
|
|||||||
sock: Socket to read from
|
sock: Socket to read from
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Decoded message
|
dict: Message dict with 'type', 'id', and payload fields
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DirtyProtocolError: If read fails or message is malformed
|
DirtyProtocolError: If read fails or message is malformed
|
||||||
"""
|
"""
|
||||||
# Read length header
|
# Read header
|
||||||
header = DirtyProtocol._recv_exactly(sock, DirtyProtocol.HEADER_SIZE)
|
header = BinaryProtocol._recv_exactly(sock, HEADER_SIZE)
|
||||||
length = struct.unpack(DirtyProtocol.HEADER_FORMAT, header)[0]
|
msg_type, request_id, length = BinaryProtocol.decode_header(header)
|
||||||
|
|
||||||
if length > DirtyProtocol.MAX_MESSAGE_SIZE:
|
|
||||||
raise DirtyProtocolError(
|
|
||||||
f"Message too large: {length} bytes "
|
|
||||||
f"(max: {DirtyProtocol.MAX_MESSAGE_SIZE})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if length == 0:
|
|
||||||
raise DirtyProtocolError("Empty message received")
|
|
||||||
|
|
||||||
# Read payload
|
# Read payload
|
||||||
payload = DirtyProtocol._recv_exactly(sock, length)
|
if length > 0:
|
||||||
return DirtyProtocol.decode(payload)
|
payload_data = BinaryProtocol._recv_exactly(sock, length)
|
||||||
|
try:
|
||||||
|
payload_dict = TLVEncoder.decode_full(payload_data)
|
||||||
|
except DirtyProtocolError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DirtyProtocolError(
|
||||||
|
f"Failed to decode TLV payload: {e}",
|
||||||
|
raw_data=payload_data[:50]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload_dict = {}
|
||||||
|
|
||||||
|
# Build response dict
|
||||||
|
msg_type_str = MSG_TYPE_TO_STR[msg_type]
|
||||||
|
result = {"type": msg_type_str, "id": request_id}
|
||||||
|
result.update(payload_dict)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_message(sock: socket.socket, message: dict) -> None:
|
def write_message(sock: socket.socket, message: dict) -> None:
|
||||||
@ -230,31 +465,92 @@ class DirtyProtocol:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
sock: Socket to write to
|
sock: Socket to write to
|
||||||
message: Dictionary to send
|
message: Message dict with 'type', 'id', and payload fields
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DirtyProtocolError: If encoding fails
|
DirtyProtocolError: If encoding fails
|
||||||
OSError: If write fails
|
OSError: If write fails
|
||||||
"""
|
"""
|
||||||
data = DirtyProtocol.encode(message)
|
data = BinaryProtocol._encode_from_dict(message)
|
||||||
sock.sendall(data)
|
sock.sendall(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_from_dict(message: dict) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a message dict to binary format.
|
||||||
|
|
||||||
# Message builder helpers
|
Supports the old dict-based API for backwards compatibility.
|
||||||
def make_request(request_id: str, app_path: str, action: str,
|
|
||||||
|
Args:
|
||||||
|
message: Message dict with 'type', 'id', and payload fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete encoded message
|
||||||
|
"""
|
||||||
|
msg_type_str = message.get("type")
|
||||||
|
request_id = message.get("id", 0)
|
||||||
|
|
||||||
|
# Handle string or int request IDs
|
||||||
|
if isinstance(request_id, str):
|
||||||
|
# For backwards compat with UUID strings, hash to int
|
||||||
|
request_id = hash(request_id) & 0xFFFFFFFFFFFFFFFF
|
||||||
|
|
||||||
|
msg_type = MSG_TYPE_FROM_STR.get(msg_type_str)
|
||||||
|
if msg_type is None:
|
||||||
|
raise DirtyProtocolError(f"Unknown message type: {msg_type_str}")
|
||||||
|
|
||||||
|
if msg_type == MSG_TYPE_REQUEST:
|
||||||
|
return BinaryProtocol.encode_request(
|
||||||
|
request_id,
|
||||||
|
message.get("app_path", ""),
|
||||||
|
message.get("action", ""),
|
||||||
|
message.get("args"),
|
||||||
|
message.get("kwargs")
|
||||||
|
)
|
||||||
|
elif msg_type == MSG_TYPE_RESPONSE:
|
||||||
|
return BinaryProtocol.encode_response(
|
||||||
|
request_id,
|
||||||
|
message.get("result")
|
||||||
|
)
|
||||||
|
elif msg_type == MSG_TYPE_ERROR:
|
||||||
|
return BinaryProtocol.encode_error(
|
||||||
|
request_id,
|
||||||
|
message.get("error", {})
|
||||||
|
)
|
||||||
|
elif msg_type == MSG_TYPE_CHUNK:
|
||||||
|
return BinaryProtocol.encode_chunk(
|
||||||
|
request_id,
|
||||||
|
message.get("data")
|
||||||
|
)
|
||||||
|
elif msg_type == MSG_TYPE_END:
|
||||||
|
return BinaryProtocol.encode_end(request_id)
|
||||||
|
else:
|
||||||
|
raise DirtyProtocolError(f"Unhandled message type: {msg_type}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Backwards Compatibility Aliases
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Alias BinaryProtocol as DirtyProtocol for drop-in replacement
|
||||||
|
DirtyProtocol = BinaryProtocol
|
||||||
|
|
||||||
|
|
||||||
|
# Message builder helpers (backwards compatible with old API)
|
||||||
|
def make_request(request_id, app_path: str, action: str,
|
||||||
args: tuple = None, kwargs: dict = None) -> dict:
|
args: tuple = None, kwargs: dict = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Build a request message.
|
Build a request message dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: Unique request identifier
|
request_id: Unique request identifier (int or str)
|
||||||
app_path: Import path of the dirty app (e.g., 'myapp.ml:MLApp')
|
app_path: Import path of the dirty app (e.g., 'myapp.ml:MLApp')
|
||||||
action: Action to call on the app
|
action: Action to call on the app
|
||||||
args: Positional arguments
|
args: Positional arguments
|
||||||
kwargs: Keyword arguments
|
kwargs: Keyword arguments
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Request message
|
dict: Request message dict
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": DirtyProtocol.MSG_TYPE_REQUEST,
|
"type": DirtyProtocol.MSG_TYPE_REQUEST,
|
||||||
@ -266,16 +562,16 @@ def make_request(request_id: str, app_path: str, action: str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_response(request_id: str, result) -> dict:
|
def make_response(request_id, result) -> dict:
|
||||||
"""
|
"""
|
||||||
Build a success response message.
|
Build a success response message dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: Request identifier this responds to
|
request_id: Request identifier this responds to
|
||||||
result: Result value (must be JSON-serializable)
|
result: Result value
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Response message
|
dict: Response message dict
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": DirtyProtocol.MSG_TYPE_RESPONSE,
|
"type": DirtyProtocol.MSG_TYPE_RESPONSE,
|
||||||
@ -284,16 +580,16 @@ def make_response(request_id: str, result) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_error_response(request_id: str, error) -> dict:
|
def make_error_response(request_id, error) -> dict:
|
||||||
"""
|
"""
|
||||||
Build an error response message.
|
Build an error response message dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: Request identifier this responds to
|
request_id: Request identifier this responds to
|
||||||
error: DirtyError instance or dict with error info
|
error: DirtyError instance or dict with error info
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Error response message
|
dict: Error response message dict
|
||||||
"""
|
"""
|
||||||
from .errors import DirtyError
|
from .errors import DirtyError
|
||||||
if isinstance(error, DirtyError):
|
if isinstance(error, DirtyError):
|
||||||
@ -314,16 +610,16 @@ def make_error_response(request_id: str, error) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_chunk_message(request_id: str, data) -> dict:
|
def make_chunk_message(request_id, data) -> dict:
|
||||||
"""
|
"""
|
||||||
Build a chunk message for streaming responses.
|
Build a chunk message dict for streaming responses.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: Request identifier this chunk belongs to
|
request_id: Request identifier this chunk belongs to
|
||||||
data: Chunk data (must be JSON-serializable)
|
data: Chunk data
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Chunk message
|
dict: Chunk message dict
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": DirtyProtocol.MSG_TYPE_CHUNK,
|
"type": DirtyProtocol.MSG_TYPE_CHUNK,
|
||||||
@ -332,15 +628,15 @@ def make_chunk_message(request_id: str, data) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_end_message(request_id: str) -> dict:
|
def make_end_message(request_id) -> dict:
|
||||||
"""
|
"""
|
||||||
Build an end-of-stream message.
|
Build an end-of-stream message dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_id: Request identifier this ends
|
request_id: Request identifier this ends
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: End message
|
dict: End message dict
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": DirtyProtocol.MSG_TYPE_END,
|
"type": DirtyProtocol.MSG_TYPE_END,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
# This file is part of gunicorn released under the MIT license.
|
# This file is part of gunicorn released under the MIT license.
|
||||||
# See the NOTICE for more information.
|
# See the NOTICE for more information.
|
||||||
|
|
||||||
"""Tests for dirty arbiter protocol module."""
|
"""Tests for dirty worker binary protocol module."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
@ -11,12 +11,23 @@ import struct
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from gunicorn.dirty.protocol import (
|
from gunicorn.dirty.protocol import (
|
||||||
|
BinaryProtocol,
|
||||||
DirtyProtocol,
|
DirtyProtocol,
|
||||||
make_request,
|
make_request,
|
||||||
make_response,
|
make_response,
|
||||||
make_error_response,
|
make_error_response,
|
||||||
make_chunk_message,
|
make_chunk_message,
|
||||||
make_end_message,
|
make_end_message,
|
||||||
|
MAGIC,
|
||||||
|
VERSION,
|
||||||
|
HEADER_SIZE,
|
||||||
|
HEADER_FORMAT,
|
||||||
|
MSG_TYPE_REQUEST,
|
||||||
|
MSG_TYPE_RESPONSE,
|
||||||
|
MSG_TYPE_ERROR,
|
||||||
|
MSG_TYPE_CHUNK,
|
||||||
|
MSG_TYPE_END,
|
||||||
|
MAX_MESSAGE_SIZE,
|
||||||
)
|
)
|
||||||
from gunicorn.dirty.errors import (
|
from gunicorn.dirty.errors import (
|
||||||
DirtyError,
|
DirtyError,
|
||||||
@ -26,118 +37,194 @@ from gunicorn.dirty.errors import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDirtyProtocolEncodeDecode:
|
class TestBinaryProtocolHeader:
|
||||||
"""Tests for encode/decode functionality."""
|
"""Tests for header encoding/decoding."""
|
||||||
|
|
||||||
def test_encode_decode_roundtrip(self):
|
def test_header_size(self):
|
||||||
"""Test basic encode/decode roundtrip."""
|
"""Test header size is 16 bytes."""
|
||||||
message = {"type": "request", "id": "123", "data": "hello"}
|
assert HEADER_SIZE == 16
|
||||||
encoded = DirtyProtocol.encode(message)
|
|
||||||
|
|
||||||
# Check header format
|
def test_encode_header(self):
|
||||||
assert len(encoded) > DirtyProtocol.HEADER_SIZE
|
"""Test header encoding."""
|
||||||
length = struct.unpack(
|
header = BinaryProtocol.encode_header(MSG_TYPE_REQUEST, 12345, 100)
|
||||||
DirtyProtocol.HEADER_FORMAT,
|
assert len(header) == HEADER_SIZE
|
||||||
encoded[:DirtyProtocol.HEADER_SIZE]
|
assert header[:2] == MAGIC
|
||||||
)[0]
|
assert header[2] == VERSION
|
||||||
assert length == len(encoded) - DirtyProtocol.HEADER_SIZE
|
assert header[3] == MSG_TYPE_REQUEST
|
||||||
|
|
||||||
# Decode payload
|
def test_decode_header(self):
|
||||||
payload = encoded[DirtyProtocol.HEADER_SIZE:]
|
"""Test header decoding."""
|
||||||
decoded = DirtyProtocol.decode(payload)
|
header = BinaryProtocol.encode_header(MSG_TYPE_RESPONSE, 67890, 200)
|
||||||
assert decoded == message
|
msg_type, request_id, length = BinaryProtocol.decode_header(header)
|
||||||
|
assert msg_type == MSG_TYPE_RESPONSE
|
||||||
|
assert request_id == 67890
|
||||||
|
assert length == 200
|
||||||
|
|
||||||
def test_encode_decode_complex_data(self):
|
def test_decode_header_invalid_magic(self):
|
||||||
"""Test with complex nested data structures."""
|
"""Test header decoding with invalid magic."""
|
||||||
message = {
|
header = b"XX" + b"\x01\x01" + b"\x00" * 12
|
||||||
"type": "response",
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
"id": "456",
|
BinaryProtocol.decode_header(header)
|
||||||
"result": {
|
assert "magic" in str(exc_info.value).lower()
|
||||||
"models": ["gpt-4", "claude-3"],
|
|
||||||
"config": {"temperature": 0.7, "max_tokens": 1000},
|
|
||||||
"metadata": None,
|
|
||||||
},
|
|
||||||
"args": [1, 2, 3],
|
|
||||||
}
|
|
||||||
encoded = DirtyProtocol.encode(message)
|
|
||||||
payload = encoded[DirtyProtocol.HEADER_SIZE:]
|
|
||||||
decoded = DirtyProtocol.decode(payload)
|
|
||||||
assert decoded == message
|
|
||||||
|
|
||||||
def test_encode_decode_unicode(self):
|
def test_decode_header_invalid_version(self):
|
||||||
"""Test with unicode characters."""
|
"""Test header decoding with invalid version."""
|
||||||
message = {
|
header = MAGIC + b"\x99\x01" + b"\x00" * 12
|
||||||
"type": "request",
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
"data": "Hello, world!"
|
BinaryProtocol.decode_header(header)
|
||||||
}
|
assert "version" in str(exc_info.value).lower()
|
||||||
encoded = DirtyProtocol.encode(message)
|
|
||||||
payload = encoded[DirtyProtocol.HEADER_SIZE:]
|
|
||||||
decoded = DirtyProtocol.decode(payload)
|
|
||||||
assert decoded == message
|
|
||||||
|
|
||||||
def test_encode_large_message(self):
|
def test_decode_header_invalid_type(self):
|
||||||
|
"""Test header decoding with invalid message type."""
|
||||||
|
header = MAGIC + bytes([VERSION, 0xFF]) + b"\x00" * 12
|
||||||
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
|
BinaryProtocol.decode_header(header)
|
||||||
|
assert "type" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_decode_header_too_large(self):
|
||||||
|
"""Test header decoding rejects too-large messages."""
|
||||||
|
header = struct.pack(HEADER_FORMAT, MAGIC, VERSION, MSG_TYPE_REQUEST,
|
||||||
|
MAX_MESSAGE_SIZE + 1, 0)
|
||||||
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
|
BinaryProtocol.decode_header(header)
|
||||||
|
assert "too large" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_decode_header_too_short(self):
|
||||||
|
"""Test header decoding with too-short data."""
|
||||||
|
header = MAGIC + b"\x01"
|
||||||
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
|
BinaryProtocol.decode_header(header)
|
||||||
|
assert "short" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBinaryProtocolEncodeDecode:
|
||||||
|
"""Tests for message encoding/decoding."""
|
||||||
|
|
||||||
|
def test_encode_decode_request(self):
|
||||||
|
"""Test request encoding/decoding roundtrip."""
|
||||||
|
encoded = BinaryProtocol.encode_request(
|
||||||
|
request_id=12345,
|
||||||
|
app_path="myapp.ml:MLApp",
|
||||||
|
action="predict",
|
||||||
|
args=("data",),
|
||||||
|
kwargs={"temperature": 0.7}
|
||||||
|
)
|
||||||
|
assert len(encoded) > HEADER_SIZE
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert msg_type_str == "request"
|
||||||
|
assert request_id == 12345
|
||||||
|
assert payload["app_path"] == "myapp.ml:MLApp"
|
||||||
|
assert payload["action"] == "predict"
|
||||||
|
assert payload["args"] == ["data"]
|
||||||
|
assert payload["kwargs"] == {"temperature": 0.7}
|
||||||
|
|
||||||
|
def test_encode_decode_response(self):
|
||||||
|
"""Test response encoding/decoding roundtrip."""
|
||||||
|
result = {"predictions": [0.1, 0.9], "metadata": {"model": "v1"}}
|
||||||
|
encoded = BinaryProtocol.encode_response(request_id=67890, result=result)
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert msg_type_str == "response"
|
||||||
|
assert request_id == 67890
|
||||||
|
assert payload["result"] == result
|
||||||
|
|
||||||
|
def test_encode_decode_error(self):
|
||||||
|
"""Test error encoding/decoding roundtrip."""
|
||||||
|
error = DirtyTimeoutError("Timed out", timeout=30)
|
||||||
|
encoded = BinaryProtocol.encode_error(request_id=11111, error=error)
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert msg_type_str == "error"
|
||||||
|
assert request_id == 11111
|
||||||
|
assert payload["error"]["error_type"] == "DirtyTimeoutError"
|
||||||
|
assert "Timed out" in payload["error"]["message"]
|
||||||
|
|
||||||
|
def test_encode_decode_chunk(self):
|
||||||
|
"""Test chunk encoding/decoding roundtrip."""
|
||||||
|
chunk_data = {"token": "hello", "index": 5}
|
||||||
|
encoded = BinaryProtocol.encode_chunk(request_id=22222, data=chunk_data)
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert msg_type_str == "chunk"
|
||||||
|
assert request_id == 22222
|
||||||
|
assert payload["data"] == chunk_data
|
||||||
|
|
||||||
|
def test_encode_decode_end(self):
|
||||||
|
"""Test end message encoding/decoding roundtrip."""
|
||||||
|
encoded = BinaryProtocol.encode_end(request_id=33333)
|
||||||
|
assert len(encoded) == HEADER_SIZE # End has no payload
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert msg_type_str == "end"
|
||||||
|
assert request_id == 33333
|
||||||
|
assert payload == {}
|
||||||
|
|
||||||
|
def test_encode_decode_binary_data(self):
|
||||||
|
"""Test binary data passes through without base64 encoding."""
|
||||||
|
binary_data = bytes(range(256))
|
||||||
|
encoded = BinaryProtocol.encode_response(
|
||||||
|
request_id=44444,
|
||||||
|
result={"data": binary_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
|
assert payload["result"]["data"] == binary_data
|
||||||
|
|
||||||
|
def test_encode_decode_large_message(self):
|
||||||
"""Test encoding a large message."""
|
"""Test encoding a large message."""
|
||||||
large_data = "x" * (1024 * 1024) # 1 MB of data
|
large_data = b"x" * (1024 * 1024) # 1 MB
|
||||||
message = {"type": "request", "data": large_data}
|
encoded = BinaryProtocol.encode_response(
|
||||||
encoded = DirtyProtocol.encode(message)
|
request_id=55555,
|
||||||
payload = encoded[DirtyProtocol.HEADER_SIZE:]
|
result={"data": large_data}
|
||||||
decoded = DirtyProtocol.decode(payload)
|
)
|
||||||
assert decoded == message
|
|
||||||
|
|
||||||
def test_encode_empty_dict(self):
|
msg_type_str, request_id, payload = BinaryProtocol.decode_message(encoded)
|
||||||
"""Test encoding an empty dictionary."""
|
assert payload["result"]["data"] == large_data
|
||||||
message = {}
|
|
||||||
encoded = DirtyProtocol.encode(message)
|
|
||||||
payload = encoded[DirtyProtocol.HEADER_SIZE:]
|
|
||||||
decoded = DirtyProtocol.decode(payload)
|
|
||||||
assert decoded == message
|
|
||||||
|
|
||||||
def test_encode_message_too_large(self):
|
|
||||||
"""Test that encoding a message that's too large raises error."""
|
|
||||||
large_data = "x" * (DirtyProtocol.MAX_MESSAGE_SIZE + 1000)
|
|
||||||
message = {"data": large_data}
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
|
||||||
DirtyProtocol.encode(message)
|
|
||||||
assert "too large" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_encode_non_serializable(self):
|
|
||||||
"""Test that encoding non-JSON-serializable data raises error."""
|
|
||||||
message = {"func": lambda x: x}
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
|
||||||
DirtyProtocol.encode(message)
|
|
||||||
assert "Failed to encode" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_decode_invalid_json(self):
|
|
||||||
"""Test decoding invalid JSON raises error."""
|
|
||||||
invalid_data = b"not valid json"
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
|
||||||
DirtyProtocol.decode(invalid_data)
|
|
||||||
assert "Failed to decode" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_decode_invalid_unicode(self):
|
|
||||||
"""Test decoding invalid unicode raises error."""
|
|
||||||
invalid_data = b"\x80\x81\x82"
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
|
||||||
DirtyProtocol.decode(invalid_data)
|
|
||||||
assert "Failed to decode" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDirtyProtocolSync:
|
class TestBinaryProtocolSync:
|
||||||
"""Tests for synchronous socket operations."""
|
"""Tests for synchronous socket operations."""
|
||||||
|
|
||||||
def test_read_write_message(self):
|
def test_read_write_message(self):
|
||||||
"""Test read/write through socket pair."""
|
"""Test read/write through socket pair."""
|
||||||
# Create a socket pair for testing
|
|
||||||
server_sock, client_sock = socket.socketpair()
|
server_sock, client_sock = socket.socketpair()
|
||||||
try:
|
try:
|
||||||
message = {"type": "request", "id": "123", "action": "test"}
|
message = make_request(
|
||||||
|
request_id=12345,
|
||||||
|
app_path="test:App",
|
||||||
|
action="run"
|
||||||
|
)
|
||||||
|
|
||||||
# Write message
|
BinaryProtocol.write_message(client_sock, message)
|
||||||
DirtyProtocol.write_message(client_sock, message)
|
received = BinaryProtocol.read_message(server_sock)
|
||||||
|
|
||||||
# Read message
|
assert received["type"] == "request"
|
||||||
received = DirtyProtocol.read_message(server_sock)
|
assert received["id"] == hash("12345") & 0xFFFFFFFFFFFFFFFF or \
|
||||||
assert received == message
|
received["id"] == 12345
|
||||||
|
assert received["app_path"] == "test:App"
|
||||||
|
assert received["action"] == "run"
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
client_sock.close()
|
||||||
|
|
||||||
|
def test_read_write_with_int_id(self):
|
||||||
|
"""Test read/write with integer request ID."""
|
||||||
|
server_sock, client_sock = socket.socketpair()
|
||||||
|
try:
|
||||||
|
message = {
|
||||||
|
"type": "request",
|
||||||
|
"id": 999888777,
|
||||||
|
"app_path": "test:App",
|
||||||
|
"action": "run",
|
||||||
|
"args": [],
|
||||||
|
"kwargs": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
BinaryProtocol.write_message(client_sock, message)
|
||||||
|
received = BinaryProtocol.read_message(server_sock)
|
||||||
|
|
||||||
|
assert received["id"] == 999888777
|
||||||
finally:
|
finally:
|
||||||
server_sock.close()
|
server_sock.close()
|
||||||
client_sock.close()
|
client_sock.close()
|
||||||
@ -147,19 +234,17 @@ class TestDirtyProtocolSync:
|
|||||||
server_sock, client_sock = socket.socketpair()
|
server_sock, client_sock = socket.socketpair()
|
||||||
try:
|
try:
|
||||||
messages = [
|
messages = [
|
||||||
{"type": "request", "id": "1"},
|
make_request(i, f"app{i}:App", f"action{i}")
|
||||||
{"type": "request", "id": "2"},
|
for i in range(1, 4)
|
||||||
{"type": "request", "id": "3"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Write all messages
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
DirtyProtocol.write_message(client_sock, msg)
|
BinaryProtocol.write_message(client_sock, msg)
|
||||||
|
|
||||||
# Read all messages
|
for i, _ in enumerate(messages, 1):
|
||||||
for expected in messages:
|
received = BinaryProtocol.read_message(server_sock)
|
||||||
received = DirtyProtocol.read_message(server_sock)
|
assert received["app_path"] == f"app{i}:App"
|
||||||
assert received == expected
|
assert received["action"] == f"action{i}"
|
||||||
finally:
|
finally:
|
||||||
server_sock.close()
|
server_sock.close()
|
||||||
client_sock.close()
|
client_sock.close()
|
||||||
@ -169,39 +254,51 @@ class TestDirtyProtocolSync:
|
|||||||
server_sock, client_sock = socket.socketpair()
|
server_sock, client_sock = socket.socketpair()
|
||||||
client_sock.close()
|
client_sock.close()
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
DirtyProtocol.read_message(server_sock)
|
BinaryProtocol.read_message(server_sock)
|
||||||
assert "closed" in str(exc_info.value).lower()
|
assert "closed" in str(exc_info.value).lower()
|
||||||
server_sock.close()
|
server_sock.close()
|
||||||
|
|
||||||
|
def test_binary_data_roundtrip(self):
|
||||||
|
"""Test binary data roundtrip through socket."""
|
||||||
|
server_sock, client_sock = socket.socketpair()
|
||||||
|
try:
|
||||||
|
binary_payload = b"\x00\x01\x02\xff\xfe\xfd"
|
||||||
|
message = make_response(12345, {"binary": binary_payload})
|
||||||
|
|
||||||
class TestDirtyProtocolAsync:
|
BinaryProtocol.write_message(client_sock, message)
|
||||||
|
received = BinaryProtocol.read_message(server_sock)
|
||||||
|
|
||||||
|
assert received["result"]["binary"] == binary_payload
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
client_sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBinaryProtocolAsync:
|
||||||
"""Tests for async stream operations."""
|
"""Tests for async stream operations."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_read_write(self):
|
async def test_async_read_write(self):
|
||||||
"""Test async read/write with mock streams."""
|
"""Test async read/write with mock streams."""
|
||||||
message = {"type": "request", "id": "123"}
|
message = make_request(12345, "test:App", "run")
|
||||||
|
|
||||||
# Create a pipe for testing
|
|
||||||
read_fd, write_fd = os.pipe()
|
read_fd, write_fd = os.pipe()
|
||||||
try:
|
try:
|
||||||
reader = asyncio.StreamReader()
|
reader = asyncio.StreamReader()
|
||||||
_ = asyncio.StreamReaderProtocol(reader)
|
_ = asyncio.StreamReaderProtocol(reader)
|
||||||
|
|
||||||
# Write the message to the pipe
|
encoded = BinaryProtocol._encode_from_dict(message)
|
||||||
encoded = DirtyProtocol.encode(message)
|
|
||||||
os.write(write_fd, encoded)
|
os.write(write_fd, encoded)
|
||||||
os.close(write_fd)
|
os.close(write_fd)
|
||||||
write_fd = None
|
write_fd = None
|
||||||
|
|
||||||
# Feed data to reader
|
|
||||||
data = os.read(read_fd, len(encoded))
|
data = os.read(read_fd, len(encoded))
|
||||||
reader.feed_data(data)
|
reader.feed_data(data)
|
||||||
reader.feed_eof()
|
reader.feed_eof()
|
||||||
|
|
||||||
# Read the message
|
received = await BinaryProtocol.read_message_async(reader)
|
||||||
received = await DirtyProtocol.read_message_async(reader)
|
assert received["type"] == "request"
|
||||||
assert received == message
|
assert received["app_path"] == "test:App"
|
||||||
finally:
|
finally:
|
||||||
if write_fd is not None:
|
if write_fd is not None:
|
||||||
os.close(write_fd)
|
os.close(write_fd)
|
||||||
@ -211,12 +308,11 @@ class TestDirtyProtocolAsync:
|
|||||||
async def test_async_read_incomplete_header(self):
|
async def test_async_read_incomplete_header(self):
|
||||||
"""Test async read with incomplete header."""
|
"""Test async read with incomplete header."""
|
||||||
reader = asyncio.StreamReader()
|
reader = asyncio.StreamReader()
|
||||||
# Feed only 2 bytes instead of 4
|
reader.feed_data(MAGIC + b"\x01") # Only 3 bytes
|
||||||
reader.feed_data(b"\x00\x00")
|
|
||||||
reader.feed_eof()
|
reader.feed_eof()
|
||||||
|
|
||||||
with pytest.raises((asyncio.IncompleteReadError, DirtyProtocolError)):
|
with pytest.raises((asyncio.IncompleteReadError, DirtyProtocolError)):
|
||||||
await DirtyProtocol.read_message_async(reader)
|
await BinaryProtocol.read_message_async(reader)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_read_empty_connection(self):
|
async def test_async_read_empty_connection(self):
|
||||||
@ -225,36 +321,33 @@ class TestDirtyProtocolAsync:
|
|||||||
reader.feed_eof()
|
reader.feed_eof()
|
||||||
|
|
||||||
with pytest.raises(asyncio.IncompleteReadError):
|
with pytest.raises(asyncio.IncompleteReadError):
|
||||||
await DirtyProtocol.read_message_async(reader)
|
await BinaryProtocol.read_message_async(reader)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_read_invalid_magic(self):
|
||||||
|
"""Test async read rejects invalid magic."""
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
header = b"XX" + bytes([VERSION, MSG_TYPE_REQUEST]) + b"\x00" * 12
|
||||||
|
reader.feed_data(header)
|
||||||
|
reader.feed_eof()
|
||||||
|
|
||||||
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
|
await BinaryProtocol.read_message_async(reader)
|
||||||
|
assert "magic" in str(exc_info.value).lower()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_read_message_too_large(self):
|
async def test_async_read_message_too_large(self):
|
||||||
"""Test async read rejects too-large messages."""
|
"""Test async read rejects too-large messages."""
|
||||||
reader = asyncio.StreamReader()
|
reader = asyncio.StreamReader()
|
||||||
# Create a header claiming an absurdly large message
|
header = struct.pack(HEADER_FORMAT, MAGIC, VERSION, MSG_TYPE_REQUEST,
|
||||||
header = struct.pack(
|
MAX_MESSAGE_SIZE + 1000, 0)
|
||||||
DirtyProtocol.HEADER_FORMAT,
|
|
||||||
DirtyProtocol.MAX_MESSAGE_SIZE + 1000
|
|
||||||
)
|
|
||||||
reader.feed_data(header)
|
reader.feed_data(header)
|
||||||
reader.feed_eof()
|
reader.feed_eof()
|
||||||
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
with pytest.raises(DirtyProtocolError) as exc_info:
|
||||||
await DirtyProtocol.read_message_async(reader)
|
await BinaryProtocol.read_message_async(reader)
|
||||||
assert "too large" in str(exc_info.value)
|
assert "too large" in str(exc_info.value)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_async_read_empty_message(self):
|
|
||||||
"""Test async read rejects empty messages."""
|
|
||||||
reader = asyncio.StreamReader()
|
|
||||||
header = struct.pack(DirtyProtocol.HEADER_FORMAT, 0)
|
|
||||||
reader.feed_data(header)
|
|
||||||
reader.feed_eof()
|
|
||||||
|
|
||||||
with pytest.raises(DirtyProtocolError) as exc_info:
|
|
||||||
await DirtyProtocol.read_message_async(reader)
|
|
||||||
assert "Empty message" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMessageBuilders:
|
class TestMessageBuilders:
|
||||||
"""Tests for message builder helper functions."""
|
"""Tests for message builder helper functions."""
|
||||||
@ -340,9 +433,9 @@ class TestMessageBuilders:
|
|||||||
assert chunk["id"] == "req-456"
|
assert chunk["id"] == "req-456"
|
||||||
assert chunk["data"] == data
|
assert chunk["data"] == data
|
||||||
|
|
||||||
def test_make_chunk_message_with_list_data(self):
|
def test_make_chunk_message_with_binary_data(self):
|
||||||
"""Test chunk message with list data."""
|
"""Test chunk message with binary data."""
|
||||||
data = [1, 2, 3, "token"]
|
data = b"\x00\x01\x02\xff"
|
||||||
chunk = make_chunk_message("req-789", data)
|
chunk = make_chunk_message("req-789", data)
|
||||||
assert chunk["data"] == data
|
assert chunk["data"] == data
|
||||||
|
|
||||||
@ -353,22 +446,22 @@ class TestMessageBuilders:
|
|||||||
assert end["id"] == "req-123"
|
assert end["id"] == "req-123"
|
||||||
assert "data" not in end
|
assert "data" not in end
|
||||||
|
|
||||||
def test_chunk_and_end_encode_decode(self):
|
def test_chunk_and_end_roundtrip(self):
|
||||||
"""Test that chunk and end messages can be encoded/decoded."""
|
"""Test that chunk and end messages can be encoded/decoded."""
|
||||||
chunk = make_chunk_message("req-123", {"token": "hello"})
|
chunk = make_chunk_message(12345, {"token": "hello"})
|
||||||
end = make_end_message("req-123")
|
end = make_end_message(12345)
|
||||||
|
|
||||||
# Test chunk roundtrip
|
# Test chunk roundtrip
|
||||||
encoded_chunk = DirtyProtocol.encode(chunk)
|
encoded_chunk = BinaryProtocol._encode_from_dict(chunk)
|
||||||
payload = encoded_chunk[DirtyProtocol.HEADER_SIZE:]
|
msg_type, req_id, payload = BinaryProtocol.decode_message(encoded_chunk)
|
||||||
decoded = DirtyProtocol.decode(payload)
|
assert msg_type == "chunk"
|
||||||
assert decoded == chunk
|
assert payload["data"] == {"token": "hello"}
|
||||||
|
|
||||||
# Test end roundtrip
|
# Test end roundtrip
|
||||||
encoded_end = DirtyProtocol.encode(end)
|
encoded_end = BinaryProtocol._encode_from_dict(end)
|
||||||
payload = encoded_end[DirtyProtocol.HEADER_SIZE:]
|
msg_type, req_id, payload = BinaryProtocol.decode_message(encoded_end)
|
||||||
decoded = DirtyProtocol.decode(payload)
|
assert msg_type == "end"
|
||||||
assert decoded == end
|
assert payload == {}
|
||||||
|
|
||||||
|
|
||||||
class TestDirtyErrors:
|
class TestDirtyErrors:
|
||||||
@ -417,3 +510,58 @@ class TestDirtyErrors:
|
|||||||
assert error.action == "run"
|
assert error.action == "run"
|
||||||
assert error.traceback == "Traceback..."
|
assert error.traceback == "Traceback..."
|
||||||
assert "myapp:App" in str(error)
|
assert "myapp:App" in str(error)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardsCompatibility:
|
||||||
|
"""Tests for backwards compatibility with old JSON API."""
|
||||||
|
|
||||||
|
def test_dirty_protocol_alias(self):
|
||||||
|
"""Test that DirtyProtocol is an alias for BinaryProtocol."""
|
||||||
|
assert DirtyProtocol is BinaryProtocol
|
||||||
|
|
||||||
|
def test_header_size_attribute(self):
|
||||||
|
"""Test HEADER_SIZE is accessible on class."""
|
||||||
|
assert DirtyProtocol.HEADER_SIZE == 16
|
||||||
|
|
||||||
|
def test_msg_type_constants(self):
|
||||||
|
"""Test message type constants are strings for compatibility."""
|
||||||
|
assert DirtyProtocol.MSG_TYPE_REQUEST == "request"
|
||||||
|
assert DirtyProtocol.MSG_TYPE_RESPONSE == "response"
|
||||||
|
assert DirtyProtocol.MSG_TYPE_ERROR == "error"
|
||||||
|
assert DirtyProtocol.MSG_TYPE_CHUNK == "chunk"
|
||||||
|
assert DirtyProtocol.MSG_TYPE_END == "end"
|
||||||
|
|
||||||
|
def test_encode_decode_preserves_dict_format(self):
|
||||||
|
"""Test that read_message returns dict compatible with old API."""
|
||||||
|
server_sock, client_sock = socket.socketpair()
|
||||||
|
try:
|
||||||
|
message = {
|
||||||
|
"type": "response",
|
||||||
|
"id": 12345,
|
||||||
|
"result": {"status": "ok"}
|
||||||
|
}
|
||||||
|
|
||||||
|
DirtyProtocol.write_message(client_sock, message)
|
||||||
|
received = DirtyProtocol.read_message(server_sock)
|
||||||
|
|
||||||
|
# Old API: access via dict keys
|
||||||
|
assert received["type"] == "response"
|
||||||
|
assert received["result"]["status"] == "ok"
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
client_sock.close()
|
||||||
|
|
||||||
|
def test_string_request_id_handled(self):
|
||||||
|
"""Test that string request IDs are handled (hashed to int)."""
|
||||||
|
server_sock, client_sock = socket.socketpair()
|
||||||
|
try:
|
||||||
|
message = make_request("uuid-string-id", "test:App", "run")
|
||||||
|
|
||||||
|
DirtyProtocol.write_message(client_sock, message)
|
||||||
|
received = DirtyProtocol.read_message(server_sock)
|
||||||
|
|
||||||
|
# Request ID should be converted to int
|
||||||
|
assert isinstance(received["id"], int)
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
client_sock.close()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user