diff --git a/build.sbt b/build.sbt index e4272d549..79d031f38 100644 --- a/build.sbt +++ b/build.sbt @@ -432,7 +432,10 @@ lazy val cats = (projectMatrix in file("effects/cats")) ) .nativePlatform( scalaVersions = scala2And3, - settings = commonNativeSettings ++ testServerSettings + settings = commonNativeSettings ++ testServerSettings ++ Seq( + // the async curl backend uses IO and the runtime's FileDescriptorPoller (epoll/kqueue) + libraryDependencies += "org.typelevel" %% "cats-effect" % catsEffect_3_version + ) ) lazy val fs2Ce2 = (projectMatrix in file("effects/fs2-ce2")) diff --git a/core/src/main/resources/scala-native/ffi.c b/core/src/main/resources/scala-native/ffi.c index 549209711..65c66061b 100644 --- a/core/src/main/resources/scala-native/ffi.c +++ b/core/src/main/resources/scala-native/ffi.c @@ -9,6 +9,25 @@ const char* sttp_curl_get_version() { } int sttp_curl_getinfo_pointer(CURL *curl, CURLINFO info, void* arg) {return curl_easy_getinfo(curl, info, arg); } +/* curl_multi_setopt is variadic, so it can't be bound directly */ +int sttp_curl_multi_setopt_pointer(CURLM *multi, CURLMoption opt, void *arg) { return curl_multi_setopt(multi, opt, arg); } +int sttp_curl_multi_setopt_long(CURLM *multi, CURLMoption opt, long arg) { return curl_multi_setopt(multi, opt, arg); } + +/* Non-blocking readiness check of a socket: want = 1 (read), 2 (write), 3 (both). + Returns non-zero if the socket is ready (or in an error/hangup state). Used to drain edge-triggered notifications. */ +#ifdef _WIN32 +int sttp_curl_fd_ready(int fd, int want) { return 1; } +#else +#include +int sttp_curl_fd_ready(int fd, int want) { + struct pollfd p; + p.fd = fd; + p.events = ((want & 1) ? POLLIN : 0) | ((want & 2) ? POLLOUT : 0); + p.revents = 0; + return poll(&p, 1, 0) > 0 && p.revents != 0; +} +#endif + /* curl_multi wrappers */ /* Most curl_multi functions are called directly via @extern bindings. This wrapper exists because CURLMsg contains a union that can't be diff --git a/core/src/main/scalanative/sttp/client4/curl/internal/CCurl.scala b/core/src/main/scalanative/sttp/client4/curl/internal/CCurl.scala index 6a28a8b0e..a1d4934b3 100644 --- a/core/src/main/scalanative/sttp/client4/curl/internal/CCurl.scala +++ b/core/src/main/scalanative/sttp/client4/curl/internal/CCurl.scala @@ -118,4 +118,17 @@ private[curl] trait CCurl { // Via C wrapper (union extraction) — see ffi.c @name("sttp_curl_multi_info_read_result") def multiInfoReadResult(multi: Ptr[CurlM], easy_out: Ptr[Ptr[Curl]]): CInt = extern + + // Multi socket interface + @name("curl_multi_socket_action") + def multiSocketAction(multi: Ptr[CurlM], sockfd: CInt, evBitmask: CInt, runningHandles: Ptr[CInt]): CInt = extern + + @name("sttp_curl_multi_setopt_pointer") + def multiSetoptPtr(multi: Ptr[CurlM], option: CInt, parameter: Ptr[_]): CInt = extern + + @name("sttp_curl_multi_setopt_long") + def multiSetoptLong(multi: Ptr[CurlM], option: CInt, parameter: Long): CInt = extern + + @name("sttp_curl_fd_ready") + def fdReady(fd: CInt, want: CInt): CInt = extern } diff --git a/docs/backends/native/curl.md b/docs/backends/native/curl.md index 774404fde..519ff4271 100644 --- a/docs/backends/native/curl.md +++ b/docs/backends/native/curl.md @@ -64,4 +64,58 @@ object Main extends ZIOAppDefault: res <- basicRequest.get(uri"http://httpbin.org/ip").send(backend) _ <- Console.printLine(res) yield () -``` \ No newline at end of file +``` + +## Cats Effect-based (asynchronous) + +To use in an sbt project, add the following dependency: + +``` +"com.softwaremill.sttp.client4" %%% "cats" % @VERSION@ +``` + +`CurlCatsAsyncBackend` is a non-blocking backend, which uses libcurl's +[multi socket interface](https://curl.se/libcurl/c/libcurl-multi.html) (`curl_multi_socket_action`). libcurl reports +which sockets it wants to wait on and for how long; the backend delegates the waiting to the Cats Effect runtime's +polling system (epoll on Linux, kqueue on macOS). No thread is blocked on network I/O, and curl shares the event loop +with the rest of the application. + +```scala +//> using platform native +//> using scala 3 +//> using dep com.softwaremill.sttp.client4::cats::@VERSION@ + +import cats.effect.{IO, IOApp} +import sttp.client4.* +import sttp.client4.curl.cats.CurlCatsAsyncBackend + +object Main extends IOApp.Simple: + def run: IO[Unit] = + CurlCatsAsyncBackend.resource().use { backend => + basicRequest.get(uri"http://httpbin.org/ip").send(backend).flatMap(IO.println) + } +``` + +`CurlCatsAsyncBackend()` creates a backend directly; it should be closed after use. The `resource()` variant closes it +for you. + +### Threading + +libcurl multi handles are not thread-safe, so each one is guarded by a fiber-aware mutex (fibers waiting for it are suspended, no thread is +blocked), held only for the short, non-blocking +libcurl calls. When Scala Native multithreading is enabled, requests are spread over several multi handles to avoid +contention; the number can be set with the `parallelism` parameter (defaults to the number of available processors, or +`1` in single-threaded mode): + +```scala +CurlCatsAsyncBackend.resource(parallelism = 2) +``` + +### Requirements + +The backend relies on the `FileDescriptorPoller` of the runtime. The default `IORuntime` on Scala Native provides one +(epoll/kqueue); if a custom runtime doesn't, each transfer is driven from the blocking thread pool instead. + +Limitations: WebSockets and `Stream`-based responses are not supported, as with the other curl backends. Responses read +using `asInputStream` work, but they are driven by the shared blocking implementation (`curl_multi_perform` + +`curl_multi_poll` on the calling thread), so they are not asynchronous. diff --git a/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsAsyncBackend.scala b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsAsyncBackend.scala new file mode 100644 index 000000000..2d646e3a3 --- /dev/null +++ b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlCatsAsyncBackend.scala @@ -0,0 +1,101 @@ +package sttp.client4.curl.cats + +import cats.effect.{FileDescriptorPoller, IO, Resource} +import sttp.client4.Backend +import sttp.client4.curl.AbstractCurlBackend +import sttp.client4.curl.internal.CurlApi._ +import sttp.client4.curl.internal.{CurlApi, CurlCode, CurlMCode} +import sttp.client4.impl.cats.CatsMonadError +import sttp.client4.wrappers.FollowRedirectsBackend + +import java.util.concurrent.atomic.AtomicInteger +import scala.scalanative.libc.stdlib +import scala.scalanative.meta.LinktimeInfo +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +/** A curl backend which never blocks a thread on network I/O. + * + * It uses libcurl's multi socket interface (`curl_multi_socket_action`): libcurl reports which sockets it wants to + * wait on and for how long, and the backend delegates the waiting to the cats-effect runtime's polling system + * (epoll on Linux, kqueue on macOS), so curl shares the event loop with the rest of the application. + * + * libcurl multi handles aren't thread-safe, so each handle is guarded by a fiber-aware mutex (waiting fibers are + * suspended, no thread is blocked), which is only held for the short, non-blocking libcurl calls. In multi-threaded mode, requests are spread over `parallelism` multi handles to avoid + * contention; in single-threaded mode, a single handle is used. + * + * If the runtime has no [[FileDescriptorPoller]] (e.g. a custom `IORuntime` with the default sleep-based polling + * system), the backend falls back to driving each transfer from the blocking thread pool. + */ +class CurlCatsAsyncBackend private (drivers: Array[CurlMultiDriver], verbose: Boolean) + extends AbstractCurlBackend[IO](new CatsMonadError[IO], verbose) + with Backend[IO] { + + private val next = new AtomicInteger(0) + + override protected def performCurl(c: CurlHandle): IO[CurlCode.CurlCode] = + FileDescriptorPoller.find.flatMap { + case Some(poller) => + val driver = drivers(Math.floorMod(next.getAndIncrement(), drivers.length)) + driver.perform(poller, c).map(CurlCode(_)) + case None => CurlCatsAsyncBackend.performBlocking(c) + } + + override def close(): IO[Unit] = drivers.foldLeft(IO.unit)(_ *> _.close) +} + +object CurlCatsAsyncBackend { + + /** The default number of multi handles: one per available processor in multi-threaded mode, one otherwise. */ + def defaultParallelism: Int = + if (LinktimeInfo.isMultithreadingEnabled) Math.max(1, Runtime.getRuntime.availableProcessors()) else 1 + + /** Creates a backend, which is closed when the resource is released. + * + * @param verbose + * If true, logs request and response summary to the console. + * @param parallelism + * The number of libcurl multi handles to spread the requests over. + */ + def resource(verbose: Boolean = false, parallelism: Int = defaultParallelism): Resource[IO, Backend[IO]] = + Resource.make(IO(apply(verbose, parallelism)))(_.close()) + + /** Creates a backend. It should be closed after use. */ + def apply(verbose: Boolean = false, parallelism: Int = defaultParallelism): Backend[IO] = { + require(parallelism > 0, "parallelism must be positive") + FollowRedirectsBackend(new CurlCatsAsyncBackend(Array.fill(parallelism)(CurlMultiDriver()), verbose)) + } + + /** Fallback when there's no poller: a dedicated multi handle per transfer, driven on the blocking pool in short + * slices, so that the fiber stays cancelable. + */ + private def performBlocking(c: CurlHandle): IO[CurlCode.CurlCode] = + IO { + val multi = CurlApi.multiInit + val running = stdlib.calloc(1.toUSize, sizeof[CInt]).asInstanceOf[Ptr[CInt]] + val rc = multi.addHandle(c) + (multi, running, rc) + }.bracket { case (multi, running, rc) => + def loop: IO[CurlCode.CurlCode] = + IO.blocking { + val pc = multi.perform(running) + if (pc != CurlMCode.Ok) Some(CurlCode.FailedInit) + else if (!running == 0) Some(CurlCode(Math.max(multi.infoReadResult(null), 0))) + else { + val _ = multi.poll(50, null) + None + } + }.flatMap { + case Some(code) => IO.pure(code) + case None => IO.cede *> loop + } + if (rc != CurlMCode.Ok) IO.raiseError(new RuntimeException(s"curl_multi_add_handle failed with $rc")) + else loop + } { case (multi, running, _) => + IO { + val _ = multi.removeHandle(c) + multi.cleanup() + stdlib.free(running.asInstanceOf[Ptr[Byte]]) + } + } +} diff --git a/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlMultiDriver.scala b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlMultiDriver.scala new file mode 100644 index 000000000..ceae5348f --- /dev/null +++ b/effects/cats/src/main/scalanative/sttp/client4/curl/cats/CurlMultiDriver.scala @@ -0,0 +1,283 @@ +package sttp.client4.curl.cats + +import cats.effect.{FiberIO, IO, SyncIO} +import cats.effect.std.Mutex +import cats.effect.{FileDescriptorPoller, FileDescriptorPollHandle} +import sttp.client4.curl.internal.CurlApi._ +import sttp.client4.curl.internal._ + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import scala.collection.mutable +import scala.concurrent.duration._ +import scala.scalanative.libc.stdlib +import scala.scalanative.runtime.{fromRawPtr, toRawPtr, Intrinsics} +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +/** Drives a single libcurl multi handle using the `curl_multi_socket_action` interface, integrated with cats-effect's + * [[FileDescriptorPoller]] (epoll/kqueue). No thread is ever blocked waiting for network I/O: curl tells us (through + * the socket callback) which sockets it is interested in, we register them with the runtime's poller, and call + * `curl_multi_socket_action` when they become ready. Timeouts requested by curl (timer callback) are scheduled with + * `IO.sleep`. + * + * Thread-safety: libcurl multi handles can be used from different threads, but never concurrently. Every call into the + * multi handle is made while holding `lock`, a fiber-aware [[Mutex]]: a fiber waiting for it is suspended, no (compute) + * thread is ever blocked on a monitor. The callbacks are invoked synchronously from within the libcurl calls, so they + * run under the lock as well, and only record state. All the other work (starting watchers, completing requests) + * happens outside of the lock. The libcurl multi calls are documented to be non-blocking, so they are suspended with + * `IO.apply` rather than `IO.blocking`. Fibers can freely migrate between the worker threads. + * + * To scale across worker threads, the backend uses several drivers. + */ +private[cats] final class CurlMultiDriver private () { + import CurlMultiDriver._ + + private val id: Long = nextId.incrementAndGet() + private val multi: CurlMultiHandle = CurlApi.multiInit + private val runningPtr: Ptr[CInt] = stdlib.calloc(1.toUSize, sizeof[CInt]).asInstanceOf[Ptr[CInt]] + private val easyOut: Ptr[Ptr[Curl]] = stdlib.calloc(1.toUSize, sizeof[Ptr[Curl]]).asInstanceOf[Ptr[Ptr[Curl]]] + @volatile private var poller: FileDescriptorPoller = _ + private var closed = false + + // --- state guarded by `lock` --- + private val lock: Mutex[IO] = Mutex.in[SyncIO, IO].unsafeRunSync() + private final class Sock(var what: Int, val gen: Long) { var gained: Int = 0 } + private val sockets = mutable.HashMap.empty[Int, Sock] + private var socketGen = 0L + private var timeoutMs: Long = -1L + private var timerGen = 0L + private val completions = mutable.HashMap.empty[Long, Either[Throwable, Int] => Unit] + + // --- state guarded by `reconcileMutex` (never acquired while holding `lock`) --- + private val reconcileMutex: Mutex[IO] = Mutex.in[SyncIO, IO].unsafeRunSync() + private final class Watcher(val gen: Long, val fiber: FiberIO[Unit]) + private val watchers = mutable.HashMap.empty[Int, Watcher] + // fibers of removed watchers; a new watcher on the same fd number waits for the old one to deregister first + private val retired = mutable.HashMap.empty[Int, FiberIO[Unit]] + private var timer: Option[(Long, FiberIO[Unit])] = None + + { + registry.put(id, this) + val userp = idToPtr(id) + CCurl.multiSetoptPtr(multi, MultiSocketFunction, CFuncPtr.toPtr(socketCallback)) + CCurl.multiSetoptPtr(multi, MultiSocketData, userp) + CCurl.multiSetoptPtr(multi, MultiTimerFunction, CFuncPtr.toPtr(timerCallback)) + CCurl.multiSetoptPtr(multi, MultiTimerData, userp) + } + + // --------------------------------------------------------------------------------------------------------------- + // callbacks (invoked by libcurl, under `lock`) + + private def onSocket(fd: Int, what: Int): Unit = + if (what == PollRemove) { val _ = sockets.remove(fd) } + else + sockets.get(fd) match { + case Some(s) => + s.gained |= (what & ~s.what) + s.what = what + case None => + socketGen += 1 + sockets.put(fd, new Sock(what, socketGen)) + } + + private def onTimer(ms: Long): Unit = { + timeoutMs = ms + timerGen += 1 + } + + // --------------------------------------------------------------------------------------------------------------- + // public API + + /** Performs the transfer of `easy`, which must be fully configured. The handle is not cleaned up. */ + def perform(fdPoller: FileDescriptorPoller, easy: CurlHandle): IO[Int] = IO.async[Int] { cb => + poller = fdPoller + val key = easy.toLong + locked { + if (closed) { cb(Left(new IllegalStateException("The curl backend is closed"))); false } + else { + completions.put(key, cb) + val rc = multi.addHandle(easy) + if (rc != CurlMCode.Ok) { + completions.remove(key) + cb(Left(new RuntimeException(s"curl_multi_add_handle failed with $rc"))) + false + } else true + } + }.flatMap { added => + if (added) reconcile.as(Some(cancel(easy, key))) + else IO.pure(None) + } + } + + def close: IO[Unit] = + reconcileMutex.lock.surround { + val cancelAll = watchers.values.map(_.fiber).toList ++ timer.map(_._2).toList + IO { watchers.clear(); timer = None } *> cancelAll.foldLeft(IO.unit)(_ *> cancelAsync(_)) + } *> locked { + if (!closed) { + closed = true + multi.cleanup() + registry.remove(id) + stdlib.free(runningPtr.asInstanceOf[Ptr[Byte]]) + stdlib.free(easyOut.asInstanceOf[Ptr[Byte]]) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // internals + + private def cancel(easy: CurlHandle, key: Long): IO[Unit] = + locked { + if (!closed && completions.remove(key).isDefined) { + val _ = multi.removeHandle(easy) + easy.cleanup() + } + } *> reconcile + + private def locked[A](body: => A): IO[A] = lock.lock.surround(IO(body)) + + /** Runs a libcurl multi call under the lock if `cond` holds, then completes the finished transfers and brings the + * watchers/timer in line with what curl asked for. Uncancelable, as curl must not be interrupted half-way and the + * completions have to be delivered. Returns whether the call was made. + */ + private def pumpIf(cond: => Boolean)(call: => Unit): IO[Boolean] = + IO.uncancelable { _ => + locked { + if (!closed && cond) { + call + Some(collectDone()) + } else None + }.flatMap { + case None => IO.pure(false) + case Some(done) => + IO(done.foreach { case (cb, code) => cb(Right(code)) }) *> reconcile.as(true) + } + } + + private def pump(call: => Unit): IO[Unit] = pumpIf(true)(call).void + + /** Must be called under the lock. */ + private def collectDone(): List[(Either[Throwable, Int] => Unit, Int)] = { + var res = List.empty[(Either[Throwable, Int] => Unit, Int)] + var code = multi.infoReadResult(easyOut) + while (code != -1) { + val easy = !easyOut + val _ = multi.removeHandle(easy) + completions.remove(easy.toLong).foreach(cb => res = (cb, code) :: res) + code = multi.infoReadResult(easyOut) + } + res + } + + private def reconcile: IO[Unit] = + reconcileMutex.lock.surround { + locked { + val desired = sockets.map { case (fd, s) => (fd, s.gen) }.toMap + val kicks = sockets.collect { case (fd, s) if s.gained != 0 => (fd, s.gained) }.toList + sockets.valuesIterator.foreach(_.gained = 0) + (desired, kicks, timerGen, timeoutMs) + }.flatMap { case (desired, kicks, tGen, tMs) => + val stale = watchers.collect { case (fd, w) if !desired.get(fd).contains(w.gen) => fd }.toList + val stop = stale.foldLeft(IO.unit) { (acc, fd) => + val w = watchers.remove(fd).get + retired.put(fd, w.fiber) + acc *> cancelAsync(w.fiber) + } + val start = desired.toList.foldLeft(IO.unit) { case (acc, (fd, gen)) => + if (watchers.contains(fd)) acc + else acc *> startWatcher(fd, retired.remove(fd)).map(f => watchers.put(fd, new Watcher(gen, f))).void + } + val timerStep: IO[Boolean] = + if (timer.exists(_._1 == tGen)) IO.pure(false) + else { + val old = timer + timer = None + val cancelOld = old.fold(IO.unit)(t => cancelAsync(t._2)) + if (tMs < 0) cancelOld.as(false) + else if (tMs == 0) cancelOld.as(true) + else + cancelOld *> (IO.sleep(tMs.millis) *> pump(timeoutAction())).start + .map { f => timer = Some((tGen, f)); false } + } + (stop *> start *> timerStep).map(fireNow => (kicks, fireNow)) + } + }.flatMap { case (kicks, fireNow) => + val fire = if (fireNow) pump(timeoutAction()) else IO.unit + // the interest of a socket was extended: its readiness edge might have been consumed already + val kick = kicks.foldLeft(IO.unit) { case (acc, (fd, gained)) => + acc *> (if ((gained & PollIn) != 0) serve(fd, PollIn) else IO.unit) *> + (if ((gained & PollOut) != 0) serve(fd, PollOut) else IO.unit) + } + fire *> kick + } + + /** Cancels without waiting: the fiber being cancelled might be the one running this code. */ + private def cancelAsync(f: FiberIO[Unit]): IO[Unit] = f.cancel.start.void + + private def timeoutAction(): Unit = { val _ = CCurl.multiSocketAction(multi, SocketTimeout, 0, runningPtr) } + + private def startWatcher(fd: Int, previous: Option[FiberIO[Unit]]): IO[FiberIO[Unit]] = { + val blocked: IO[Either[Unit, Nothing]] = IO.pure(Left(())) + def loop(h: FileDescriptorPollHandle): IO[Unit] = + IO.race( + h.pollReadRec[Unit, Nothing](())(_ => serve(fd, PollIn) *> blocked), + h.pollWriteRec[Unit, Nothing](())(_ => serve(fd, PollOut) *> blocked) + ).void + (previous.fold(IO.unit)(_.join.void) *> + poller.registerFileDescriptor(fd, monitorReadReady = true, monitorWriteReady = true).use(loop)) + // deregistering fails if curl already closed the socket; there is nothing to do about it then + .handleError(_ => ()) + .start + } + + /** The readiness notifications are edge-triggered (epoll), so we have to keep driving curl as long as the socket + * stays ready and curl is interested in it - curl might not drain it in one go. + */ + private def serve(fd: Int, flag: Int): IO[Unit] = { + def interested: Boolean = sockets.get(fd).exists(s => (s.what & flag) != 0) + def go(n: Int): IO[Unit] = + pumpIf(interested && CCurl.fdReady(fd, flag) != 0) { val _ = CCurl.multiSocketAction(multi, fd, flag, runningPtr) } + .flatMap { + case false => IO.unit + case true => if (n >= 31) IO.cede *> go(0) else go(n + 1) + } + go(0) + } +} + +private[cats] object CurlMultiDriver { + // CURL_POLL_* (also the same values as CURL_CSELECT_IN/OUT) + private val PollIn = 1 + private val PollOut = 2 + private val PollRemove = 4 + private val SocketTimeout = -1 + + // CURLMOPT_* + private val MultiSocketFunction = 20001 + private val MultiSocketData = 10002 + private val MultiTimerFunction = 20004 + private val MultiTimerData = 10005 + + private val nextId = new AtomicLong(0) + private val registry = new ConcurrentHashMap[Long, CurlMultiDriver]() + + private def idToPtr(id: Long): Ptr[Byte] = fromRawPtr[Byte](Intrinsics.castLongToRawPtr(id)) + private def ptrToId(p: Ptr[Byte]): Long = Intrinsics.castRawPtrToLong(toRawPtr(p)) + + private val socketCallback: CFuncPtr5[Ptr[Curl], CInt, CInt, Ptr[Byte], Ptr[Byte], CInt] = + (_: Ptr[Curl], fd: CInt, what: CInt, userp: Ptr[Byte], _: Ptr[Byte]) => { + val d = registry.get(ptrToId(userp)) + if (d != null) d.onSocket(fd, what) + 0 + } + + private val timerCallback: CFuncPtr3[Ptr[CurlM], CLong, Ptr[Byte], CInt] = + (_: Ptr[CurlM], ms: CLong, userp: Ptr[Byte]) => { + val d = registry.get(ptrToId(userp)) + if (d != null) d.onTimer(ms.toLong) + 0 + } + + def apply(): CurlMultiDriver = new CurlMultiDriver() +} diff --git a/effects/cats/src/test/scalanative/sttp/client4/curl/cats/CurlCatsAsyncHttpTest.scala b/effects/cats/src/test/scalanative/sttp/client4/curl/cats/CurlCatsAsyncHttpTest.scala new file mode 100644 index 000000000..9c4086543 --- /dev/null +++ b/effects/cats/src/test/scalanative/sttp/client4/curl/cats/CurlCatsAsyncHttpTest.scala @@ -0,0 +1,28 @@ +package sttp.client4.curl.cats + +import cats.effect.IO +import cats.syntax.all._ +import sttp.client4.Backend +import sttp.client4.impl.cats.{CatsRetryTest, CatsTestBase} +import sttp.client4.testing.HttpTest + +class CurlCatsAsyncHttpTest extends HttpTest[IO] with CatsTestBase with CatsRetryTest { + override implicit val backend: Backend[IO] = CurlCatsAsyncBackend() + override def supportsHostHeaderOverride = false + override def supportsDeflateWrapperChecking = false + override def supportsCancellation = false + + for (parallelism <- List(1, 4)) { + s"curl async backend with parallelism = $parallelism" - { + "handle many concurrent requests" in { + CurlCatsAsyncBackend + .resource(parallelism = parallelism) + .use { b => + val one = postEchoExact.body("x").send(b).map(_.body) + List.fill(300)(one).parSequence.map(_.toSet shouldBe Set(Right("x"))) + } + .toFuture() + } + } + } +}