190 lines
5.7 KiB
Python
190 lines
5.7 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import sys
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from cmvr_edge_ai.workers import (
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
FramedSubprocessWorker,
|
||
|
|
FramedWorkerProtocolError,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
_ECHO_WORKER = r"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import struct
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
length = struct.Struct("!I")
|
||
|
|
|
||
|
|
def read_frame():
|
||
|
|
prefix = sys.stdin.buffer.read(4)
|
||
|
|
if not prefix:
|
||
|
|
return None, ()
|
||
|
|
size = length.unpack(prefix)[0]
|
||
|
|
header = json.loads(sys.stdin.buffer.read(size))
|
||
|
|
blobs = tuple(sys.stdin.buffer.read(item) for item in header.pop("blob_lengths"))
|
||
|
|
return header, blobs
|
||
|
|
|
||
|
|
def write_frame(header, blobs=()):
|
||
|
|
header = dict(header)
|
||
|
|
header["blob_lengths"] = [len(item) for item in blobs]
|
||
|
|
encoded = json.dumps(header, separators=(",", ":")).encode()
|
||
|
|
sys.stdout.buffer.write(length.pack(len(encoded)))
|
||
|
|
sys.stdout.buffer.write(encoded)
|
||
|
|
for blob in blobs:
|
||
|
|
sys.stdout.buffer.write(blob)
|
||
|
|
sys.stdout.buffer.flush()
|
||
|
|
|
||
|
|
write_frame({"protocol": "cmvr.external-worker/v1", "type": "ready", "pid": os.getpid()})
|
||
|
|
for header, blobs in iter(read_frame, (None, ())):
|
||
|
|
if header["type"] == "shutdown":
|
||
|
|
break
|
||
|
|
if header.get("operation") == "slow":
|
||
|
|
time.sleep(1)
|
||
|
|
write_frame(
|
||
|
|
{
|
||
|
|
"protocol": "cmvr.external-worker/v1",
|
||
|
|
"type": "response",
|
||
|
|
"request_id": header["request_id"],
|
||
|
|
"pid": os.getpid(),
|
||
|
|
},
|
||
|
|
tuple(blob[::-1] for blob in blobs),
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
_INVALID_READY_WORKER = r"""
|
||
|
|
import json
|
||
|
|
import struct
|
||
|
|
import sys
|
||
|
|
header = json.dumps({"protocol": "wrong", "type": "ready", "blob_lengths": []}).encode()
|
||
|
|
sys.stdout.buffer.write(struct.pack("!I", len(header)) + header)
|
||
|
|
sys.stdout.buffer.flush()
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def test_framed_worker_keeps_one_process_and_transfers_raw_blobs() -> None:
|
||
|
|
async def exercise() -> None:
|
||
|
|
worker = FramedSubprocessWorker(
|
||
|
|
(sys.executable, "-u", "-c", _ECHO_WORKER),
|
||
|
|
startup_timeout_s=2,
|
||
|
|
shutdown_timeout_s=1,
|
||
|
|
max_blob_bytes=1024,
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
ready = await worker.start()
|
||
|
|
first = await worker.submit(
|
||
|
|
{
|
||
|
|
"protocol": PROTOCOL_VERSION,
|
||
|
|
"type": "request",
|
||
|
|
"request_id": "one",
|
||
|
|
},
|
||
|
|
(b"image-data", b"artifact"),
|
||
|
|
timeout_s=2,
|
||
|
|
)
|
||
|
|
second = await worker.submit(
|
||
|
|
{
|
||
|
|
"protocol": PROTOCOL_VERSION,
|
||
|
|
"type": "request",
|
||
|
|
"request_id": "two",
|
||
|
|
},
|
||
|
|
(b"second",),
|
||
|
|
timeout_s=2,
|
||
|
|
)
|
||
|
|
assert first.header["request_id"] == "one"
|
||
|
|
assert first.header["pid"] == ready["pid"] == second.header["pid"]
|
||
|
|
assert first.blobs == (b"atad-egami", b"tcafitra")
|
||
|
|
assert second.blobs == (b"dnoces",)
|
||
|
|
assert worker.is_alive is True
|
||
|
|
finally:
|
||
|
|
await worker.stop()
|
||
|
|
await worker.stop()
|
||
|
|
assert worker.is_alive is False
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
|
||
|
|
def test_timeout_discards_uncorrelated_process_and_next_call_restarts() -> None:
|
||
|
|
async def exercise() -> None:
|
||
|
|
worker = FramedSubprocessWorker(
|
||
|
|
(sys.executable, "-u", "-c", _ECHO_WORKER),
|
||
|
|
startup_timeout_s=2,
|
||
|
|
shutdown_timeout_s=1,
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
original_pid = (await worker.start())["pid"]
|
||
|
|
with pytest.raises(asyncio.TimeoutError):
|
||
|
|
await worker.submit(
|
||
|
|
{
|
||
|
|
"protocol": PROTOCOL_VERSION,
|
||
|
|
"type": "request",
|
||
|
|
"request_id": "slow",
|
||
|
|
"operation": "slow",
|
||
|
|
},
|
||
|
|
timeout_s=0.05,
|
||
|
|
)
|
||
|
|
assert worker.is_alive is False
|
||
|
|
|
||
|
|
response = await worker.submit(
|
||
|
|
{
|
||
|
|
"protocol": PROTOCOL_VERSION,
|
||
|
|
"type": "request",
|
||
|
|
"request_id": "after-restart",
|
||
|
|
},
|
||
|
|
timeout_s=2,
|
||
|
|
)
|
||
|
|
assert response.header["pid"] != original_pid
|
||
|
|
assert response.header["request_id"] == "after-restart"
|
||
|
|
finally:
|
||
|
|
await worker.stop()
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
|
||
|
|
def test_invalid_ready_handshake_is_rejected_and_child_is_reaped() -> None:
|
||
|
|
async def exercise() -> None:
|
||
|
|
worker = FramedSubprocessWorker(
|
||
|
|
(sys.executable, "-u", "-c", _INVALID_READY_WORKER),
|
||
|
|
startup_timeout_s=2,
|
||
|
|
shutdown_timeout_s=1,
|
||
|
|
)
|
||
|
|
with pytest.raises(FramedWorkerProtocolError, match="protocol"):
|
||
|
|
await worker.start()
|
||
|
|
assert worker.is_alive is False
|
||
|
|
await worker.stop()
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|
||
|
|
|
||
|
|
|
||
|
|
def test_framed_worker_enforces_outbound_blob_limit() -> None:
|
||
|
|
async def exercise() -> None:
|
||
|
|
worker = FramedSubprocessWorker(
|
||
|
|
(sys.executable, "-u", "-c", _ECHO_WORKER),
|
||
|
|
startup_timeout_s=2,
|
||
|
|
shutdown_timeout_s=1,
|
||
|
|
max_blob_bytes=3,
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with pytest.raises(FramedWorkerProtocolError, match="outbound"):
|
||
|
|
await worker.submit(
|
||
|
|
{
|
||
|
|
"protocol": PROTOCOL_VERSION,
|
||
|
|
"type": "request",
|
||
|
|
"request_id": "too-large",
|
||
|
|
},
|
||
|
|
(b"four",),
|
||
|
|
timeout_s=2,
|
||
|
|
)
|
||
|
|
# A rejected frame never reached the child, but the conservative
|
||
|
|
# exchange boundary still resets correlation before reuse.
|
||
|
|
assert worker.is_alive is False
|
||
|
|
finally:
|
||
|
|
await worker.stop()
|
||
|
|
|
||
|
|
asyncio.run(exercise())
|