Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stack-safety issues in Kleisli instances #3947

Open
catostrophe opened this issue Jul 20, 2021 · 6 comments
Open

Stack-safety issues in Kleisli instances #3947

catostrophe opened this issue Jul 20, 2021 · 6 comments

Comments

@catostrophe
Copy link
Contributor

Repro 1:

import cats._
import cats.data._
import cats.implicits._

val res1 = (1 to 10000).toList.traverse_[Id, Unit](_ => ())  // ok

val res2 = (1 to 1000).toList.traverse_(_ => Kleisli.liftF[Id, String, Unit](())).run("") // fails with SO

traverse_ relies on List'sfoldRight and Kleisli'smap2Eval under the hood, which in turn relies on F.map2Eval

Repro 2 (involves Cats Effect):

import cats._
import cats.data._
import cats.implicits._
import cats.effect._

val res3 = (1 to 10000).toList.traverse_[IO, Unit](_ => IO.unit) // ok
val res4 = (1 to 10000).toList.traverse_(_ => Kleisli.liftF[IO, String, Unit](IO.unit)).run("") // ok

val res5 = (1 to 1000).toList.parTraverse_(_ => Kleisli.liftF[IO, String, Unit](IO.unit)).run("") // fails with SO

Thus, using Kleisli[IO, R, A] may result in SO at runtime if operations involving IO.Par are used, e.g. parTraverse_.

@johnynek
Copy link
Contributor

johnynek commented Aug 9, 2021

I think an issue there is a deep, cats3-level issue that Eval[A] was used in early cats where we really wanted to use Defer[F] but we had yet to understand that. Calling .value on an Eval is code smell and doing so in a loop is reasonably likely to break stack safety.

I could fix this issue with this diff:

+trait KleisliDeferApplicative[F[_], A] extends KleisliApplicative[F, A] {
+  implicit def deferF: Defer[F]
+
+  override def map2Eval[B, C, Z](fa: Kleisli[F, A, B], fb: Eval[Kleisli[F, A, C]])(
+    f: (B, C) => Z
+  ): Eval[Kleisli[F, A, Z]] = {
+    
+    val kb = Defer[Kleisli[F, A, *]].defer(fb.value)
+
+    Eval.now(Kleisli { a =>
+      val ec: Eval[F[C]] = Eval.always(kb.run(a))
+      val efz: Eval[F[Z]] = F.map2Eval(fa.run(a), ec)(f)
+      deferF.defer(efz.value)
+    })
+  }
+}
+

diff --git a/tests/src/test/scala/cats/tests/KleisliSuite.scala b/tests/src/test/scala/cats/tests/KleisliSuite.scala
index 0879b4a02..dd031a249 100644
--- a/tests/src/test/scala/cats/tests/KleisliSuite.scala
+++ b/tests/src/test/scala/cats/tests/KleisliSuite.scala
@@ -354,6 +354,16 @@ class KleisliSuite extends CatsSuite {
     assertEquals(program.run(A123), List((1, "2", true)))
   }
 
+  test("traverse_ doesn't stack overflow") {
+    // see: https://github.com/typelevel/cats/issues/3947
+    implicit val lazyKleisli = new cats.data.KleisliDeferApplicative[Eval, String] {
+      val deferF = Eval.catsDeferForEval
+      val F = Eval.catsBimonadForEval
+    }
+    val res2 = (1 to 10000).toList.traverse_(_ => Kleisli.liftF[Eval, String, Unit](Eval.now(()))).run("").value // fails with SO
+    assert(res2 == ())
+  }
+
   /**
    * Testing that implicit resolution works. If it compiles, the "test" passes.
    */

but requiring a Defer[F] to make an Applicative for Kleisli would be a breaking change.

Note, we did something pretty unprincipled before: #2185 to address a related issue. If instead of doing the reflection hack there, we had used Defer[F].defer(run(a)) I think we could have solved the stack safety issues there.

This is related to how Defer[F] was used to implement ContT: #2506

We could add a higher priority implicit to provide the Apply/Applicative/Monad/... etc I wrote above if a Defer is available for F, but that does complicate the implicit search for Kleisi and it is already quite complex.

Also, we need to work to remove all internal calls to Eval.value for which there is no way to be sure we are at constant stack depth.

@johnynek
Copy link
Contributor

johnynek commented Aug 9, 2021

basically, look at all the internal calls to run in Kleisi. Those should pretty much all be Defer[F].defer(run(a)) in order to be stack safe, otherwise we are increasing stack depth constantly.

@armanbilge
Copy link
Member

but requiring a Defer[F] to make an Applicative for Kleisli would be a breaking change.

Source-breaking for sure, but theoretically we could do this in a binary-compatible way? e.g. taking your idea

We could add a higher priority implicit to provide the Apply/Applicative/Monad/... etc I wrote above if a Defer is available for F, but that does complicate the implicit search for Kleisi and it is already quite complex.

But instead of making it a higher-priority implicit, making the old way no longer an implicit.

@johnynek
Copy link
Contributor

johnynek commented Aug 9, 2021

Yeah, I guess that's true.

But, also note how many internal methods to Kleisli call run(a) and those are not type classes and they could not require Defer[F] without breaking binary compatibility.

Honestly, I wonder if a whole new implementation is worth it. DKleisli[F[_], A, B] where we always require Defer[F] before calling run.

@johnynek
Copy link
Contributor

johnynek commented Aug 9, 2021

I've updated the PR.

One work around is to make collections have safe traverse_ rather than expecting the foldRight implementation to work (which is fundamentally linear depth).

@johnynek
Copy link
Contributor

see #3962 for some related work.

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

No branches or pull requests

3 participants