Skip to content

livekit-datatrack: stop aborting when a foreign E2EE provider errors - #1429

Open
davidliu wants to merge 1 commit into
mainfrom
dl/encryption-error-flat-fix
Open

davidliu wants to merge 1 commit into
mainfrom
dl/encryption-error-flat-fix

Conversation

@davidliu

Copy link
Copy Markdown
Contributor

Problem

EncryptionError and DecryptionError are the error types of EncryptionProvider and DecryptionProvider, both #[uniffi::export(with_foreign)]. Foreign code implements those traits -- DataTrackCryptor in the Android and Swift SDKs bridges data track frames onto each platform's AES-GCM path -- so uniffi has to lift these errors into Rust.

Both were uniffi(flat_error). A flat error can be lowered but not lifted: uniffi emits a Lift impl that exists only to satisfy trait bounds and panics if called.

fn try_read(buf: &mut &[u8]) -> Result<Self> { panic!("Can't lift flat errors") }

A panic in an FFI callback has nowhere to unwind to, so every failed decrypt aborted the host process:

Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 31589 (Thread-24)
Abort message: 'Can't lift flat errors'

That reached anything that can fail a decrypt: a subscriber with no E2EE manager, a key mismatch, or a single corrupt frame. Reproduced on Android against a real SFU; iOS ships the same cryptor and the same exposure. Present since these types were introduced in #1034 -- data tracks had simply never run a failed decrypt across the boundary.

Fix

Drop flat_error from both enums and carry the detail in the variant:

#[error("Decryption failed: {reason}")]
Failed { reason: String },

This is the shape PacketDeliveryError already uses for the same reason; it is the only other error returned across a with_foreign trait. Both enums also gain its From<UnexpectedUniFFICallbackError> catch-all, so a foreign provider throwing something other than the declared type surfaces as an error rather than aborting.

reason is free-form host context (logged, not parsed), and it is the first time the string a foreign cryptor builds reaches Rust at all: under flat_error that message was write-only, since lowering synthesized it from Display and lifting never happened.

The four construction sites in livekit/src/room/e2ee/data_track.rs were all map_err(|_| ..). Now that there is somewhere to put the cause, they propagate it.

Breaking Changes

  • EncryptionError::Failed and DecryptionError::Failed are struct variants.
  • Rust callers construct Failed { reason }.
  • Foreign callers still pass a single string:
    • Kotlin's positional Failed(msg) is source-compatible
    • Swift's Failed(message:) becomes Failed(reason:)

Test

cargo test -p livekit-datatrack --features uniffi: 109 passed.

Regenerated the Kotlin and Swift bindings from the built cdylib. The converter now reads the field instead of a synthesized Display string, i.e. the real Lift impl replaced the panicking stub:

1 -> DecryptionException.Failed(FfiConverterString.read(buf))

Built the Android AAR locally and re-ran the two e2e tests that previously aborted (Pixel 6, real SFU): both pass, logcat -b crash clean. A failed decrypt now logs and drops the frame, leaving the room connected and the track published.

… an error

## Problem

`EncryptionError` and `DecryptionError` are the error types of `EncryptionProvider`
and `DecryptionProvider`, both `#[uniffi::export(with_foreign)]`. Foreign code
implements those traits -- `DataTrackCryptor` in the Android and Swift SDKs bridges
data track frames onto each platform's AES-GCM path -- so uniffi has to lift these
errors *into* Rust.

Both were `uniffi(flat_error)`. A flat error can be lowered but not lifted: uniffi
emits a `Lift` impl that exists only to satisfy trait bounds and panics if called.

    fn try_read(buf: &mut &[u8]) -> Result<Self> { panic!("Can't lift flat errors") }

A panic in an FFI callback has nowhere to unwind to, so every failed decrypt aborted
the host process:

    Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 31589 (Thread-24)
    Abort message: 'Can't lift flat errors'

That reached anything that can fail a decrypt: a subscriber with no E2EE manager, a
key mismatch, or a single corrupt frame. Reproduced on Android against a real SFU;
iOS ships the same cryptor and the same exposure. Present since these types were
introduced in #1034 -- data tracks had simply never run a failed decrypt across the
boundary.

## Fix

Drop `flat_error` from both enums and carry the detail in the variant:

    #[error("Decryption failed: {reason}")]
    Failed { reason: String },

This is the shape `PacketDeliveryError` already uses for the same reason; it is the
only other error returned across a `with_foreign` trait. Both enums also gain its
`From<UnexpectedUniFFICallbackError>` catch-all, so a foreign provider throwing
something other than the declared type surfaces as an error rather than aborting.

`reason` is free-form host context (logged, not parsed), and it is the first time
the string a foreign cryptor builds reaches Rust at all: under `flat_error` that
message was write-only, since lowering synthesized it from `Display` and lifting
never happened.

The four construction sites in `livekit/src/room/e2ee/data_track.rs` were all
`map_err(|_| ..)`. Now that there is somewhere to put the cause, they propagate it.

**Breaking:** `EncryptionError::Failed` and `DecryptionError::Failed` are struct
variants. Rust callers construct `Failed { reason }`. Foreign callers still pass a
single string -- Kotlin's positional `Failed(msg)` is source-compatible, Swift's
`Failed(message:)` becomes `Failed(reason:)`.

## Test

`cargo test -p livekit-datatrack --features uniffi`: 109 passed.

Regenerated the Kotlin and Swift bindings from the built cdylib. The converter now
reads the field instead of a synthesized `Display` string, i.e. the real `Lift` impl
replaced the panicking stub:

    1 -> DecryptionException.Failed(FfiConverterString.read(buf))

Built the Android AAR locally and re-ran the two e2e tests that previously aborted
(Pixel 6, real SFU): both pass, `logcat -b crash` clean. A failed decrypt now logs
and drops the frame, leaving the room connected and the track published.
@github-actions

Copy link
Copy Markdown
Contributor

Changeset ✓

This PR includes a changeset covering all affected packages:

Package Bump
livekit patch
livekit-capture patch
livekit-datatrack major
livekit-ffi patch
livekit-uniffi major

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@ladvoc ladvoc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM ✅, one optional suggestion. Please run cargo fmt to make the last CI check pass.

#[error("Encryption failed")]
Failed,
#[error("Encryption failed: {reason}")]
Failed { reason: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (non-blocking): Consider defining an enum case for each error type (e.g., UnexpectedIvLength, KeyIndexOutOfRange, and Failed).

Comment on lines 50 to +56
/// An error indicating a payload could not be decrypted.
#[derive(Debug, Error)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
#[cfg_attr(feature = "uniffi", uniffi(flat_error))]
pub enum DecryptionError {
#[error("Decryption failed")]
Failed,
#[error("Decryption failed: {reason}")]
Failed { reason: String },
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might also be worth adding something to the AGENTS.md saying something to the effect of "never use flat_error in contexts where an error needs to be lifted from client sdk -> rust"?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants