Skip to content

Nbv000/native-bridge-ipc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

native-bridge-ipc

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.

Why

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.

What is in the box

  • 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.

Design notes

Two timeouts, not one

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 levels of serialization

Two callers can race in two ways:

  • Two threads inside one Python process. threading.Lock (_proc_mu) handles this. Without it, thread A can unlink(ACK) between thread B's write(CMD) and poll(ACK), and B will happily return A's reply.
  • Two Python processes on the same machine. fcntl.flock on a per-instance lock file handles this. flock is 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.

The req-ID handshake

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.

Atomic writes

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.

Multi-instance isolation

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.

Running the demo

$ 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)

Running the tests

$ 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.

Requirements

  • Linux (POSIX flock, AF_UNIX, /tmp)
  • Python 3.7+
  • A C++17 compiler if you want to build the demo host

License

MIT. See LICENSE.

About

Two-tier IPC channel (AF_UNIX socket + file-based flock fallback) between a native C/C++ host and a Python client, with auto-negotiated req-ID protocol.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages