A two-tier IPC channel between a native (C/C++) host process and a Python client, designed for the case where a Python-side controller drives a long-running native process and needs reliable request/reply semantics under concurrent access.
Primary transport is an AF_UNIX stream socket. When the socket is not
available (host restarting, listener not yet open), the channel automatically
falls back to a file-based .cmd / .ack pair guarded by fcntl.flock.
The Python client and the native host negotiate a req-ID protocol on the
first exchange: once agreed, every command carries a per-call token that the
host echoes on its reply. Foreign acks left over from concurrent clients or
previous crashes are silently dropped instead of being misread as ours.
This was pulled out of a larger project where several long-lived Python
processes had to control a native library loaded into a Unity IL2CPP host
via LD_PRELOAD. The requirements that shaped the design:
- The native side is a black box that sometimes crashes and gets restarted. The Python side must survive that without hanging or spuriously succeeding.
- Multiple threads inside one Python process, and multiple Python processes on the same machine, both need to talk to the same native host. Neither layer should ever confuse another caller's reply with its own.
- Some commands are fast (
ping), some block for up to a minute waiting for an in-game event. A single timeout does not fit both classes. - The native library gets rebuilt often. The wire protocol needs a way to detect a version mismatch and fall back to plain text without extra configuration.
bridge_ipc/ipc.py— the Python client (send,flush_bridge,fi).examples/echo_host.cpp— a minimal native host that speaks the same protocol over the file transport. Useful for exercising the client without the original IL2CPP setup.examples/echo_client.py— Python driver for the demo host.tests/test_ipc.py— pytest suite covering the handshake, foreign-ack drop,fi()parsing, and timeouts. Uses in-process mock hosts.
The socket transport uses a short connect deadline (~2 s) to detect a dead
host quickly, and a caller-supplied recv deadline (default 10 s) that
bounds how long a legitimately blocking command may run. Collapsing both
into one timeout either makes dead-host detection slow, or breaks slow but
valid commands.
Two callers can race in two ways:
- Two threads inside one Python process.
threading.Lock(_proc_mu) handles this. Without it, thread A canunlink(ACK)between thread B'swrite(CMD)andpoll(ACK), and B will happily return A's reply. - Two Python processes on the same machine.
fcntl.flockon a per-instance lock file handles this.flockis per-open-file-description, so different processes serialize correctly even though they share a filesystem path.
Neither lock alone is sufficient. The intra-process mutex has no effect across processes; the OFD flock returns success immediately for the same process that already holds it. Both are required.
On first send() the client probes with req=<token> ping. A host that
understands the protocol echoes the same req=<token> on its ack line. A
legacy host either replies unknown command (if it parses req= as a
command) or returns a bare pong (if it strips unknown prefixes). The
client sets _supports_req accordingly and never probes again for this
process. On restart the probe runs again, so a host rebuild is picked up
without any configuration change.
Under _supports_req = True, every command carries a fresh 8-hex token.
When polling for the reply, acks whose prefix does not match ours and starts
with req= are recognized as another client's reply, unlinked, and the poll
continues. Acks without a req= prefix at all are accepted as a legacy-mode
fallback — this covers the rare case where the host was hot-swapped for an
older build mid-session.
The demo host writes its ack via rename() from a .ack.tmp sibling so a
polling client never observes a half-written ack. The client's write of
CMD is not atomic, and does not need to be — the host tolerates partial
reads by requiring a trailing newline.
BRIDGE_INSTANCE_ID (default "1") is used as a suffix on every temp file
path (/tmp/bridge_<IID>.cmd, .ack, .log, .ipc_lock, .sock). Set it
to a different value per host-client pair to run several bridges side by
side.
$ g++ -std=c++17 -O2 -o echo_host examples/echo_host.cpp
$ BRIDGE_INSTANCE_ID=demo ./echo_host &
$ BRIDGE_INSTANCE_ID=demo python3 examples/echo_client.py
ping -> pong
echo hello -> hello world
add 40 2 -> 42
sleep 150 ms -> ok
add 100 23 -> '123' (parsed as int: 123)
$ pip install pytest
$ python3 -m pytest tests/ -v
The tests do not compile the C++ host. Each test spawns an in-process mock
host on a unique instance id and reloads bridge_ipc.ipc to reset module
state.
- Linux (POSIX flock, AF_UNIX,
/tmp) - Python 3.7+
- A C++17 compiler if you want to build the demo host
MIT. See LICENSE.