Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix_headless_livekit_feature_wiring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
livekit: patch
---

Disable libwebrtc default features in workspace dependency wiring so `livekit --no-default-features -F tokio` no longer re-enables `glib-main-loop` transitively.
8 changes: 8 additions & 0 deletions .changeset/headless_linux_build_path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
livekit: patch
webrtc-sys: patch
libwebrtc: patch
livekit-ffi: patch
---

Add headless Linux build path - #1024 (@mikefaille)
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ license = "Apache-2.0"
[workspace.dependencies]
device-info = { version = "0.1.1", path = "device-info" }
imgproc = { version = "0.3.19", path = "imgproc" }
libwebrtc = { version = "0.3.34", path = "libwebrtc" }
libwebrtc = { version = "0.3.34", path = "libwebrtc", default-features = false }
livekit = { version = "0.7.42", path = "livekit" }
livekit-api = { version = "0.4.24", path = "livekit-api" }
livekit-ffi = { version = "0.12.60", path = "livekit-ffi" }
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ When building on MacOS, `-ObjC` linker flag is needed. LiveKit's WebRTC implemen

## Environment variables

### `LK_HEADLESS`

For server/headless Linux deployments where desktop capturing dependencies (such as X11, GLib, GIO, DRM, and GBM) are not present, set `LK_HEADLESS=1` to compile without those native library requirements. The C++ desktop capturer will be replaced by stubs at build time, and `DesktopCapturer::new()` will return `None`.

See [HEADLESS.md](docs/HEADLESS.md) for more details.

### `LIVEKIT_PREFERRED_HW_ENCODER`

On Linux builds that include support for multiple hardware video encoders (NVENC and VAAPI), this variable selects which one is preferred when both are available at runtime. Accepted values:
Expand Down
113 changes: 113 additions & 0 deletions docs/HEADLESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Headless Linux Build Support

This document details how to compile and run the LiveKit Rust SDK on headless Linux environments (such as server deployments, Docker containers, or CI environments) where desktop-related libraries (e.g., X11, GLib, GIO, DRM, and GBM) are not installed.

---

## Overview

By default, the SDK compiles with native desktop capture support (`glib-main-loop`). On Linux, this requires GLib event loops and X11/Wayland libraries. For server-side or containerized usage, these desktop dependencies are unnecessary and complicate builds.

We provide a **unified hybrid configuration** offering two parallel mechanisms to compile without these dependencies:

1. **Option A: Cargo Features** (Declarative configuration within your Cargo dependency tree)
2. **Option B: Environment Variables** (Direct shell or CI/CD environment overrides)

Regardless of the option chosen, **the public Rust API surface remains identical**. `DesktopCapturer::new()` compiles successfully but returns `None` at runtime, ensuring your downstream code doesn't need to be gated by custom `#[cfg]` attributes.

---

## Comparison: Option A vs. Option B

| Dimension | Option A: Cargo Features | Option B: Environment Variable |
| :--- | :--- | :--- |
| **Configuration Style** | Declarative in `Cargo.toml`. | Shell environment (`export LK_HEADLESS=1`). |
| **Scope** | Targets a specific crate or workspace package. | Global override across the entire build invocation. |
| **Workspace Ergonomics** | Fragile if some crates transitively unify default features. | Highly robust for workspace builds and containerized builds. |
| **Caching Integration** | Automatic compile cache invalidation by Cargo. | Enabled via `cargo:rerun-if-env-changed` in `build.rs`. |
| **Best Used For** | Purely headless applications declaring their own dependencies. | CI/CD pipelines, Dockerfiles, and heterogeneous workspaces. |

---

## Option A: Cargo Features (`headless`)

To configure a headless build declaratively, disable the default features of the `livekit` crate and opt in to `headless` and `tokio`:

```toml
[dependencies]
livekit = { version = "0.7", default-features = false, features = ["tokio", "headless"] }
```

### Option A: FAQ

#### Q1: Why do I need `default-features = false`?
By default, the `livekit` crate includes the `glib-main-loop` feature to provide desktop capturing integration out of the box. You must disable default features to prevent this feature from pulling in GLib.

#### Q2: What happens if a third-party dependency pulls in default features of `livekit`?
Due to Cargo's feature unification, if any dependency in your tree enables default features for `livekit`, the `glib-main-loop` feature will be unified and enabled globally. In this case, Option A will fail, and you must use **Option B** to force a headless compile.

#### Q3: Do I need `resolver = "2"` in my workspace?
Yes. Cargo's resolver version 2 is required to prevent build-dependencies (like `build.rs` dependencies) from unifying their features with target dependencies. The root `Cargo.toml` in the SDK is already configured with `resolver = "2"`.

---

## Option B: Environment Variable (`LK_HEADLESS=1`)

To force a headless build globally without editing `Cargo.toml` files, set the environment variable:

```bash
LK_HEADLESS=1 cargo build --release
```

Or inject it into a Dockerfile:

```dockerfile
ENV LK_HEADLESS=1
RUN cargo build --release
```

### Option B: FAQ

#### Q1: How does `LK_HEADLESS=1` bypass Cargo feature unification?
Even if the `glib-main-loop` Cargo feature is transitively enabled, the `build.rs` script of `webrtc-sys` checks the `LK_HEADLESS` environment variable. If set to `1` or `true`, the build script completely bypasses probing for GLib/GIO/X11/DRM/GBM packages.

#### Q2: Can this cause runtime linker errors?
No. Because `build.rs` skips registering these library linkages, the binary will not link against X11, GLib, or DRM libraries.

#### Q3: Does this affect hardware accelerated video codecs (CUDA / VAAPI)?
No. Hardware acceleration (such as NVidia CUDA/NVCodec or Intel VAAPI) is preserved on headless builds since they do not require desktop environments. They continue to load their respective drivers dynamically via dlopen at runtime.

#### Q4: How is compile caching affected when changing `LK_HEADLESS`?
The build script prints `cargo:rerun-if-env-changed=LK_HEADLESS`. Cargo automatically invalidates the compile cache and rebuilds the native bindings if you switch the environment variable between builds.

---

## What Changes in the API?

The Rust API surface is kept completely uniform. You do **not** need to wrap your screensharing logic in `#[cfg]` attributes:

```rust
use livekit::webrtc::desktop_capturer::{DesktopCapturer, DesktopCapturerOptions, DesktopCaptureSourceType};

// This compiles on both desktop and headless targets
let options = DesktopCapturerOptions {
source_type: DesktopCaptureSourceType::Screen,
include_cursor: true,
allow_sck_system_picker: false,
};

// Returns Some(capturer) on desktop; returns None on headless Linux/servers
if let Some(capturer) = DesktopCapturer::new(options) {
println!("Desktop capturer started successfully!");
} else {
println!("Running in a headless/server environment (desktop capturer disabled).");
}
```

---

## How It Works Under the Hood

1. **Feature Wiring**: The `headless` Cargo feature is forwarded from `livekit` -> `libwebrtc` -> `webrtc-sys`.
2. **Build Script Bypassing**: If `LK_HEADLESS=1` or `CARGO_FEATURE_HEADLESS` is detected, `webrtc-sys/build.rs` skips GLib/GIO/X11 pkg-config lookups and defines the preprocessor macro `-DLK_HEADLESS=1` for the C++ compiler.
3. **C++ Stubbing**: Inside `desktop_capturer.cpp`, all methods accessing WebRTC's desktop capture systems are enclosed in `#ifndef LK_HEADLESS` guards. In headless mode, the factory returns `nullptr`, and all operations become safe no-ops.
2 changes: 1 addition & 1 deletion examples/local_audio/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ tokio = { workspace = true, features = ["full"] }
env_logger = { workspace = true }
livekit = { workspace = true, features = ["rustls-tls-native-roots"] }
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"] }
libwebrtc = { workspace = true }
libwebrtc = { workspace = true, features = ["glib-main-loop"] }
log = { workspace = true }
cpal = "0.15"
anyhow = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion examples/local_video/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ required-features = ["desktop"]
tokio = { workspace = true, features = ["full", "parking_lot"] }
livekit = { workspace = true, features = ["rustls-tls-native-roots"] }
webrtc-sys = { workspace = true }
libwebrtc = { workspace = true }
libwebrtc = { workspace = true, features = ["glib-main-loop"] }
livekit-api = { workspace = true }
yuv-sys = { workspace = true, features = ["jpeg"] }
futures = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions libwebrtc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ default = [ "glib-main-loop" ]
# event loop running in your application, for example if you are using the
# GTK or GStreamer Rust bindings, disable this feature.
glib-main-loop = [ "dep:glib" ]
# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds
headless = [ "webrtc-sys/headless" ]

[dependencies]
log = { workspace = true }
Expand Down
6 changes: 4 additions & 2 deletions livekit-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@ readme = "README.md"
publish = false

[features]
default = ["rustls-tls-native-roots"]
default = ["rustls-tls-native-roots", "livekit/tokio", "livekit/glib-main-loop"]
native-tls = ["livekit/native-tls"]
native-tls-vendored = ["livekit/native-tls-vendored"]
rustls-tls-native-roots = ["livekit/rustls-tls-native-roots"]
rustls-tls-webpki-roots = ["livekit/rustls-tls-webpki-roots"]
__rustls-tls = ["livekit/__rustls-tls"]
# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds
headless = ["livekit/headless", "webrtc-sys/headless"]

# Enable tokio-console to debug tasks
tracing = ["tokio/tracing", "console-subscriber"]

[dependencies]
livekit = { workspace = true }
livekit = { workspace = true, default-features = false }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move livekit feature disabling to workspace dependency

default-features = false is ignored on inherited dependencies (workspace = true) unless the workspace dependency itself sets default-features, and Cargo already emits that warning for this entry. As written, livekit-ffi still pulls livekit defaults, so headless Linux builds can still enable livekit/glib-main-loop transitively and fail on machines without GLib despite selecting headless. Please set the default-feature behavior in [workspace.dependencies.livekit] (or stop inheriting this dependency) so the headless feature wiring is actually enforced.

Useful? React with 👍 / 👎.

webrtc-sys = { workspace = true }
soxr-sys = { workspace = true }
imgproc = { workspace = true }
Expand Down
5 changes: 4 additions & 1 deletion livekit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ repository.workspace = true

[features]
# By default ws TLS is not enabled
default = ["tokio"]
default = ["tokio", "glib-main-loop"]

async = ["livekit-api/signal-client-async"]
tokio = ["livekit-api/signal-client-tokio"]
dispatcher = ["livekit-api/signal-client-dispatcher"]
glib-main-loop = ["libwebrtc/glib-main-loop"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable libwebrtc defaults for headless feature control

This adds a glib-main-loop feature toggle at the livekit level, but the libwebrtc dependency still uses its own default features, so cargo build -p livekit --no-default-features -F tokio will continue to enable libwebrtc’s default glib-main-loop path. In practice, headless Linux builds still pull GLib transitively unless libwebrtc is declared with default-features = false, which means the new documented headless flow is not actually enforced by this feature wiring.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in the following commit

# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds
headless = ["libwebrtc/headless"]


# Note that the following features only change the behavior of tokio-tungstenite.
Expand Down
2 changes: 2 additions & 0 deletions webrtc-sys/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ repository.workspace = true

[features]
default = []
# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds
headless = []

[dependencies]
cxx = "1.0"
Expand Down
34 changes: 22 additions & 12 deletions webrtc-sys/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ fn main() {

let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
let is_headless = target_os == "linux"
&& (env::var("LK_HEADLESS").as_deref() == Ok("1")
|| env::var("LK_HEADLESS").as_deref() == Ok("true")
|| env::var("CARGO_FEATURE_HEADLESS").is_ok());
let is_desktop = target_os == "linux" || target_os == "windows" || target_os == "macos";

println!("cargo:rerun-if-env-changed=LK_DEBUG_WEBRTC");
println!("cargo:rerun-if-env-changed=LK_CUSTOM_WEBRTC");
println!("cargo:rerun-if-env-changed=LK_HEADLESS");

let mut rust_files = vec![
"src/peer_connection.rs",
Expand Down Expand Up @@ -119,6 +124,9 @@ fn main() {
webrtc_include.join("sdk/objc/base"),
]);
builder.define("WEBRTC_APM_DEBUG_DUMP", "0");
if is_headless {
builder.define("LK_HEADLESS", "1");
}

println!("cargo:rustc-link-search=native={}", webrtc_lib.to_str().unwrap());

Expand Down Expand Up @@ -171,20 +179,22 @@ fn main() {
println!("cargo:rustc-link-lib=dylib=pthread");
println!("cargo:rustc-link-lib=dylib=m");

// In order to avoid any ABI mismatches we use the sysroot's headers.
add_gio_headers(&mut builder);
if !is_headless {
// In order to avoid any ABI mismatches we use the sysroot's headers.
add_gio_headers(&mut builder);

for lib_name in ["glib-2.0", "gobject-2.0", "gio-2.0"] {
pkg_config::probe_library(lib_name).unwrap();
}
for lib_name in ["glib-2.0", "gobject-2.0", "gio-2.0"] {
pkg_config::probe_library(lib_name).unwrap();
}

add_lazy_load_so(
&mut builder,
"desktop_capturer",
["drm", "gbm", "X11", "Xfixes", "Xdamage", "Xrandr", "Xcomposite", "Xext"]
.map(String::from)
.to_vec(),
);
add_lazy_load_so(
&mut builder,
"desktop_capturer",
["drm", "gbm", "X11", "Xfixes", "Xdamage", "Xrandr", "Xcomposite", "Xext"]
.map(String::from)
.to_vec(),
);
}

let x86 = target_arch == "x86_64" || target_arch == "i686";
let arm = target_arch == "aarch64" || target_arch.contains("arm");
Expand Down
40 changes: 40 additions & 0 deletions webrtc-sys/include/livekit/desktop_capturer.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,41 @@
#pragma once
#include <memory>

#ifndef LK_HEADLESS
#include "modules/desktop_capture/desktop_capturer.h"
#else
#include <vector>
#include <string>
namespace webrtc {
class DesktopFrame {
public:
struct Size { int32_t width() const { return 0; } int32_t height() const { return 0; } };
struct Rect { int32_t left() const { return 0; } int32_t top() const { return 0; } };
Size size() const { return {}; }
Rect rect() const { return {}; }
int32_t stride() const { return 0; }
const uint8_t* data() const { return nullptr; }
};
class DesktopCapturer {
public:
enum class Result { SUCCESS, ERROR_TEMPORARY, ERROR_PERMANENT };
struct Source {
int64_t id;
std::string title;
};
typedef std::vector<Source> SourceList;
class Callback {
public:
virtual ~Callback() = default;
virtual void OnCaptureResult(Result result, std::unique_ptr<DesktopFrame> frame) = 0;
};
};
}
#endif

#include "rust/cxx.h"


namespace livekit_ffi {
class DesktopFrame;
class DesktopCapturer;
Expand All @@ -40,9 +72,17 @@ class DesktopCapturer : public webrtc::DesktopCapturer::Callback {
std::unique_ptr<webrtc::DesktopFrame> frame) final;

rust::Vec<Source> get_source_list() const;
#ifdef LK_HEADLESS
bool select_source(uint64_t id) const { return false; }
#else
bool select_source(uint64_t id) const { return capturer->SelectSource(id); }
#endif
void start(rust::Box<DesktopCapturerCallbackWrapper> callback);
#ifdef LK_HEADLESS
void capture_frame() const {}
#else
void capture_frame() const { capturer->CaptureFrame(); }
#endif

private:
std::unique_ptr<webrtc::DesktopCapturer> capturer;
Expand Down
Loading
Loading