Update deployment validation plan to reflect Phase 1b completion — pjsua2 built from pjproject 2.17 on caliban without sudo. Document the non-obvious RPATH/patchelf step, the status() method name correction, and two remaining caveats (not captured by pip install, Docker still runs stub media). Update README to point at the new docs/pjsua2-build.md and clarify stub-mode behavior.
4.5 KiB
Building the PJSUA2 Python bindings
The media pipeline (core/media_pipeline.py) needs
the pjsua2 Python bindings. They are not pip-installable — they are SWIG
bindings compiled from pjproject. Without them MediaPipeline.start() catches
ImportError and runs in stub mode: signaling works, but audio routing,
recording, tapping and playback are all no-ops that return successfully. That
last part is the trap — a stub gateway looks healthy while being unable to speak
or listen.
Verified on Ubuntu 25.10 / Python 3.13.7 / gcc 15.2, pjproject 2.17, 2026-07-28.
Prerequisites
swig and patchelf are both needed and both have PyPI wheels, so no sudo
is required — install them into the venv:
pip install swig patchelf
System dev libraries (already present on caliban; libsrtp2-dev is not
needed — pjproject bundles its own SRTP):
libasound2-dev libssl-dev libopus-dev uuid-dev python3-dev
Build
mkdir -p ~/src && cd ~/src
git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git
cd pjproject
# Minimal config_site.h — enable TLS transport support
echo '#define PJ_HAS_SSL_SOCK 1' > pjlib/include/pj/config_site.h
# -fPIC is REQUIRED: the Python extension links these into a shared object.
# --enable-shared builds the .so files the bindings load at runtime.
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" ./configure \
--enable-shared \
--disable-video --disable-libyuv --disable-libwebrtc \
--prefix=$HOME/.local
make dep && make -j$(nproc) && make install
Confirm configure found what the gateway needs (all should say yes/enabled):
OpenSSL, ALSA (alsa/version.h), OPUS.
# Python bindings
cd pjsip-apps/src/swig
make python
cd python && python setup.py install
The RPATH step — do not skip this
The shared libraries install to ~/.local/lib, which is not on the default
loader path, and neither the extension nor pjproject's own libraries carry an
RPATH. Straight after setup.py install the import fails with:
ImportError: libpjsua2.so.2: cannot open shared object file
Rather than requiring LD_LIBRARY_PATH everywhere (it would have to be set for
uvicorn, systemd, cron and any subprocess — easy to miss, and it fails at call
time, not startup), bake the path into the binaries:
# The extension module …
patchelf --set-rpath $HOME/.local/lib \
$VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2.cpython-313-x86_64-linux-gnu.so
# … and pjproject's libraries, which must also find each other.
cd ~/.local/lib && for f in libpj*.so.2; do
patchelf --set-rpath $HOME/.local/lib "$f"
done
Patching only the extension is not enough: it resolves libpjsua2, which then
fails on its own transitive deps (libpjsua, libpjsip, libpjmedia, libpj,
…). Patch the whole set.
Verify
# 1. Loads with a completely empty environment (proves RPATH, not inherited vars)
cd / && env -i $VIRTUAL_ENV/bin/python -c \
"import pjsua2 as pj; ep=pj.Endpoint(); ep.libCreate(); print(ep.libVersion().full); ep.libDestroy()"
# 2. No unresolved libraries
ldd $VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2*.so | grep "not found"
# 3. The real gate — Hold Slayer's own pipeline reports it
python -c "
import asyncio
from core.media_pipeline import MediaPipeline
async def m():
p = MediaPipeline(); await p.start()
assert p.status()['pjsua2_available'] is True
print('pjsua2_available: True'); await p.stop()
asyncio.run(m())"
ImportError in step 1 or pjsua2_available: False in step 3 means you are
still in stub mode.
Notes
- Not captured by
pip install -e ".[dev]". This build lives outside the Python packaging metadata, so a fresh venv, a rebuilt container, or another host needs it repeated. Treat it as host provisioning. - The Docker image deliberately does not build this (see the comment at the top of the Dockerfile) — the container therefore runs stub media. Anything validating audio must run outside the image, or the Dockerfile needs a build stage adding.
- Threading: PJSUA2 starts its own worker threads, in addition to the Sippy ED thread. Per the concurrency rule, PJSUA2 objects belong to the media pipeline and must not be touched from the Sippy thread or mutated directly from the asyncio loop.
- The extension compiles against system headers (
/usr/include/python3.13) rather than the venv's. Harmless while both are the same 3.13.7 with matching SOABI — worth re-checking if the venv's Python is ever upgraded independently.