docs: mark PJSUA2 build complete and add build procedure
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

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.
This commit is contained in:
2026-07-28 22:05:26 -04:00
parent 394e3fc920
commit e9219f2d4a
3 changed files with 177 additions and 14 deletions

130
docs/pjsua2-build.md Normal file
View File

@@ -0,0 +1,130 @@
# Building the PJSUA2 Python bindings
The media pipeline ([core/media_pipeline.py](../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:
```bash
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
```bash
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.
```bash
# 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:
```bash
# 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
```bash
# 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](../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.