FakeSubSocket and FakePubSocket for IPC synchronization (#439)

* Implementation of FakeSubSocket and FakePubSocket using eventfd with support for one-in/one-out synchronization

* Expose FakeEvent to Python

* Add demo showcasing synchronization between processes

* Fix linter errors

* Expose more FakeEvent APIs in Python bindings

* Add FakePoller implementation

* Remove suffix from poll env vars

* Set poller timeout to zero when events are enabled

* Replace poll with ppoll. Add invalidation methods

* Fix lint issues

* Fix comment indent

* Remove fake_demo

* Remove FakePubSocket. Simpler FakePoller implementation. Ability to wait for multiple events

* Rename FakeEvent to Event and move it to event.cc

* Rename event purpose constants in py

* Add support for timeout in wait methods

* Add tests for events and fake sockets

* Fix lint errors

* Add zmq sleeps

* Temporarly disable TestFakeSockets on ZMQ

* Add exception type specifiers to test_fake

* Event Manager implementation

* Fix fake sockets tests

* Update EventManager API

* Add test for enable/disable

* Add tests for cereal prefix

* Remove EventPurpose from python bindings

* Fix lint issues

* event_state_shm_mmap implementation shared by EventManager and FakeSubSocket

* Rename EventManager to SocketEventHandle

* More renames
This commit is contained in:
Kacper Rączy 2023-05-23 19:38:52 +02:00 committed by GitHub
parent 0b5e05013c
commit 4e063ca166
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 701 additions and 7 deletions

View File

@ -26,8 +26,10 @@ services_h = env.Command(['services.h'], ['services.py'], 'python3 ' + cereal_di
messaging_objects = env.SharedObject([
'messaging/messaging.cc',
'messaging/event.cc',
'messaging/impl_zmq.cc',
'messaging/impl_msgq.cc',
'messaging/impl_fake.cc',
'messaging/msgq.cc',
'messaging/socketmaster.cc',
])

View File

@ -1,5 +1,5 @@
# must be build with scons
from .messaging_pyx import Context, Poller, SubSocket, PubSocket # pylint: disable=no-name-in-module, import-error
from .messaging_pyx import Context, Poller, SubSocket, PubSocket, SocketEventHandle, toggle_fake_events, set_fake_prefix, get_fake_prefix, delete_fake_prefix, wait_for_one_event # pylint: disable=no-name-in-module, import-error
from .messaging_pyx import MultiplePublishersError, MessagingError # pylint: disable=no-name-in-module, import-error
import os
import capnp
@ -12,6 +12,11 @@ from cereal.services import service_list
assert MultiplePublishersError
assert MessagingError
assert toggle_fake_events
assert set_fake_prefix
assert get_fake_prefix
assert delete_fake_prefix
assert wait_for_one_event
NO_TRAVERSAL_LIMIT = 2**64-1
AVG_FREQ_HISTORY = 100
@ -27,9 +32,20 @@ except ImportError:
context = Context()
def fake_event_handle(endpoint: str, identifier: Optional[str] = None, override: bool = True, enable: bool = False) -> SocketEventHandle:
identifier = identifier or get_fake_prefix()
handle = SocketEventHandle(endpoint, identifier, override)
if override:
handle.enabled = enable
return handle
def log_from_bytes(dat: bytes) -> capnp.lib.capnp._DynamicStructReader:
return log.Event.from_bytes(dat, traversal_limit_in_words=NO_TRAVERSAL_LIMIT)
def new_message(service: Optional[str] = None, size: Optional[int] = None) -> capnp.lib.capnp._DynamicStructBuilder:
dat = log.Event.new_message()
dat.logMonoTime = int(sec_since_boot() * 1e9)
@ -41,11 +57,13 @@ def new_message(service: Optional[str] = None, size: Optional[int] = None) -> ca
dat.init(service, size)
return dat
def pub_sock(endpoint: str) -> PubSocket:
sock = PubSocket()
sock.connect(context, endpoint)
return sock
def sub_sock(endpoint: str, poller: Optional[Poller] = None, addr: str = "127.0.0.1",
conflate: bool = False, timeout: Optional[int] = None) -> SubSocket:
sock = SubSocket()
@ -75,6 +93,7 @@ def drain_sock_raw(sock: SubSocket, wait_for_one: bool = False) -> List[bytes]:
return ret
def drain_sock(sock: SubSocket, wait_for_one: bool = False) -> List[capnp.lib.capnp._DynamicStructReader]:
"""Receive all message currently available on the queue"""
ret: List[capnp.lib.capnp._DynamicStructReader] = []
@ -114,18 +133,21 @@ def recv_sock(sock: SubSocket, wait: bool = False) -> Optional[capnp.lib.capnp._
return dat
def recv_one(sock: SubSocket) -> Optional[capnp.lib.capnp._DynamicStructReader]:
dat = sock.receive()
if dat is not None:
dat = log_from_bytes(dat)
return dat
def recv_one_or_none(sock: SubSocket) -> Optional[capnp.lib.capnp._DynamicStructReader]:
dat = sock.receive(non_blocking=True)
if dat is not None:
dat = log_from_bytes(dat)
return dat
def recv_one_retry(sock: SubSocket) -> capnp.lib.capnp._DynamicStructReader:
"""Keep receiving until we get a message"""
while True:
@ -133,6 +155,7 @@ def recv_one_retry(sock: SubSocket) -> capnp.lib.capnp._DynamicStructReader:
if dat is not None:
return log_from_bytes(dat)
class SubMaster:
def __init__(self, services: List[str], poll: Optional[List[str]] = None,
ignore_alive: Optional[List[str]] = None, ignore_avg_freq: Optional[List[str]] = None,
@ -247,6 +270,7 @@ class SubMaster:
and self.all_freq_ok(service_list=service_list) \
and self.all_valid(service_list=service_list)
class PubMaster:
def __init__(self, services: List[str]):
self.sock = {}

208
messaging/event.cc Normal file
View File

@ -0,0 +1,208 @@
#include <cassert>
#include <cstring>
#include <cstdlib>
#include <iostream>
#include <string>
#include <exception>
#include <filesystem>
#include <sys/eventfd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <poll.h>
#include <signal.h>
#include <fcntl.h>
#include "cereal/messaging/event.h"
void event_state_shm_mmap(std::string endpoint, std::string identifier, char **shm_mem, std::string *shm_path) {
const char* op_prefix = std::getenv("OPENPILOT_PREFIX");
std::string full_path = "/dev/shm/";
if (op_prefix) {
full_path += std::string(op_prefix) + "/";
}
full_path += CEREAL_EVENTS_PREFIX + "/";
if (identifier.size() > 0) {
full_path += identifier + "/";
}
std::filesystem::create_directories(full_path);
full_path += endpoint;
int shm_fd = open(full_path.c_str(), O_RDWR | O_CREAT, 0664);
if (shm_fd < 0) {
throw std::runtime_error("Could not open shared memory file.");
}
int rc = ftruncate(shm_fd, sizeof(EventState));
if (rc < 0){
close(shm_fd);
throw std::runtime_error("Could not truncate shared memory file.");
}
char * mem = (char*)mmap(NULL, sizeof(EventState), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
close(shm_fd);
if (mem == nullptr) {
throw std::runtime_error("Could not map shared memory file.");
}
if (shm_mem != nullptr)
*shm_mem = mem;
if (shm_path != nullptr)
*shm_path = full_path;
}
SocketEventHandle::SocketEventHandle(std::string endpoint, std::string identifier, bool override) {
char *mem;
event_state_shm_mmap(endpoint, identifier, &mem, &this->shm_path);
this->state = (EventState*)mem;
if (override) {
this->state->fds[0] = eventfd(0, EFD_NONBLOCK);
this->state->fds[1] = eventfd(0, EFD_NONBLOCK);
}
}
SocketEventHandle::~SocketEventHandle() {
close(this->state->fds[0]);
close(this->state->fds[1]);
munmap(this->state, sizeof(EventState));
unlink(this->shm_path.c_str());
}
bool SocketEventHandle::is_enabled() {
return this->state->enabled;
}
void SocketEventHandle::set_enabled(bool enabled) {
this->state->enabled = enabled;
}
Event SocketEventHandle::recv_called() {
return Event(this->state->fds[0]);
}
Event SocketEventHandle::recv_ready() {
return Event(this->state->fds[1]);
}
void SocketEventHandle::toggle_fake_events(bool enabled) {
if (enabled)
setenv("CEREAL_FAKE", "1", true);
else
unsetenv("CEREAL_FAKE");
}
void SocketEventHandle::set_fake_prefix(std::string prefix) {
if (prefix.size() == 0) {
unsetenv("CEREAL_FAKE_PREFIX");
} else {
setenv("CEREAL_FAKE_PREFIX", prefix.c_str(), true);
}
}
std::string SocketEventHandle::fake_prefix() {
const char* prefix = std::getenv("CEREAL_FAKE_PREFIX");
if (prefix == nullptr) {
return "";
} else {
return std::string(prefix);
}
}
Event::Event(int fd): event_fd(fd) {}
void Event::set() const {
throw_if_invalid();
uint64_t val = 1;
size_t count = write(this->event_fd, &val, sizeof(uint64_t));
assert(count == sizeof(uint64_t));
}
int Event::clear() const {
throw_if_invalid();
uint64_t val = 0;
// read the eventfd to clear it
read(this->event_fd, &val, sizeof(uint64_t));
return val;
}
void Event::wait(int timeout_sec) const {
throw_if_invalid();
int event_count;
struct pollfd fds = { this->event_fd, POLLIN, 0 };
struct timespec timeout = { timeout_sec, 0 };
sigset_t signals;
sigfillset(&signals);
sigdelset(&signals, SIGALRM);
sigdelset(&signals, SIGINT);
sigdelset(&signals, SIGTERM);
sigdelset(&signals, SIGQUIT);
event_count = ppoll(&fds, 1, timeout_sec < 0 ? nullptr : &timeout, &signals);
if (event_count == 0) {
throw std::runtime_error("Event timed out pid: " + std::to_string(getpid()));
} else if (event_count < 0) {
throw std::runtime_error("Event poll failed, errno: " + std::to_string(errno) + " pid: " + std::to_string(getpid()));
}
}
bool Event::peek() const {
throw_if_invalid();
int event_count;
struct pollfd fds = { this->event_fd, POLLIN, 0 };
// poll with timeout zero to return status immediately
event_count = poll(&fds, 1, 0);
return event_count != 0;
}
bool Event::is_valid() const {
return event_fd != -1;
}
int Event::fd() const {
return event_fd;
}
int Event::wait_for_one(const std::vector<Event>& events, int timeout_sec) {
struct pollfd fds[events.size()];
for (size_t i = 0; i < events.size(); i++) {
fds[i] = { events[i].fd(), POLLIN, 0 };
}
struct timespec timeout = { timeout_sec, 0 };
sigset_t signals;
sigfillset(&signals);
sigdelset(&signals, SIGALRM);
sigdelset(&signals, SIGINT);
sigdelset(&signals, SIGTERM);
sigdelset(&signals, SIGQUIT);
int event_count = ppoll(fds, events.size(), timeout_sec < 0 ? nullptr : &timeout, &signals);
if (event_count == 0) {
throw std::runtime_error("Event timed out pid: " + std::to_string(getpid()));
} else if (event_count < 0) {
throw std::runtime_error("Event poll failed, errno: " + std::to_string(errno) + " pid: " + std::to_string(getpid()));
}
for (size_t i = 0; i < events.size(); i++) {
if (fds[i].revents & POLLIN) {
return i;
}
}
throw std::runtime_error("Event poll failed, no events ready");
}

58
messaging/event.h Normal file
View File

@ -0,0 +1,58 @@
#pragma once
#include <string>
#include <vector>
#define CEREAL_EVENTS_PREFIX std::string("cereal_events")
void event_state_shm_mmap(std::string endpoint, std::string identifier, char **shm_mem, std::string *shm_path);
enum EventPurpose {
RECV_CALLED,
RECV_READY
};
struct EventState {
int fds[2];
bool enabled;
};
class Event {
private:
int event_fd = -1;
inline void throw_if_invalid() const {
if (!this->is_valid()) {
throw std::runtime_error("Event does not have valid file descriptor.");
}
}
public:
Event(int fd = -1);
void set() const;
int clear() const;
void wait(int timeout_sec = -1) const;
bool peek() const;
bool is_valid() const;
int fd() const;
static int wait_for_one(const std::vector<Event>& events, int timeout_sec = -1);
};
class SocketEventHandle {
private:
std::string shm_path;
EventState* state;
public:
SocketEventHandle(std::string endpoint, std::string identifier = "", bool override = true);
~SocketEventHandle();
bool is_enabled();
void set_enabled(bool enabled);
Event recv_called();
Event recv_ready();
static void toggle_fake_events(bool enabled);
static void set_fake_prefix(std::string prefix);
static std::string fake_prefix();
};

9
messaging/impl_fake.cc Normal file
View File

@ -0,0 +1,9 @@
#include "cereal/messaging/impl_fake.h"
void FakePoller::registerSocket(SubSocket *socket) {
this->sockets.push_back(socket);
}
std::vector<SubSocket*> FakePoller::poll(int timeout) {
return this->sockets;
}

67
messaging/impl_fake.h Normal file
View File

@ -0,0 +1,67 @@
#pragma once
#include <cassert>
#include <iostream>
#include <string>
#include <vector>
#include <filesystem>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "cereal/messaging/messaging.h"
#include "cereal/messaging/event.h"
template<typename TSubSocket>
class FakeSubSocket: public TSubSocket {
private:
Event *recv_called = nullptr;
Event *recv_ready = nullptr;
EventState *state = nullptr;
public:
FakeSubSocket(): TSubSocket() {}
~FakeSubSocket() {
delete recv_called;
delete recv_ready;
if (state != nullptr) {
munmap(state, sizeof(EventState));
}
}
int connect(Context *context, std::string endpoint, std::string address, bool conflate=false, bool check_endpoint=true) override {
const char* cereal_prefix = std::getenv("CEREAL_FAKE_PREFIX");
char* mem;
std::string identifier = cereal_prefix != nullptr ? std::string(cereal_prefix) : "";
event_state_shm_mmap(endpoint, identifier, &mem, nullptr);
this->state = (EventState*)mem;
this->recv_called = new Event(state->fds[EventPurpose::RECV_CALLED]);
this->recv_ready = new Event(state->fds[EventPurpose::RECV_READY]);
return TSubSocket::connect(context, endpoint, address, conflate, check_endpoint);
}
Message *receive(bool non_blocking=false) override {
if (this->state->enabled) {
this->recv_called->set();
this->recv_ready->wait();
this->recv_ready->clear();
}
return TSubSocket::receive(non_blocking);
}
};
class FakePoller: public Poller {
private:
std::vector<SubSocket*> sockets;
public:
void registerSocket(SubSocket *socket) override;
std::vector<SubSocket*> poll(int timeout) override;
~FakePoller() {};
};

View File

@ -4,6 +4,7 @@
#include "cereal/messaging/messaging.h"
#include "cereal/messaging/impl_zmq.h"
#include "cereal/messaging/impl_msgq.h"
#include "cereal/messaging/impl_fake.h"
#ifdef __APPLE__
const bool MUST_USE_ZMQ = true;
@ -22,6 +23,11 @@ bool messaging_use_zmq(){
return false;
}
bool messaging_use_fake(){
char* fake_enabled = std::getenv("CEREAL_FAKE");
return fake_enabled != NULL;
}
Context * Context::create(){
Context * c;
if (messaging_use_zmq()){
@ -34,11 +40,20 @@ Context * Context::create(){
SubSocket * SubSocket::create(){
SubSocket * s;
if (messaging_use_zmq()){
s = new ZMQSubSocket();
if (messaging_use_fake()) {
if (messaging_use_zmq()) {
s = new FakeSubSocket<ZMQSubSocket>();
} else {
s = new FakeSubSocket<MSGQSubSocket>();
}
} else {
s = new MSGQSubSocket();
if (messaging_use_zmq()){
s = new ZMQSubSocket();
} else {
s = new MSGQSubSocket();
}
}
return s;
}
@ -61,6 +76,7 @@ PubSocket * PubSocket::create(){
} else {
s = new MSGQPubSocket();
}
return s;
}
@ -78,10 +94,14 @@ PubSocket * PubSocket::create(Context * context, std::string endpoint, bool chec
Poller * Poller::create(){
Poller * p;
if (messaging_use_zmq()){
p = new ZMQPoller();
if (messaging_use_fake()) {
p = new FakePoller();
} else {
p = new MSGQPoller();
if (messaging_use_zmq()){
p = new ZMQPoller();
} else {
p = new MSGQPoller();
}
}
return p;
}

View File

@ -6,6 +6,34 @@ from libcpp.vector cimport vector
from libcpp cimport bool
cdef extern from "cereal/messaging/impl_fake.h":
cdef cppclass Event:
@staticmethod
int wait_for_one(vector[Event], int) except +
Event()
Event(int)
void set()
int clear()
void wait(int) except +
bool peek()
int fd()
cdef cppclass SocketEventHandle:
@staticmethod
void toggle_fake_events(bool)
@staticmethod
void set_fake_prefix(string)
@staticmethod
string fake_prefix()
SocketEventHandle(string, string, bool)
bool is_enabled()
void set_enabled(bool)
Event recv_called()
Event recv_ready()
cdef extern from "cereal/messaging/messaging.h":
cdef cppclass Context:
@staticmethod

View File

@ -3,8 +3,10 @@
import sys
from libcpp.string cimport string
from libcpp.vector cimport vector
from libcpp cimport bool
from libc cimport errno
from cython.operator import dereference
from .messaging cimport Context as cppContext
@ -12,6 +14,7 @@ from .messaging cimport SubSocket as cppSubSocket
from .messaging cimport PubSocket as cppPubSocket
from .messaging cimport Poller as cppPoller
from .messaging cimport Message as cppMessage
from .messaging cimport Event as cppEvent, SocketEventHandle as cppSocketEventHandle
class MessagingError(Exception):
@ -22,6 +25,91 @@ class MultiplePublishersError(MessagingError):
pass
def toggle_fake_events(bool enabled):
cppSocketEventHandle.toggle_fake_events(enabled)
def set_fake_prefix(string prefix):
cppSocketEventHandle.set_fake_prefix(prefix)
def get_fake_prefix():
return cppSocketEventHandle.fake_prefix()
def delete_fake_prefix():
cppSocketEventHandle.set_fake_prefix(b"")
def wait_for_one_event(list events, int timeout=-1):
cdef vector[cppEvent] items
for event in events:
items.push_back(dereference(<cppEvent*><size_t>event.ptr))
return cppEvent.wait_for_one(items, timeout)
cdef class Event:
cdef cppEvent event;
def __cinit__(self):
pass
cdef setEvent(self, cppEvent event):
self.event = event
def set(self):
self.event.set()
def clear(self):
return self.event.clear()
def wait(self, int timeout=-1):
self.event.wait(timeout)
def peek(self):
return self.event.peek()
@property
def fd(self):
return self.event.fd()
@property
def ptr(self):
return <size_t><void*>&self.event
cdef class SocketEventHandle:
cdef cppSocketEventHandle * handle;
def __cinit__(self, string endpoint, string identifier, bool override):
self.handle = new cppSocketEventHandle(endpoint, identifier, override)
def __dealloc__(self):
del self.handle
@property
def enabled(self):
return self.handle.is_enabled()
@enabled.setter
def enabled(self, bool value):
self.handle.set_enabled(value)
@property
def recv_called_event(self):
e = Event()
e.setEvent(self.handle.recv_called())
return e
@property
def recv_ready_event(self):
e = Event()
e.setEvent(self.handle.recv_ready())
return e
cdef class Context:
cdef cppContext * context
@ -68,6 +156,7 @@ cdef class Poller:
return sockets
cdef class SubSocket:
cdef cppSubSocket * socket
cdef bool is_owner

View File

@ -0,0 +1,189 @@
import os
import unittest
import multiprocessing
from parameterized import parameterized_class
import cereal.messaging as messaging
WAIT_TIMEOUT = 5
class TestEvents(unittest.TestCase):
def test_mutation(self):
handle = messaging.fake_event_handle("carState")
event = handle.recv_called_event
self.assertFalse(event.peek())
event.set()
self.assertTrue(event.peek())
event.clear()
self.assertFalse(event.peek())
del event
def test_wait(self):
handle = messaging.fake_event_handle("carState")
event = handle.recv_called_event
event.set()
try:
event.wait(WAIT_TIMEOUT)
self.assertTrue(event.peek())
except RuntimeError:
self.fail("event.wait() timed out")
def test_wait_multiprocess(self):
handle = messaging.fake_event_handle("carState")
event = handle.recv_called_event
def set_event_run():
event.set()
try:
p = multiprocessing.Process(target=set_event_run)
p.start()
event.wait(WAIT_TIMEOUT)
self.assertTrue(event.peek())
except RuntimeError:
self.fail("event.wait() timed out")
p.kill()
def test_wait_zero_timeout(self):
handle = messaging.fake_event_handle("carState")
event = handle.recv_called_event
try:
event.wait(0)
self.fail("event.wait() did not time out")
except RuntimeError:
self.assertFalse(event.peek())
@unittest.skipIf("ZMQ" in os.environ, "FakeSockets not supported on ZMQ")
@parameterized_class([{"prefix": None}, {"prefix": "test"}])
class TestFakeSockets(unittest.TestCase):
prefix = None
def setUp(self):
messaging.toggle_fake_events(True)
if self.prefix is not None:
messaging.set_fake_prefix(self.prefix)
else:
messaging.delete_fake_prefix()
def tearDown(self):
messaging.toggle_fake_events(False)
messaging.delete_fake_prefix()
def test_event_handle_init(self):
handle = messaging.fake_event_handle("controlsState", override=True)
self.assertFalse(handle.enabled)
self.assertGreaterEqual(handle.recv_called_event.fd, 0)
self.assertGreaterEqual(handle.recv_ready_event.fd, 0)
def test_non_managed_socket_state(self):
# non managed socket should have zero state
_ = messaging.pub_sock("ubloxGnss")
handle = messaging.fake_event_handle("ubloxGnss", override=False)
self.assertFalse(handle.enabled)
self.assertEqual(handle.recv_called_event.fd, 0)
self.assertEqual(handle.recv_ready_event.fd, 0)
def test_managed_socket_state(self):
# managed socket should not change anything about the state
handle = messaging.fake_event_handle("ubloxGnss")
handle.enabled = True
expected_enabled = handle.enabled
expected_recv_called_fd = handle.recv_called_event.fd
expected_recv_ready_fd = handle.recv_ready_event.fd
_ = messaging.pub_sock("ubloxGnss")
self.assertEqual(handle.enabled, expected_enabled)
self.assertEqual(handle.recv_called_event.fd, expected_recv_called_fd)
self.assertEqual(handle.recv_ready_event.fd, expected_recv_ready_fd)
def test_sockets_enable_disable(self):
carState_handle = messaging.fake_event_handle("ubloxGnss", enable=True)
recv_called = carState_handle.recv_called_event
recv_ready = carState_handle.recv_ready_event
pub_sock = messaging.pub_sock("ubloxGnss")
sub_sock = messaging.sub_sock("ubloxGnss")
try:
carState_handle.enabled = True
recv_ready.set()
pub_sock.send(b"test")
_ = sub_sock.receive()
self.assertTrue(recv_called.peek())
recv_called.clear()
carState_handle.enabled = False
recv_ready.set()
pub_sock.send(b"test")
_ = sub_sock.receive()
self.assertFalse(recv_called.peek())
except RuntimeError:
self.fail("event.wait() timed out")
def test_synced_pub_sub(self):
def daemon_repub_process_run():
pub_sock = messaging.pub_sock("ubloxGnss")
sub_sock = messaging.sub_sock("carState")
frame = -1
while True:
frame += 1
msg = sub_sock.receive(non_blocking=True)
if msg is None:
print("none received")
continue
bts = frame.to_bytes(8, 'little')
pub_sock.send(bts)
carState_handle = messaging.fake_event_handle("carState", enable=True)
recv_called = carState_handle.recv_called_event
recv_ready = carState_handle.recv_ready_event
p = multiprocessing.Process(target=daemon_repub_process_run)
p.start()
pub_sock = messaging.pub_sock("carState")
sub_sock = messaging.sub_sock("ubloxGnss")
try:
for i in range(10):
recv_called.wait(WAIT_TIMEOUT)
recv_called.clear()
if i == 0:
sub_sock.receive(non_blocking=True)
bts = i.to_bytes(8, 'little')
pub_sock.send(bts)
recv_ready.set()
recv_called.wait(WAIT_TIMEOUT)
msg = sub_sock.receive(non_blocking=True)
self.assertIsNotNone(msg)
self.assertEqual(len(msg), 8)
frame = int.from_bytes(msg, 'little')
self.assertEqual(frame, i)
except RuntimeError:
self.fail("event.wait() timed out")
finally:
p.kill()
if __name__ == "__main__":
unittest.main()