From 8cc63d78d08367d078a3b48b8e1303011af4a6c5 Mon Sep 17 00:00:00 2001 From: Jacob Wang Date: Wed, 3 Jul 2024 12:47:38 +0100 Subject: [PATCH] Specilized API for IO (#7) * Specilized API for IO * Use IOCatch.apply instead of ioCatching. Revert rename of ioCatch (kept the val) * Add headers * Update uncancelable test to handle race-y condition * fmt * headers for test file * fix warnings --- .../src/main/scala/catcheffect/Catch.scala | 2 +- .../src/main/scala/catcheffect/IOCatch.scala | 34 ++++++++ .../test/scala/catcheffect/IOCatchTest.scala | 81 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 modules/core/src/main/scala/catcheffect/IOCatch.scala create mode 100644 modules/core/src/test/scala/catcheffect/IOCatchTest.scala diff --git a/modules/core/src/main/scala/catcheffect/Catch.scala b/modules/core/src/main/scala/catcheffect/Catch.scala index 9b00165..3d997da 100644 --- a/modules/core/src/main/scala/catcheffect/Catch.scala +++ b/modules/core/src/main/scala/catcheffect/Catch.scala @@ -222,7 +222,7 @@ object Catch { |The handler was defined at $alloc""".stripMargin } - def ioCatch: IO[Catch[IO]] = + val ioCatch: IO[Catch[IO]] = LocalForIOLocal .localForIOLocalDefault(Vault.empty) .map(implicit loc => fromLocal[IO]) diff --git a/modules/core/src/main/scala/catcheffect/IOCatch.scala b/modules/core/src/main/scala/catcheffect/IOCatch.scala new file mode 100644 index 0000000..d69fc37 --- /dev/null +++ b/modules/core/src/main/scala/catcheffect/IOCatch.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Valdemar Grange + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package catcheffect + +import Catch.ioCatch +import cats.effect.* + +object IOCatch { + + def apply[E]: IOCatchPartiallyApplied[E] = + new IOCatchPartiallyApplied[E] + + class IOCatchPartiallyApplied[E] { + def apply[A](f: Handle[IO, E] => IO[A]): IO[Either[E, A]] = { + ioCatch.flatMap { c => + c.use[E](f) + } + } + } + +} diff --git a/modules/core/src/test/scala/catcheffect/IOCatchTest.scala b/modules/core/src/test/scala/catcheffect/IOCatchTest.scala new file mode 100644 index 0000000..139574a --- /dev/null +++ b/modules/core/src/test/scala/catcheffect/IOCatchTest.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Valdemar Grange + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package catcheffect + +import catcheffect.Catch.{RaisedInUncancellable, RaisedWithoutHandler} +import cats.effect.* +import munit.CatsEffectSuite + +class IOCatchTest extends CatsEffectSuite { + test("should abort execution as soon as raise is called") { + for { + ref <- Ref[IO].of("init") + res <- IOCatch[String](h => + for { + _ <- h.raise("error").void + _ <- ref.set("should not be set") + } yield 1 + ) + _ = assertEquals(res, Left("error")) + _ <- assertIO(ref.get, "init") + } yield () + } + + test("can nest correctly") { + for { + res <- IOCatch[String](hs1 => + for { + _ <- IOCatch[String](_ => hs1.raise("1")) + _ <- IO.raiseError(new AssertionError("should not reach this point")).void + } yield () + ) + _ = assertEquals(res, Left("1")) + } yield res + } + + test("Raising in an uncancellable region may correctly raise the error or throw RaisedInUncancellable") { + // There's a small race condition where cancellation from outside will win against the IO.cancel call, + // therefore RaisedInUncancellable isn't thrown. Unfortunately it doesn't seem like we can detect + // whether the current scope is cancellable without actually triggering cancellation + (for { + res <- IOCatch[String](h => IO.uncancelable(_ => h.raise("oops").void)) + // If it did raise error instead of throwing RaisedInUncancellable, at least make sure it's raised correctly + _ = assertEquals(res, Left("oops")) + } yield ()).recoverWith { + case _: RaisedInUncancellable[_] => IO.unit // succeed + case e => IO.raiseError(e) + } + } + + test("Can raise in an cancellable region when polled") { + for { + res <- IOCatch[String](h => IO.uncancelable(poll => poll(h.raise("oops")))) + _ = assertEquals(res, Left("oops")) + } yield () + } + + test("Fail to raise if the Raise instance is used outside of its original fiber") { + (for { + cached <- Deferred[IO, Raise[IO, String]] + res <- IOCatch[String](h => cached.complete(h).void) + _ = assertEquals(res, Right(())) + _ <- cached.get.flatMap(_.raise("hm")).void + } yield ()).attempt.map { + case Left(_: RaisedWithoutHandler[_]) => () // succeed + case other => fail(s"Expected RaisedWithoutHandler, got $other") + } + } +}