Skip to content

feat(ffi): type decoder setup refusals - #7730

Merged
kixelated merged 7 commits into
mainfrom
quest/m1/ffi-unsupported-config
Nov 3, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m1/ffi-unsupported-config

Conversation

@kixelated

@kixelated kixelated commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

OBS discarded audio when subscribe refused a rendition's description, even when a lower ranked rendition was playable. Its codec-name gate duplicated moq-audio and could not detect AAC-LC with SBR in its description.

Approach

Audio/video subscribe returns a distinct setup refusal, mirrored through moq-c and every binding. The refusal covers only a codec, layout, or description the decoder knows it cannot handle. Decoder faults keep typed errors of their own and are never folded into it. OBS tries each ranked audio candidate once on that typed refusal and keeps its fallback snapshot current across catalog updates. Its duplicate codec gate and tests are removed.

  • Audio: the consumer tags Unsupported from decoder and remix setup as UnsupportedConfig. libopus now splits its failures: a rejected argument or unimplemented feature is Unsupported, while allocation, internal, and state failures are the new Error::Codec. When several backends refuse, the selector reports a fault rather than folding it into Unsupported. An output sample rate of zero is a caller error, not a rendition refusal.
  • Video: Decoder::new reports a missing or malformed avcC/hvcC description as UnsupportedCodec, alongside codec shapes it cannot decode. The backend selector reports UnsupportedCodec when nothing in the build decodes the codec. NoDecoder now means only that every compiled-in decoder failed to open, which is a fault. The consumer tags only UnsupportedCodec as UnsupportedConfig. Worker startup or loss reports CodecGone.

Decisions

  • Decoder-open failure boundary (maintainer, 2026-11-03):
    • (1) Keep attempt-scoped UnsupportedConfig for every local codec or backend-open refusal.
    • ✅ (2) Return UnsupportedConfig only for a known-unsupported codec, layout, or description. Typed backend faults stay distinct errors and are never folded into it.
  • Setup order around foreign broadcast resolution (maintainer, 2026-11-03):
    • ✅ (1) Keep the existing resolve-then-open shape, with one authoritative decoder open.
    • (2) A prepared decoder handle, reused after resolution, so unsupported foreign renditions fail before any origin request.

Choices made without asking, for review:

  • A hardware decoder that refuses at open, such as a GPU lacking the codec or a missing driver, counts as a fault (NoDecoder). Backends do not yet tell capability apart from device failure at open. A codec with no compiled-in decoder is a known refusal.
  • A requested output channel count with no layout is a layout refusal (UnsupportedConfig). A zero output sample rate stays Unsupported → MoqError::Audio.
  • This PR finishes quest m1/ffi-unsupported-config, so it deletes the quest file. The decisions are recorded here.

Impact

  • Adds MoqError::UnsupportedConfig { reason }, the corresponding generated language variants, and Go's ErrUnsupportedConfig. Handle this variant for decoder setup refusals previously reported as Unsupported, Audio, or Video; regenerate bindings with their native library.
  • Adds moq_audio::Error::UnsupportedConfig and moq_video::Error::UnsupportedConfig for consumer decoder setup, moq_audio::Error::Codec for libopus faults, and MOQ_ERROR_UNSUPPORTED_CONFIG (-44) to the handwritten moq-c ABI.
  • moq_video::decode::Decoder::new reports description and missing-backend refusals as UnsupportedCodec instead of Codec/Mux/NoDecoder.
  • Peer retention refusals, decoder faults, and later decode failures keep their existing errors.
  • Wire: none.

Alternatives

Retaining a codec-name allowlist cannot account for descriptions or layout support. The decoder's setup result is the capability boundary. Recognized codecs are checked once by the decoder after broadcast resolution, rather than by a disposable decoder probe or another allowlist. Refusals describe a setup attempt, so OBS retains no permanent negative capability cache.

Checks

  • just rs check-test -p moq-audio -p moq-video -p moq-ffi -p moq-c ran all 875 selected tests, all passing (11 skipped). Clippy, rustdoc, shear, sort, and hakari also passed. Its first run failed only cargo fmt; after formatting, just rs lint with the same packages passed.
  • Dart bindings regenerated with the flake's pinned uniffi-bindgen-dart (sh/dart/generate.sh under nix develop) are identical to the checked-in dart/moq_ffi/lib/src.
  • quest check --base origin/main passed.
  • Not rerun after this round: the Go, Kotlin, Python, C/C++, and OBS suites, interop, and Swift. Their sources and the FFI signatures are unchanged since the earlier just ci run; this round changed only Rust error classification, docs, and MIGRATE notes.
  • Merged origin/main at de94d96243c79bb7b86f74aaa7ec9b37ece2b3ec. The conflicts were additive MIGRATE and doc entries, and MoqDelay.min became an Option in the FFI test.

Follow-ups

  • Hardware decoder backends could report a capability refusal at open (for example, a codec profile the GPU lacks) as a refusal rather than a fault. That needs per-platform capability queries. It is not planned here.
  • Falling through after a later peer or decode failure remains out of scope.

Review

Codex / gpt-6.1-sol, medium effort, session 01a1a377-34cd-7582-b9a0-36491f045c71 (new after the design change), round 2 reviewed a960f3721f1a372d48c85737fc0e9e6cb833d123 against origin/main at de94d96243c79bb7b86f74aaa7ec9b37ece2b3ec: no findings.

Round 1 reported one finding, refuted. rs/moq-ffi/src/audio.rs:654, reject unsupported audio before resolving its broadcast: the maintainer's 2026-11-03 decision keeps resolve-then-open. That choice accepted that an unannounced foreign reference can wait before the refusal, and rules out restoring a codec allowlist ahead of resolution.

(Written by Claude Opus 5.5)

kixelated and others added 4 commits November 3, 2026 03:23
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
…back

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: tag audio decoder/output refusals inside moq-audio before subscribing, matching video's source-owned setup boundary, instead of reinterpreting every audio Unsupported at the FFI edge. Peer retention refusals keep their existing type. Video worker startup failures now use CodecGone, so infrastructure failures cannot become capability refusals.

Recognized codecs remain checked once by the decoder after broadcast resolution. OBS retries selection on each catalog update and keeps no permanent negative capability cache; a setup refusal describes that attempt, rather than promising the capability can never become available.

(Written by GPT-6.1 Sol)

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
@kixelated kixelated changed the title moq-ffi reports an unsupported media configuration as a typed error feat(ffi): type decoder setup refusals Nov 3, 2026
kixelated and others added 2 commits November 3, 2026 13:59
…config

# Conflicts:
#	cpp/moq/MIGRATE.md
#	dart/moq/MIGRATE.md
#	doc/lib/dart/index.md
#	doc/lib/go/index.md
#	doc/lib/kt/index.md
#	doc/lib/py/index.md
#	doc/lib/swift/index.md
#	go/wrapper/MIGRATE.md
#	kt/moq/MIGRATE.md
#	py/moq-rs/MIGRATE.md
#	rs/moq-audio/MIGRATE.md
#	rs/moq-ffi/MIGRATE.md
#	swift/MIGRATE.md
Return UnsupportedConfig only for a codec, layout, or description the
decoder knows it cannot handle, per the maintainer's decision. Backend
faults keep typed errors of their own: libopus allocation and internal
failures become moq_audio::Error::Codec, a multi-backend audio refusal
reports a fault instead of folding it into Unsupported, and
moq_video::Error::NoDecoder now means only that every compiled-in decoder
failed to open. Missing or malformed avcC/hvcC descriptions and codecs
with no compiled-in decoder are UnsupportedCodec. A zero output sample
rate stays a caller error.

The quest is finished, so delete it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: per the maintainer's 2026-11-03 decision (option 2), UnsupportedConfig now covers only a codec, layout, or description the decoder knows it cannot handle. Typed backend faults stay distinct and are never folded into it. Resolve-then-open is unchanged.

  • moq-video: a missing or malformed avcC/hvcC, or a codec with no compiled-in decoder, is UnsupportedCodec. The consumer tags only that variant. NoDecoder (every compiled-in decoder failed to open), CodecGone, Codec, and scale-hint errors pass through.
  • moq-audio: libopus allocation, internal, and state failures are the new Error::Codec rather than Unsupported. When several backends refuse, the selector reports a fault rather than folding it. A zero output sample rate stays Unsupported, which maps to MoqError::Audio.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review November 3, 2026 22:14
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 15 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4705a458-723d-4617-adaa-0a828c4d5cc5
Commits

Reviewing files that changed from the base of the PR and between 88115c1 and a960f37.

Files selected for processing: 45
  • c/moq/MIGRATE.md
  • cpp/moq/MIGRATE.md
  • cpp/obs/src/moq-source.cpp
  • cpp/obs/test/moq-source-test.cpp
  • dart/moq/MIGRATE.md
  • dart/moq_ffi/MIGRATE.md
  • dart/moq_ffi/lib/src/moq.dart
  • doc/bin/obs.md
  • doc/lib/c/index.md
  • doc/lib/cpp/index.md
  • doc/lib/dart/index.md
  • doc/lib/go/index.md
  • doc/lib/kt/index.md
  • doc/lib/py/index.md
  • doc/lib/swift/index.md
  • go/wrapper/MIGRATE.md
  • go/wrapper/errors.go
  • go/wrapper/errors_test.go
  • kt/moq-ffi/MIGRATE.md
  • kt/moq/MIGRATE.md
  • py/moq-ffi/MIGRATE.md
  • py/moq-rs/MIGRATE.md
  • quest/m1/README.md
  • quest/m1/ffi-unsupported-config.md
  • rs/moq-audio/MIGRATE.md
  • rs/moq-audio/src/aac.rs
  • rs/moq-audio/src/decode/backend/mod.rs
  • rs/moq-audio/src/decode/consumer.rs
  • rs/moq-audio/src/error.rs
  • rs/moq-audio/src/opus.rs
  • rs/moq-c/src/audio.rs
  • rs/moq-c/src/error.rs
  • rs/moq-c/src/video.rs
  • rs/moq-ffi/MIGRATE.md
  • rs/moq-ffi/src/audio.rs
  • rs/moq-ffi/src/error.rs
  • rs/moq-ffi/src/test.rs
  • rs/moq-ffi/src/video.rs
  • rs/moq-video/MIGRATE.md
  • rs/moq-video/src/decode/backend/mod.rs
  • rs/moq-video/src/decode/consumer.rs
  • rs/moq-video/src/decode/decoder.rs
  • rs/moq-video/src/error.rs
  • rs/moq-video/src/worker.rs
  • swift/MIGRATE.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@kixelated
kixelated added this pull request to the merge queue Nov 3, 2026

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head a960f3721f1a372d48c85737fc0e9e6cb833d123 against base 88115c13237b53d3a291e59f8addde7351e469bb.

[P2] Preserve the pending fallback across unchanged catalog updates — cpp/obs/src/moq-source.cpp:1025–1029. If top-ranked A (for example, FLAC) has already refused and playable B is still subscribing or waiting for its first frame, any catalog update creates a fresh selection starting at A. A is not the pending B, so moq_source_try_audio replaces B at line 1070 before it reaches the same-pending check for B. Repeated unrelated updates faster than B's startup can prevent audio from ever starting, whereas the previous synchronous codec gate skipped A and retained B. Preserve attempt-local refusal progress or an unchanged pending fallback while refreshing the candidate snapshot, without a permanent capability cache, and cover an update arriving between A's refusal and B's first frame.

Verification: static GitHub source review only; builds, tests, and binding regeneration were not run. The exact-head Check workflow is still in progress.

(Written by OpenAI)

@pullfrog pullfrog 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.

ℹ️ No critical issues — one classification edge worth a look.

Reviewed changes

  • Typed setup refusals. MoqError::UnsupportedConfig (UniFFI index 34, Go ErrUnsupportedConfig, C MOQ_ERROR_UNSUPPORTED_CONFIG = -44), plus moq_audio::Error::{UnsupportedConfig, Codec} and moq_video::Error::UnsupportedConfig, with decode::Consumer::new tagging only setup refusals.
  • Fault/refusal split. libopus error() separates rejected arguments from allocation/internal/state faults; audio select lets a fault outrank refusals; CodecGone for worker startup/loss; NoDecoder narrowed to "all compiled-in decoders failed to open".
  • FFI config gates. audio_config drops the Opus/AAC/Pcm allowlist so recognized-but-undecodable codecs reach the decoder; video parse/unknown is UnsupportedConfig.
  • OBS fallback. moq_source_audio_decodes deleted; async AudioSelection retries the next ranked rendition on kUnsupportedConfig, refreshes the in-flight pending selection on catalog updates, and keeps the playing track until a replacement emits.
  • Docs/bindings. MIGRATE entries per binding, doc/bin/obs.md, doc/lib/*, quest removal, and the regenerated Dart enum case.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

available: available.join(", "),
}),
kind => Err(Error::NoDecoder(format!(
kind => Err(Error::UnsupportedCodec(format!(

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.

tried.is_empty() also covers a Kind::Hardware or Kind::Software request whose only decoders for the codec exist in the other tier, so available_names(codec) is non-empty. Reporting that as UnsupportedCodec makes decode::Consumer return UnsupportedConfig, which tells a player to try another rendition even though no rendition of this codec can open at the requested kind; the message also says "nothing compiled in" while listing a compiled-in decoder.

Technical details
# Kind-restricted decoder misses are faults, not codec refusals

## Affected sites
- `rs/moq-video/src/decode/backend/mod.rs:333` — `tried.is_empty()` returns `UnsupportedCodec` for every non-`Named` kind.
- `rs/moq-audio/src/decode/backend/mod.rs:139` — same latent shape: `refused.is_empty()` returns `Unsupported` (→ `UnsupportedConfig`) when `Kind::Software` filtered out the only platform decoder. Platform list is empty today, so not yet reachable.

## Required outcome
- `UnsupportedCodec`/`UnsupportedConfig` only when the build genuinely has no decoder for the codec, i.e. `available_names(codec).is_empty()`.
- A decoder that exists but was excluded by `Kind` stays a fault (`NoDecoder`), so a player does not fall through renditions that cannot help.

## Suggested approach
```rust
return match &config.kind {
    Kind::Named(name) => Err(Error::UnknownDecoder {
        name: name.clone(),
        codec,
        available: available.join(", "),
    }),
    _ if available.is_empty() => Err(Error::UnsupportedCodec(format!(
        "nothing compiled in for {} at {kind:?}",
        codec.label(),
    ))),
    kind => Err(Error::NoDecoder(format!(
        "no {kind:?} decoder for {} (this build has: {})",
        codec.label(),
        available.join(", "),
    ))),
};
```
This changes `av1_is_supported_by_hardware_only` on hosts that compile a hardware AV1 decoder (it would become `NoDecoder`); the test's intent is the software-only case, so scope it to `available_names(Codec::Av1).is_empty()` or an equivalent fixture.

Merged via the queue into main with commit 7e4ed93 Nov 3, 2026
11 checks passed
@kixelated
kixelated deleted the quest/m1/ffi-unsupported-config branch November 3, 2026 23:04
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.

1 participant