Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions .github/scripts/check_no_private_symbols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# Copyright 2026 LiveKit
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Verify that liblivekit's exported ABI does not leak private dependency symbols.

The LiveKit SDK statically links several private dependencies (spdlog, fmt,
google::protobuf, absl). When those symbols escape the dynamic symbol table
of liblivekit.{so,dylib,dll}, they collide at runtime with the same libraries
loaded elsewhere in the host process (a common failure mode is ROS 2's
rcl_logging_spdlog ABI-clashing with our vendored spdlog and crashing inside
spdlog::pattern_formatter).

This script lists exported defined symbols from the supplied shared library
using the platform-appropriate tool and fails (exit code 1) if any of them
match a forbidden pattern.

Usage:
python3 check_no_private_symbols.py <path-to-shared-library>

Optional environment variables:
LIVEKIT_SYMBOL_CHECK_VERBOSE=1 Print every leaked symbol (default: print
up to 20 examples).
LIVEKIT_SYMBOL_CHECK_EXTRA_FORBIDDEN=foo,bar Additional comma-separated
patterns to forbid.
"""

import os
Comment thread
alan-george-lk marked this conversation as resolved.
import re
import shutil
import subprocess
import sys
from pathlib import Path

# Substring patterns that must NOT appear in any exported symbol after
# demangling. We use plain-substring semantics for readability; if you need a
# regex, switch to re.search.
DEFAULT_FORBIDDEN = [
"spdlog::",
"fmt::v",
"google::protobuf",
"absl::",
]

MAX_REPORTED_LEAKS = 20


def _which_or_die(name: str) -> str:
path = shutil.which(name)
if not path:
sys.stderr.write(f"error: required tool '{name}' not found on PATH\n")
sys.exit(2)
return path


def _list_exports_macos(lib: Path) -> list[str]:
nm = _which_or_die("nm")
cxxfilt = shutil.which("c++filt")
# -gU: external (global) defined symbols.
raw = subprocess.run(
[nm, "-gU", str(lib)],
check=True,
capture_output=True,
text=True,
).stdout
if cxxfilt:
raw = subprocess.run(
[cxxfilt],
input=raw,
check=True,
capture_output=True,
text=True,
).stdout
return raw.splitlines()


def _list_exports_linux(lib: Path) -> list[str]:
nm = _which_or_die("nm")
cxxfilt = shutil.which("c++filt")
# -D: dynamic symbols (i.e., what's actually visible to the dynamic linker)
# --defined-only: drop UND entries
raw = subprocess.run(
[nm, "-D", "--defined-only", str(lib)],
check=True,
capture_output=True,
text=True,
).stdout
if cxxfilt:
raw = subprocess.run(
[cxxfilt],
input=raw,
check=True,
capture_output=True,
text=True,
).stdout
return raw.splitlines()


def _find_dumpbin_via_vswhere() -> str | None:
"""Locate dumpbin.exe under the latest installed Visual Studio.

GitHub-hosted Windows runners (and standard local VS installs) don't add
dumpbin to PATH unless the user opens a Developer Command Prompt. vswhere
ships at a fixed location with every Visual Studio install since 2017 and
is the supported way to discover the install tree from a vanilla shell.
"""
program_files_x86 = os.environ.get(
"ProgramFiles(x86)", r"C:\Program Files (x86)"
)
vswhere = Path(program_files_x86) / "Microsoft Visual Studio" / "Installer" \
/ "vswhere.exe"
if not vswhere.exists():
return None
try:
proc = subprocess.run(
[
str(vswhere),
"-latest",
"-products", "*",
"-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-property", "installationPath",
],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError:
return None
install_path = proc.stdout.strip()
if not install_path:
return None
msvc_root = Path(install_path) / "VC" / "Tools" / "MSVC"
if not msvc_root.is_dir():
return None
# Pick the highest-versioned toolchain present (lexicographic order matches
# version order for dotted MSVC versions like "14.44.35207").
for version_dir in sorted(msvc_root.iterdir(), reverse=True):
candidate = version_dir / "bin" / "Hostx64" / "x64" / "dumpbin.exe"
if candidate.exists():
return str(candidate)
return None


def _list_exports_windows(lib: Path) -> list[str]:
# dumpbin ships with MSVC; it understands import libs (.lib) and DLLs.
dumpbin = shutil.which("dumpbin") or _find_dumpbin_via_vswhere()
if not dumpbin:
sys.stderr.write(
"error: 'dumpbin' not on PATH and could not be located via "
"vswhere; run from a Visual Studio Developer command prompt or "
"ensure dumpbin.exe is available\n"
)
sys.exit(2)
raw = subprocess.run(
[dumpbin, "/exports", str(lib)],
check=True,
capture_output=True,
text=True,
).stdout
# dumpbin output lines for export entries look like
# " 1 0 00001000 ?foo@@YAHXZ = ?foo@@YAHXZ (int __cdecl foo(void))"
# We keep all of stdout: the substring search will only fire on actual
# symbol names, headers/footers are harmless.
return raw.splitlines()


def _list_exports(lib: Path) -> list[str]:
if sys.platform == "darwin":
return _list_exports_macos(lib)
if sys.platform.startswith("linux"):
return _list_exports_linux(lib)
if os.name == "nt" or sys.platform == "win32":
return _list_exports_windows(lib)
sys.stderr.write(f"error: unsupported platform '{sys.platform}'\n")
sys.exit(2)


def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write(__doc__ or "")
return 2

lib = Path(argv[1]).resolve()
if not lib.exists():
sys.stderr.write(f"error: library not found: {lib}\n")
return 2

forbidden = list(DEFAULT_FORBIDDEN)
extra = os.environ.get("LIVEKIT_SYMBOL_CHECK_EXTRA_FORBIDDEN", "")
if extra:
forbidden.extend(p for p in extra.split(",") if p)

verbose = bool(os.environ.get("LIVEKIT_SYMBOL_CHECK_VERBOSE"))

print(f"[symbol-check] library : {lib}")
print(f"[symbol-check] platform: {sys.platform}")
print(f"[symbol-check] forbidden patterns: {forbidden}")

lines = _list_exports(lib)
print(f"[symbol-check] {len(lines)} lines of nm/dumpbin output")

# Group leaks by pattern for a tidy summary.
leaks_by_pattern: dict[str, list[str]] = {p: [] for p in forbidden}
for line in lines:
for pat in forbidden:
if pat in line:
leaks_by_pattern[pat].append(line.rstrip())

total_leaks = sum(len(v) for v in leaks_by_pattern.values())
if total_leaks == 0:
print("[symbol-check] OK: no forbidden symbols exported")
return 0

print(f"[symbol-check] FAIL: {total_leaks} forbidden symbol(s) exported")
for pat, hits in leaks_by_pattern.items():
if not hits:
continue
print(f" pattern {pat!r}: {len(hits)} hit(s)")
shown = hits if verbose else hits[:MAX_REPORTED_LEAKS]
for h in shown:
print(f" {h}")
if not verbose and len(hits) > MAX_REPORTED_LEAKS:
print(f" ... and {len(hits) - MAX_REPORTED_LEAKS} more "
"(set LIVEKIT_SYMBOL_CHECK_VERBOSE=1 to see all)")

print(
"\nliblivekit must not re-export private dependency symbols.\n"
"If you intentionally added a public symbol that triggered this, mark\n"
"it with LIVEKIT_API in include/livekit/visibility.h and rebuild.\n"
)
return 1


if __name__ == "__main__":
sys.exit(main(sys.argv))
9 changes: 6 additions & 3 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
- cpp-example-collection/**
- bridge/**
- client-sdk-rust/**
- cmake/**
Comment thread
alan-george-lk marked this conversation as resolved.
- scripts/**
- CMakeLists.txt
- build.sh
- build.cmd
Expand All @@ -27,6 +29,8 @@ on:
- cpp-example-collection/**
- bridge/**
- client-sdk-rust/**
- cmake/**
- scripts/**
- CMakeLists.txt
- build.sh
- build.cmd
Expand Down Expand Up @@ -133,15 +137,14 @@ jobs:
libssl-dev \
libprotobuf-dev protobuf-compiler \
libabsl-dev \
libwayland-dev libdecor-0-dev \
libspdlog-dev
libwayland-dev libdecor-0-dev
Comment thread
stephen-derosa marked this conversation as resolved.

- name: Install deps (macOS)
if: runner.os == 'macOS'
run: |
set -eux
brew update
brew install cmake ninja protobuf abseil spdlog
brew install cmake ninja protobuf abseil

# ---------- Rust toolchain ----------
- name: Install Rust (stable)
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/make-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,49 @@ jobs:
Write-Host "Bundle contents:"
Get-ChildItem -Recurse -File $bundleDir | Select-Object -First 200 | ForEach-Object { $_.FullName }

# ---------- Verify exported ABI: no third-party symbol leaks ----------
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v6.0.0
with:
python-version: '3.x'

- name: Symbol leak check (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -eux
libdir="build-release/lib"
if [[ "$RUNNER_OS" == "macOS" ]]; then
lib="${libdir}/liblivekit.dylib"
if [[ ! -f "${lib}" ]]; then
lib="build-release/bin/liblivekit.dylib"
fi
else
lib="${libdir}/liblivekit.so"
if [[ ! -f "${lib}" ]]; then
lib="build-release/bin/liblivekit.so"
fi
fi
python3 .github/scripts/check_no_private_symbols.py "${lib}"

- name: Symbol leak check (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$candidates = @(
"build-release/bin/livekit.dll",
"build-release/lib/livekit.dll"
)
$lib = $null
foreach ($p in $candidates) {
if (Test-Path -LiteralPath $p) { $lib = $p; break }
}
if ($null -eq $lib) {
Write-Error "livekit.dll not found in any of: $($candidates -join ', ')"
exit 1
}
python .github/scripts/check_no_private_symbols.py "$lib"

# ---------- Upload artifact (raw directory, no pre-compression) ----------
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,14 @@ jobs:
libprotobuf-dev protobuf-compiler \
libabsl-dev \
libwayland-dev libdecor-0-dev \
libspdlog-dev \
jq

- name: Install deps (macOS)
if: runner.os == 'macOS'
run: |
set -eux
brew update
brew install cmake ninja protobuf abseil spdlog jq
brew install cmake ninja protobuf abseil jq

# ---------- Rust toolchain ----------
- name: Install Rust (stable)
Expand Down
Loading
Loading