Skip to content

database: replace uWebSockets and libuv with libcurl WebSockets - #1959

Merged
AustinBenoit merged 10 commits into
mainfrom
libcurl-websockets
Nov 12, 2026
Merged

AustinBenoit merged 10 commits into
mainfrom
libcurl-websockets

Conversation

@AustinBenoit

@AustinBenoit AustinBenoit commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Replaces the Realtime Database desktop WebSocket transport (uWebSockets v0.14 and libuv v1.33.1) with libcurl's WebSocket API (CURLOPT_CONNECT_ONLY = 2L, curl_ws_recv(), curl_ws_send()), removing two unmaintained third-party dependencies.

1. Dependency removal

  • Removes uWebSockets and libuv from CMakeLists.txt, cmake/external/*, cmake/external_rules.cmake, README.md, scripts/gha/print_matrix_configuration.py, release_build_files/NOTICES, and the uWebSockets patch under scripts/git/patches/.
  • Windows apps no longer need to link psapi or userenv for firebase_database.
  • Requires libcurl 8.14.0 or newer with WebSocket support (static_assert in app/rest/curl/curl_handles.h).

2. WebSocketClientImpl (database/src/desktop/connection/)

  • Same threading model as before: one I/O thread per client driving a private CURLM* with curl_multi_poll() and curl_multi_wakeup(). Handler callbacks are marshalled onto the Database scheduler through a SafeReference.
  • Non-blocking HTTP 101 upgrade with an explicit handshake deadline, because libcurl's connect timeout does not cover the Upgrade exchange.
  • RFC 6455 framing with send backpressure, explicit PING/PONG handling (CURLWS_NOAUTOPONG) that never splices a control frame into a partially sent message, a 125-byte control-frame cap and a 256 MiB message cap.
  • Every curl_easy_setopt() is checked; a rejected option fails the connect through OnError().
  • The idle poll timeout is 60 s. A failed curl_multi_wakeup() is logged and switches the loop to a 50 ms poll.
  • A curl_multi_init() failure leaves the client inert and Connect() reports OnError() instead of aborting.
  • ReceiveFrames() handles at most 256 frames per pass so a peer that streams continuously cannot starve Close(), Send() or teardown.
  • CURLM error codes are honoured: a failing curl_multi_perform() during the handshake reports OnError(), and a failing curl_multi_poll() backs off 100 ms instead of spinning.
  • CURLMOPT_QUICK_EXIT is deliberately not set. It detaches in-flight resolver threads, which then outlive curl_global_cleanup() in Database-only processes. The destructor can therefore wait for an in-flight getaddrinfo().

3. Connection::OnError closes synchronously, and the crash that exposed

Connection::OnError() used to schedule Close() through a second scheduler hop, so mid-session errors were reported as kDisconnectReasonConnectionLost. It now calls Close() directly and reports kDisconnectReasonWebsocketError.

That exposed a latent bug. Close() calls ConnectionEventHandler::OnDisconnect(), and PersistentConnection destroys the Connection there (realtime_.reset()) while the handler still holds a SafeReferenceLock taken through the member safe_this_. ~Mutex() then ran pthread_mutex_destroy() on a locked mutex, which asserts and aborts on Linux and macOS and is a use-after-free on Windows. Before OnOpen() nothing else holds a reference, so every failed connect or reconnect attempt aborted.

OnOpen, OnMessage, OnClose and OnError now lock a local copy of the SafeReference, so the reference data outlives the lock even when the handler destroys this.

Regression tests in connection_test.cc: DestroyFromOnDisconnectAfterConnectRefused (loopback port with no listener) and DestroyFromOnDisconnectAfterDnsFailure (.invalid TLD). The first aborts the test binary without the fix.

4. Shared libcurl primitives (app/rest/curl/)

  • curl_handles.{h,cc}: RAII deleters, ApplyOpt() for checking batches of curl_easy_setopt(), MSVC #pragma comment(lib) directives, and a refcounted AcquireCurlGlobal() / ReleaseCurlGlobal() shared by transport_curl.cc, util.cc and the WebSocket client. The refcount mutex is leaked on purpose so release during static destruction is safe.
  • tls_policy.{h,cc}: one TLS policy for REST and WebSockets. VERIFYPEER=1, VERIFYHOST=2, TLS 1.2 minimum, and OS trust stores (Schannel on Windows; OpenSSL/BoringSSL with USE_APPLE_SECTRUST on macOS; the distribution CA store on Linux). The SDK does not configure revocation checking.
    • Linux fix, affects every desktop product. libcurl compiles the build host's CA bundle path into CURL_CA_BUNDLE, so the packaged Linux SDK failed with CURLE_SSL_CACERT_BADFILE on RHEL, Fedora and openSUSE. The bundle and directory are now resolved once per process at runtime: CURL_CA_BUNDLE, SSL_CERT_FILE, then the standard distribution paths; SSL_CERT_DIR, then /etc/ssl/certs. Honouring the environment variables is a deliberate choice; libcurl itself does not read them.
    • The same policy is applied to the TLS leg to an HTTPS proxy (CURLOPT_PROXY_*).
    • The environment variables are read with secure_getenv() on glibc, so setuid/setgid processes ignore them.
    • Unit tests: app/rest/tests/tls_policy_test.cc.

5. Supporting fixes

  • Logger::LogMessageImplV tolerates a null parent_logger_.
  • DatabaseInternal::persistence_enabled_ is default-initialized.
  • build_scripts/desktop/package.sh: in sequential mode, all merge_<product>.sh scripts are generated before any runs. Previously merge_app.sh with -D deleted libcurl.a before merge_database.sh resolved its --hide_c_symbols list, which left the curl_* references in libfirebase_database.a un-renamed. This path is reached whenever parallel is missing and -j falls back to sequential.

6. Connection::Send frame count (bug that predates this PR)

Connection::Send announced len / 16384 + 1 frames but sent ceil(len / 16384). For a serialized request that is an exact multiple of 16384 bytes the server waited for a frame that never came and attached the next request to the incomplete one. The count is now a ceiling division. ConnectionTest.MessageOfExactlyMaxFrameSizeMultiple fails on the old formula.

Behaviour changes

  • Realtime Database connections honour the same proxy environment variables as the REST transport. uWebSockets ignored them.
  • Linux: the CA bundle and directory are discovered at runtime; see section 4.
  • Mid-session socket errors are reported as kDisconnectReasonWebsocketError rather than kDisconnectReasonConnectionLost. The reason is only logged.

Verification

Linux x86_64, GCC 15.2, system OpenSSL 3.6:

  • firebase_rtdb_desktop_connection_web_socket_client_impl_test: 9/9.
  • firebase_rtdb_desktop_connection_connection_test: 12/12 against the live backend.
  • firebase_app_rest_tls_policy_test: 10/10.
  • All three suites are clean under ASan, UBSan and LSan.
  • TSan on the two Realtime Database suites, three runs: no reports in this PR's code or in libcurl. Three reports remain in App teardown against the heartbeat thread (app/src/app_common.cc, app/src/heartbeat/), which this PR does not touch.
  • Packaging, Release build of app and database: package.sh -D sequential (with parallel hidden) and -D -j both leave no un-renamed curl_* symbols in libfirebase_database.a. The package.sh on main leaves 18 in sequential mode.
  • GitHub Actions Desktop CI across Linux (ubuntu-22.04 x64/x86 Debug & Release + local-integration-tests-linux-openssl), macOS (macos-26 arm64 Debug & Release), and Windows (windows-2022 x64 Release static — 105/105 CTest suites passed — and x86 Debug dynamic): all 10 jobs green.
  • GitHub Actions Integration Tests (tests: succeeded): Desktop (Linux & Windows OpenSSL), Android (FTL real & emulator), iOS (device & simulator), and tvOS (simulator) all green.

Notes on manual checks:

  • Offline / DNS-failure connect behavior is exercised automatically by ConnectionTest.DestroyFromOnDisconnectAfterConnectRefused and ConnectionTest.DestroyFromOnDisconnectAfterDnsFailure across Linux, macOS, and Windows CTest runs.
  • A full link of a Realtime Database app against the packaged libraries needs a build that includes Firestore, because that is where package.sh picks up leveldb.

Follow-ups

  • Make SafeReferenceLock hold a shared_ptr to the reference data, and add safe_reference_test.cc. The same member-lock pattern remains in PersistentConnection, Repo, DatabaseInternal, RemoteConfig and UserSecureManager.
  • Connection::OnConnectionShutdown() reads client_ after OnKill() may have destroyed the Connection.
  • PersistentConnection::TryScheduleReconnect() has no backoff.
  • Repo::~Repo() decrements the shared scheduler refcount even when the constructor returned early on a bad URL.
  • Server frames with unexpected types reach FIREBASE_ASSERT or a null dereference instead of kDisconnectReasonProtocolError.
  • The server-supplied session id is put into the reconnect URL unescaped.
  • No closing handshake on client Close(), no progress timeout on a stalled send queue, and no test of the partial-write path.
  • The destructor can wait for an in-flight DNS lookup. Fixing that needs a decision about whether the SDK should ever call curl_global_cleanup().

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request replaces the legacy uWebSockets and libuv WebSocket transport in the desktop Realtime Database SDK with a native libcurl-based implementation. It removes the libuv and uWebSockets dependencies entirely, centralizes the TLS policy across REST and WebSocket connections, and introduces a robust set of unit and integration tests. The reviewer identified a potential memory leak in web_socket_client_impl.cc where request_headers_.release() is passed directly to curl_slist_append; if the append fails, the original list is leaked. A safer helper lambda was suggested to handle appending safely.

Comment thread database/tests/CMakeLists.txt Resolved
Comment thread app/rest/curl/tls_policy.cc Resolved
Comment thread app/rest/curl/tls_policy.cc Resolved
Comment thread app/rest/util.cc Resolved
- Drop unmaintained external dependencies uWebSockets (v0.14) and libuv
  (v1.40) from CMake, external rules, patches, and release NOTICES.
- Migrate Realtime Database WebSocket client (WebSocketClientImpl) to
  libcurl's native WebSocket support (curl_ws_send, curl_ws_recv,
  CURLOPT_CONNECT_ONLY = 2L).
- Give each WebSocket connection its own lightweight event thread and private
  CURLM* handle using curl_multi_poll() and curl_multi_wakeup().
- Add stalled handshake deadline watchdog (5000 ms default).
- Centralize SDK transport security in app/rest/curl/tls_policy.{h,cc}
  (TLS 1.2+ minimum, peer/hostname verification, and native OS certificate
  trust store via Schannel on Windows, Keychain on macOS, and runtime CA bundle
  detection on Linux).
- Add thread-safe reference-counted AcquireCurlGlobal() / ReleaseCurlGlobal()
  and RAII handle deleters in app/rest/curl/curl_handles.{h,cc}.
- Initialize DatabaseInternal::persistence_enabled_ to false to prevent
  reading uninitialized member variables on desktop platforms.
- Guard Logger::LogMessageImplV when parent_logger_ is null.
- Fix package.sh symbol hiding script generation for libfirebase_database.a.
- Add local loopback RFC 6455 WebSocket unit tests (8/8 passing).
@AustinBenoit AustinBenoit added the tests-requested: quick Trigger a quick set of integration tests. label Nov 4, 2026
@github-actions github-actions Bot added tests: in-progress This PR's integration tests are in progress. and removed tests-requested: quick Trigger a quick set of integration tests. labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

❌  Integration test FAILED

Requested by @AustinBenoit on commit 5090147
Last updated: Thu Nov 12 17:02 PST 2026
View integration test log & download artifacts

Failures Configs
app_check [TEST] [FAILURE] [iOS] [macos] [1/2 ios_device: simulator_target]
(6 failed tests)  FirebaseAppCheckTest.TestAddTokenChangedListener
  FirebaseAppCheckTest.TestGetLimitedUseAppCheckToken
  FirebaseAppCheckTest.TestGetTokenForcingRefresh
  FirebaseAppCheckTest.TestGetTokenLastResult
  FirebaseAppCheckTest.TestRemoveTokenChangedListener
  FirebaseAppCheckTest.TestSignIn
remote_config [TEST] [FLAKINESS] [Android] [1/3 os: ubuntu] [1/2 android_device: android_target]
(1 failed tests)  FirebaseRemoteConfigTest.TestAddOnConfigUpdateListener

Add flaky tests to go/fpl-cpp-flake-tracker

@AustinBenoit

AustinBenoit commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Triggered Firebase Unity SDK build & integration tests against firebase_cpp_sdk_version=libcurl-websockets (32dac605):

@github-actions github-actions Bot added the tests: failed This PR's integration tests failed. label Nov 4, 2026
@firebase-workflow-trigger firebase-workflow-trigger Bot removed the tests: in-progress This PR's integration tests are in progress. label Nov 4, 2026
…estroy the Connection

Connection::OnError() now calls Close() synchronously. Close() invokes
ConnectionEventHandler::OnDisconnect(), and PersistentConnection destroys the
Connection from inside that callback (realtime_.reset()). The callback locked
the SafeReference through the member, so ~Connection() freed the ReferenceData
while its mutex was still held and ~Mutex() ran pthread_mutex_destroy() on a
locked mutex: FIREBASE_ASSERT -> abort() on Linux/macOS, use-after-free on
Windows. Any failed connect or reconnect attempt (DNS failure, refused
connection, handshake timeout) triggered it because no keep-alive callback
exists before OnOpen() to hold a second reference.

Lock a local copy of the SafeReference in OnOpen/OnMessage/OnClose/OnError so
the reference data outlives the lock even when the handler destroys this.

Add regression tests that own the Connection the way PersistentConnection does
and destroy it from OnDisconnect() after a refused loopback connect and after a
DNS failure. Both abort without the fix.
- Check every curl_easy_setopt() in ConnectOnLoop() via a shared ApplyOpt()
  helper (moved from tls_policy.cc into curl_handles.h) and fail the connect
  with OnError() if any option is rejected.
- Raise kPollTimeoutMs from 1 s to 60 s: curl_multi_poll() honors libcurl's
  own timers and the handshake deadline is applied separately, so the timeout
  is only a fallback for a failed curl_multi_wakeup(). Log when that call
  fails since the fallback is then the only thing that wakes the loop.
- Leak the curl global-init mutex (as util.cc does) so ReleaseCurlGlobal()
  is safe during static destruction.
- Log curl_ws_send() failures at Debug like curl_ws_recv() failures; both are
  ordinary network drops.
- Drop the duplicate persistence_enabled_ initializer, clarify the thread_
  and PING-validation comments.
- Restore TestEdgeCase2 to a time-boxed loop (>= 500 iterations, <= 10 s or
  10000 iterations) instead of a fixed 50, and add
  DestroyClientFromOwnErrorCallback, which destroys the client from inside
  its own scheduled OnError() the way the Realtime Database does.
…_test

LinuxCaBundlePath()/LinuxCaDirectoryPath() ran getenv()/stat()/access() on
every REST request and WebSocket connect and returned a pointer into the
environment block. Resolve once per process into a leaked std::string and
return that; the uncached probes are exposed as internal::Resolve*() so the
precedence (CURL_CA_BUNDLE, SSL_CERT_FILE, distro paths; SSL_CERT_DIR,
FIREBASE_SSL_CAPATH) can be unit-tested.

Trim the CMake and tls_policy.h comments to what the configuration actually
guarantees. libcurl 8.23.0 uses SCH_CRED_REVOCATION_CHECK_CHAIN (not
_EXCLUDE_ROOT) with Schannel, Apple SecTrust revocation is best-effort
(kSecRevocationUseAnyAvailableMethod), and the OpenSSL backend performs no
revocation checks, so the previous claims about enforced OCSP/CRL were
inaccurate. Document that the environment variables are a deliberate choice
(libcurl itself does not read them) and must be set before first use.

Add app/rest/tests/tls_policy_test.cc covering ApplyTlsPolicy() success and
error reporting, cache stability, and the Linux env-var precedence rules.
…ersarial review)

WebSocketClientImpl:
- Set CURLMOPT_QUICK_EXIT so curl_multi_cleanup() does not join libcurl's
  resolver thread pool; ~WebSocketClientImpl runs on the Database scheduler
  thread and could otherwise stall for a full DNS timeout when a connection
  is torn down mid-resolve.
- curl_multi_init() failure (e.g. fd exhaustion) no longer aborts the process:
  the client stays inert and Connect() reports OnError().
- Cap ReceiveFrames() at 256 frames per pass and re-poll without blocking
  (receive_backlog_) so a peer streaming frames continuously cannot starve
  posted work (Close, Send, destructor teardown).
- Honor CURLM return codes: a failing curl_multi_perform() during the
  handshake reports OnError(); a failing curl_multi_poll() backs off 100 ms
  instead of spinning.
- Once curl_multi_wakeup() has failed, poll with a 50 ms timeout so Post()
  latency stays bounded without a working wakeup.

Connection::Send: announce ceil(len / kMaxFrameSize) frames. The old
len / kMaxFrameSize + 1 announced one frame too many for payloads that are an
exact multiple of 16384 bytes, so the server waited for a frame that never
came and glued the next message onto the incomplete one.

tls_policy: apply the same verification, TLS 1.2 floor and CA location to the
HTTPS-proxy leg (CURLOPT_PROXY_*), and read the CA environment variables with
secure_getenv() so AT_SECURE (setuid/setgid) processes ignore them.

connection.h: correct the threading comment; the WebSocket callbacks do run
on the scheduler thread and may destroy the Connection.
- connection_test target: define WIN32_LEAN_AND_MEAN and link ws2_32 on MSVC;
  the new GetClosedLoopbackPort() includes <winsock2.h> after SDK headers that
  include <windows.h>.
- Add MessageOfExactlyMaxFrameSizeMultiple (live): fails without the
  Connection::Send frame-count fix.
- DestroyFromOnDisconnect* tests: own the Logger in the fixture and never reset
  the Connection from the main thread, so a timeout cannot race the scheduler.
- RejectsNonWebSocketUriSchemes: point http:// at a listening echo server and
  assert it accepted zero connections (plus a ws:// control), so the test no
  longer passes merely because the port was refused.
- TestEdgeCase2: drop the tautological EXPECT_GE(count, kMinIterations).
- tls_policy_test: replace the test that could never reach the null-bundle
  path with one that pins the libcurl contract it relied on (null CAINFO /
  CAPATH are accepted).
…ndling

Covers the user-visible changes in the two adversarial-review commits; drop
this commit together with them if they move to a follow-up PR.
8fd3f94 set CURLMOPT_QUICK_EXIT so ~WebSocketClientImpl would not wait for
an in-flight DNS lookup. With it, curl_multi_cleanup() detaches running
resolver threads (Curl_thrdpool_destroy, join == false) instead of joining
them. In Database-only processes the libcurl global refcount drops to zero
between connections, so those detached threads kept running across
curl_global_cleanup() and the next curl_global_init(). ThreadSanitizer
reported the resulting races on libcurl's allocator globals
(Curl_getaddrinfo_ex and thrdslot_destroy vs. global_init) in
DestroyFromOnDisconnectAfterDnsFailure and TestCreateDestroyRace.

Go back to joining. The destructor can again wait for getaddrinfo() to
return when a connection is torn down mid-resolve; that is the behaviour of
the original change and is preferable to libcurl threads outliving
curl_global_cleanup().
@AustinBenoit AustinBenoit added the tests-requested: quick Trigger a quick set of integration tests. label Nov 10, 2026
@github-actions github-actions Bot added tests: in-progress This PR's integration tests are in progress. tests: failed This PR's integration tests failed. and removed tests-requested: quick Trigger a quick set of integration tests. tests: failed This PR's integration tests failed. labels Nov 10, 2026
@firebase-workflow-trigger firebase-workflow-trigger Bot removed the tests: in-progress This PR's integration tests are in progress. label Nov 10, 2026
@github-actions github-actions Bot added tests: succeeded This PR's integration tests succeeded. and removed tests: failed This PR's integration tests failed. labels Nov 10, 2026
a-maurice

This comment was marked as outdated.

@AustinBenoit
AustinBenoit merged commit 5090147 into main Nov 12, 2026
39 checks passed
@github-actions github-actions Bot added tests: in-progress This PR's integration tests are in progress. and removed tests: succeeded This PR's integration tests succeeded. labels Nov 12, 2026
@AustinBenoit
AustinBenoit deleted the libcurl-websockets branch November 12, 2026 23:49
@github-actions github-actions Bot added the tests: failed This PR's integration tests failed. label Nov 13, 2026
@firebase-workflow-trigger firebase-workflow-trigger Bot removed the tests: in-progress This PR's integration tests are in progress. label Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: core api: database cla: yes dependencies Pull requests that update a dependency file tests: failed This PR's integration tests failed. type: cleanup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants