Files
hold-slayer/docs/pjsua2-build.md
Robert Helewka e9219f2d4a
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 58s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m42s
docs: mark PJSUA2 build complete and add build procedure
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.
2026-07-28 22:05:26 -04:00

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.