Skip to content

feat(wma): add experimental WebRTC session apps (fal.wma) - #1153

Open
noahgsolomon wants to merge 6 commits into
mainfrom
noah/fal-wma
Open

feat(wma): add experimental WebRTC session apps (fal.wma)#1153
noahgsolomon wants to merge 6 commits into
mainfrom
noah/fal-wma

Conversation

@noahgsolomon

@noahgsolomon noahgsolomon commented Aug 26, 2026

Copy link
Copy Markdown
Member

What

Adds fal.wma — an experimental subpackage for connection-oriented WebRTC session apps (world models, live video transforms, realtime avatars). Users subclass fal.wma.App, implement create_backend(), and deploy with fal deploy; the browser connects through the WMA bridge via the wma() extension for fal.realtime.open() in @fal-ai/client (branch noah/realtime-wma in fal-js).

Ported from fal-ai/registry @ bf407fcae (noah/wma-app-contracts), whose registry.wma package was written as a forward-compatible shim for exactly this move. Two commits: ① byte-for-byte port, ② adaptations — review effort belongs on ②.

Experimental: APIs may change in a minor release. fal.wma is deliberately not exported from fal/__init__.py; import fal is unchanged.

Surface

  • App / Session / SessionParams / PeerBackend / AiortcPeer / StartSessionRequest / SessionAnswer — one POST /start-session SSE endpoint owns the session lifetime
  • RunnerIceConfig (from_bridge / from_env / from_server) + Metered TURN helpers — bring-your-own ICE for apps outside fal's managed-TURN allowlist
  • RealtimeContract → OpenAPI x-fal-realtime discovery + standalone AsyncAPI 3.1 (pydantic v2 only, lazily gated)
  • Privacy-bounded connection_report telemetry (v1)
  • Deferred billing: Session.add_billable_units()x-fal-billable-units-webhook: 1 → one settle report at close

Adaptations (commit 2)

  • Vendored registry edges, wire-identical: _errors.py (422 at body.sdp, billing-safe 500s), _ssrf.py, _request_id.py, _billing.py
  • Python 3.8 floor + pydantic v1/v2 range: typing generics at runtime-evaluated sites, fal.compat.run_in_thread, asyncio.wait_for, lazy TypeAdapter
  • wrap_app injects WMA_APP_REQUIREMENTS = ["aiortc>=1.9,<2"] into WMA runner envs (the REALTIME_APP_REQUIREMENTS pattern); detection via sys.modules, so the package loads only when imported
  • Fixes a latent wrap_app bug this made observable: cls.requirements (often the shared App.requirements default) was passed by reference and extended in place by add_requirements, leaking injected requirements across apps in one process. Now copied.

Verification

  • py3.14 + pydantic 2: 247 wma tests pass, including a real aiortc ICE negotiation end-to-end
  • py3.9.24 + pydantic 2.13.3: 239 passed / py3.8 + pydantic 1.10.18: 204 passed
  • Full unit suite 1123 passed, 0 failed (the 12 test_file_sync errors reproduce on clean main)
  • ruff format/check clean, mypy clean, make docs builds, wheel contains fal/wma/

Rollout context

  • Merge gate: noah/wma-app-contracts should land in registry first so the port source is settled; registry then swaps registry.wma internals for fal.wma re-exports (net −2k lines) after the next fal release.
  • Client: fal-js noah/realtime-wma (client 1.11.0 + server-proxy) ships alongside.
  • Access gate lives in bridge config (owner allowlist), not in this SDK.

🤖 Generated with Claude Code


Note

Medium Risk
Adds a large experimental WebRTC/billing surface and runner dependency injection; mitigations are opt-in import, input validation on ICE/billing paths, and isolated changes to wrap_app/discovery.

Overview
Introduces fal.wma, an opt-in experimental package for long-lived browser↔runner WebRTC sessions over POST /start-session (SSE answer, then held open until teardown). Subclasses implement create_backend(); the SDK supplies Session, AiortcPeer, ICE/TURN plumbing (bridge-forwarded, Metered env, or app-owned providers), wire-shaped errors, deferred session billing, connection telemetry, and optional OpenAPI/AsyncAPI contracts.

Core fal integration: wrap_app no longer mutates shared host_kwargs/requirements on the app class (fixes leaked injected deps across multiple wraps); WMA subclasses get aiortc>=1.9,<2 in the runner env unless they already pin aiortc. Module discovery ignores the re-exported fal.wma.App base so importing it does not count as a deployable app. Test extras add optional aiortc for negotiation tests.

Security-oriented behavior in the new stack includes SDP candidate filtering (SSRF), UUID validation before billing REST paths, and strict validation of forwarded/Metered ICE URLs. An aioice teardown shim reduces orphaned STUN retry noise on failed negotiations.

Reviewed by Cursor Bugbot for commit 40683d5. Bugbot is set up for automated code reviews on this repo. Configure here.

@socket-security

socket-security Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​aiortc@​1.15.0100100100100100

View full report

Comment thread projects/fal/src/fal/wma/_billing.py Dismissed
Comment thread projects/fal/src/fal/wma/_billing.py Dismissed
Comment thread projects/fal/src/fal/wma/sdk.py
Comment thread projects/fal/src/fal/wma/_billing.py

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the inline finding, I also looked at RunnerIceConfig.build_ice_servers_async mutating self.status without a lock across concurrent session negotiations (projects/fal/src/fal/wma/ice.py) — it's used purely for status reporting/telemetry, not as correctness-critical state, so a race there yields a stale reported status rather than a security or session-handling bug.

Extended reasoning...

The inline finding (AiortcPeer's initial_connect_timeout_seconds defaulting to None, silently disabling the stall-detection watchdog described in _raw.py) is a real, confirmed issue and is already flagged inline, so it is not restated here. I additionally examined the RunnerIceConfig.status mutation pattern flagged as a candidate in the prior investigation: build_ice_servers_async writes self.status at several branches (ICE_STATUS_MISCONFIGURED, ICE_STATUS_TURN, ICE_STATUS_STUN_ONLY, ICE_STATUS_UNREACHABLE) with no lock, and RunnerIceConfig instances can be shared/long-lived across concurrent sessions. Reading the surrounding code, self.status is only consumed for external status reporting (e.g. exposed for observability/telemetry), not for branching logic that would affect session correctness or security — so a race here can only produce a momentarily stale or overwritten status value, not an exploitable bug. This matches the prior ruled-out assessment and I confirm it independently from the code.

Comment thread projects/fal/src/fal/wma/sdk.py Outdated
Port the connection-oriented WMA surface from fal-ai/registry @
bf407fcae (noah/wma-app-contracts) into a self-contained experimental
subpackage. Users subclass fal.wma.App, implement create_backend(), and
deploy with `fal deploy`; the browser side is the wma() extension for
fal.realtime.open() in @fal-ai/client.

Surface: App/Session/SessionParams/PeerBackend/AiortcPeer and the
StartSessionRequest/SessionAnswer negotiation contract (one
POST /start-session SSE endpoint owns the session lifetime),
RunnerIceConfig (bridge/env/server ICE resolution) with Metered TURN
helpers, RealtimeContract rendering to OpenAPI x-fal-realtime +
AsyncAPI 3.1, bounded connection_report telemetry, and deferred billing
(Session.add_billable_units settles once at close).

Severed registry edges as private modules: _errors.py (wire-identical
422/500 shapes and billing/retry headers), _ssrf.py (vendored
is_globally_routable_ip for the ICE candidate filter), _request_id.py
(x-fal-request-id UUID canonicalization), _billing.py (deferred-billing
REST report loop).

Compatibility: importable and tested across py3.8-3.14 and pydantic
v1/v2 (typing generics at runtime-evaluated sites,
fal.compat.run_in_thread, asyncio.wait_for, lazy TypeAdapter — only
RealtimeContract rendering requires pydantic v2). aiortc stays a lazy
session-time import; wrap_app injects WMA_APP_REQUIREMENTS
(aiortc>=1.9,<2) into WMA runner envs via a sys.modules check, so
`import fal` never imports fal.wma.

Also fixes a latent wrap_app bug this made observable: cls.requirements
(often the shared App.requirements class default) was passed by
reference and extended in place by add_requirements, leaking injected
requirements across apps wrapped in the same process. Now copied.

fal.wma is experimental: APIs may change in a minor release, and it is
deliberately not exported from fal/__init__.py.
Comment thread projects/fal/src/fal/wma/sdk.py Outdated
create_default_channel: bool = True,
rtc_configuration: Any = None,
peer_connection_factory: Callable[[], Any] | None = None,
disconnected_grace_seconds: float | None = 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Default disconnect ends sessions early

Medium Severity

AiortcPeer defaults disconnected_grace_seconds to 0, so any connectionState == "disconnected" immediately sets the closed event and ends the SSE session. The raw helper documents the opposite default (None): treat disconnected as a transient ICE blip, matching client policy. The documented AiortcPeer(session, on_connect) usage therefore tears down recoverable sessions on brief network jitter.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7a50bd. Configure here.

CI runners have no globally routable interface, so with iceServers=[]
every host candidate was stripped by the SSRF filter and the test timed
out. Pin host discovery to loopback and bypass the candidate classifier
inside this one test (its strictness is pinned by dedicated tests), so
the end-to-end negotiation is deterministic and offline on any runner.
await session.close()
ice.Connection.close = original_ice_close
ice.get_host_addresses = original_get_host_addresses
_raw.is_globally_routable_ip = original_classifier

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Monkeypatches can leak across tests

Medium Severity

The new process-global patches to ice.get_host_addresses and _raw.is_globally_routable_ip (which disables the SSRF candidate filter) are applied before the try and restored only after await client.close() / await session.close(). A failure during setup or either close leaves host discovery pinned to loopback and the SSRF classifier always-true for the rest of the worker, which can cascade into unrelated ICE/security tests. Nearby aioice teardown tests restore globals in an outer finally around the whole scenario instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2399f7f. Configure here.

A module-level urllib OpenerDirector holds an SSLContext on Python
3.12+, and the whole fal package is cloudpickled by value to runners at
deploy — so any app referencing fal.wma.metered failed to serialize
with "cannot pickle SSLContext object". Mints happen at most once per
cache TTL, so per-call construction costs nothing measurable.
# the intermediate WMA base's inherited value ("app"), every concrete
# subclass ignores both ``name=...`` and its own class-derived default.
cls.app_name = None
super().__init_subclass__(**kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Subclass app_name cleared too aggressively

Medium Severity

The new __init_subclass__ always assigns cls.app_name = None before calling fal.App.__init_subclass__. That fixes the poisoned inherited "app" from the WMA base, but also wipes a subclass app_name ClassVar and any name inherited from an intermediate WMA base. fal.App normally preserves those via getattr(cls, "app_name") or app_name, so WMA apps can silently deploy under the wrong name when using the ClassVar or inheritance patterns that work for plain fal.App.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41f097f. Configure here.

sdk.py carried `from __future__ import annotations`, which stringifies
every annotation. fal apps are cloudpickled to runners, and cloudpickle
ships only the globals referenced by code — so the endpoint signature's
names (Optional, the request model) never reached the runner, FastAPI's
dependency resolution hit an unresolvable ForwardRef, and every
fal.wma.App /start-session answered 500 at the first request (verified
against a live deployment; works locally because the module is really
imported there). The registry original omitted the future import for
exactly this reason.

Annotations are now real objects evaluated at def time: typing forms
where 3.8 can evaluate them, explicit strings for runtime-unsafe
subscripts (asyncio.Task) in non-FastAPI positions. A regression test
pins that no start_session annotation is a string.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 40683d5. Configure here.

request: StartSessionRequest,
*,
caller_user_id: Optional[str] = None,
request_id: Optional[str] = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Constructors crash on older Python

High Severity

Dropping postponed annotation evaluation makes instance-attribute hints run at construction time. Several self.* annotations still use PEP 585 subscripting and PEP 604 unions, so creating Session, SessionParams, or AiortcPeer raises TypeError on the SDK's Python 3.8 and 3.9 floor. Function signatures were converted; these attribute annotations were not.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40683d5. Configure here.

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.

2 participants