Skip to content

Add asynchronous curl backends for cats-effect and fs2 (curl_multi_socket_action) - #3021

Closed
lolgab wants to merge 3 commits into
softwaremill:masterfrom
lolgab:curl-async-backend
Closed

lolgab wants to merge 3 commits into
softwaremill:masterfrom
lolgab:curl-async-backend

Conversation

@lolgab

@lolgab lolgab commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds two asynchronous Scala Native curl backends which never block a thread on network I/O:

  • CurlCatsAsyncBackend (cats module): Backend[IO].
  • CurlFs2Backend (fs2 module): StreamBackend[IO, Fs2Streams[IO]], additionally supporting streaming response bodies (with backpressure) and stream request bodies (buffered in memory).

Both are created as a Resource (CurlCatsAsyncBackend.resource(), CurlFs2Backend.resource()).

How it works

  • libcurl's multi socket interface (curl_multi_socket_action + socket/timer callbacks). The sockets curl waits on are registered with cats-effect's FileDescriptorPoller (epoll on Linux, kqueue on macOS); curl's timeouts use IO.sleep. curl shares the event loop with the rest of the application.
  • A multi handle isn't thread-safe, so each is guarded by a fiber-aware Mutex[IO] (waiting fibers are suspended, no thread is blocked), held only for the short non-blocking libcurl calls. Requests are spread over one multi handle per available processor in multi-threaded mode (one otherwise).
  • epoll is edge-triggered, so after each socket_action the socket is re-checked with poll(fd, 0) and curl is driven while it stays ready.
  • Streaming responses: the write callback pushes chunks to a buffer; above ~256 KB it returns CURL_WRITEFUNC_PAUSE, and the transfer is resumed with curl_easy_pause once the consumer drains it below ~64 KB.
  • Requires a FileDescriptorPoller in the runtime (the default IORuntime has one on Linux and macOS, not e.g. on Windows): creating the backend fails with CE's "No FileDescriptorPoller installed" error otherwise. There is no blocking fallback.
  • catsNative gets cats-effect (not only kernel/std) as a compile dependency, as FileDescriptorPoller is reached through IO.pollers.

Changes to the shared curl code

  • GenericCurlBackend[F, P] (parametrized by the capabilities) with hooks for streaming responses and stream request bodies; AbstractCurlBackend extends it with no capabilities.
  • cleanupCurl hook: easy handles sharing a multi handle must not be cleaned up concurrently with other calls into it (this caused crashes). Handles are now also cleaned up (and the response buffers freed) when a request fails or is cancelled; previously they leaked.

Bugs fixed in the shared code (found by running the real test suites, see below), which also affect the existing curl backends:

  • send allocated the easy handle, zone and headers when called, not when the returned effect is run, and freed them after the first run. Running an effect twice (retries) or in parallel used freed memory. It is now lazy.
  • Binary request bodies went through a String: the declared Content-Length didn't match what was sent, which corrupted keep-alive connections (the next request on the connection failed). Bodies are now sent as exact bytes with an explicit size.
  • Binary response bodies were corrupted (and truncated at zero bytes). The raw body is now kept as ISO-8859-1, byte for byte.

Tests

ScalaTest on Scala Native ignores the result of tests which return a Future: a failing assertion inside a Future doesn't fail the test (even Future.successful(1).map(_ shouldBe 2) passes). So the existing IO-based Native suites, including CurlCatsHttpTest, never actually verified assertions. BlockingConvertToFuture (test code) runs the IO to completion and fails synchronously; I checked that it catches deliberately wrong assertions. It is applied to CurlCatsHttpTest, CurlCatsAsyncHttpTest, CurlFs2HttpTest and CurlFs2StreamingTest.

For HttpTest on Native, two adjustments are needed (CurlHttpTestOverrides): shouldBe Symbol("empty") needs reflection, and per-part multipart charsets aren't supported (supportsCustomMultipartEncoding = false).

New: CurlCatsAsyncHttpTest, CurlFs2HttpTest, CurlFs2StreamingTest (the shared StreamingTest, plus a 4 MB response consumed slowly, which pauses and resumes the transfer).

Docs

docs/backends/native/curl.md: sections for both backends and their limitations.

Limitations

  • WebSockets are not supported (as with the other curl backends).
  • asInputStream still uses the blocking curl_multi_perform/curl_multi_poll implementation.
  • Stream request bodies are read into memory before sending.

Open questions

  • Is a separate Native-only artifact preferable to adding cats-effect to the cats module (and the fs2 backend to the fs2 module)? fs2 already adds fs2-io for Native, so I followed that.

Verification

Run on the fork (lolgab/sttp): compile and tests pass on all Scala versions × JVM/JS/Native, including the Native suites on Linux (epoll). The only failing CI step on the fork is Prepare release notes on JDK 17, because the fork's default branch has no .github/release-drafter.yml.

lolgab added 3 commits October 9, 2026 13:14
…_action

Integrates libcurl's multi socket interface with the cats-effect
FileDescriptorPoller (epoll/kqueue), so no thread blocks on network I/O.
Requests are spread over several multi handles in multi-threaded mode.
…hronized

A monitor blocks compute worker threads under contention. A fiber-aware
Mutex suspends the waiting fibers instead.
@lolgab

lolgab commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Closing for now, will reopen when ready.

@lolgab lolgab closed this Oct 9, 2026
@lolgab lolgab changed the title Add asynchronous Curl backend for cats-effect (curl_multi_socket_action) Add asynchronous curl backends for cats-effect and fs2 (curl_multi_socket_action) Oct 10, 2026
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