From 5558a544cd19be661b36b067b50209fa85197e46 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:48:20 +0000 Subject: [PATCH 01/10] Add ResourceScope supertrait; freeze finalizer registration after scope end Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/Ox.scala | 27 +++++++++-- core/src/main/scala/ox/unsupervised.scala | 56 ++++++++++++----------- core/src/test/scala/ox/ResourceTest.scala | 12 +++++ 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/core/src/main/scala/ox/Ox.scala b/core/src/main/scala/ox/Ox.scala index e65a170c..e15ffd78 100644 --- a/core/src/main/scala/ox/Ox.scala +++ b/core/src/main/scala/ox/Ox.scala @@ -8,6 +8,29 @@ import ox.internal.ThreadHerd import java.util.concurrent.atomic.AtomicReference import scala.annotation.implicitNotFound +/** Capability granted by a [[resourceScope]], as well as, via subtyping, by any concurrency scope ([[supervised]], [[supervisedError]], + * [[unsupervised]]). + * + * Represents a capability to register resources (e.g. using [[useInScope]] or [[releaseAfterScope]]) to be released when the scope + * completes. Does not allow forking. + * + * @see + * [[OxUnsupervised]], [[Ox]] + */ +@implicitNotFound( + "This operation must be run within a `resourceScope`, or any concurrency scope (`supervised`, `supervisedError`, `unsupervised`). " + + "Alternatively, you must require that the enclosing method is run within a scope, by adding a `using ResourceScope` parameter list." +) +trait ResourceScope: + // contains null once the scope's finalizers have been run; registration then throws (see addFinalizer) + private[ox] def finalizers: AtomicReference[List[() => Unit]] + private[ox] def addFinalizer(f: () => Unit): Unit = + finalizers.updateAndGet { + case null => throw new IllegalStateException("Cannot register a resource: the scope to which it would be attached has already ended") + case fs => f :: fs + }.discard +end ResourceScope + /** Capability granted by an [[unsupervised]] concurrency scope (as well as, via subtyping, by [[supervised]] and [[supervisedError]]). * * Represents a capability to: @@ -21,11 +44,9 @@ import scala.annotation.implicitNotFound @implicitNotFound( "This operation must be run within a `supervised`, `supervisedError` or `unsupervised` block. Alternatively, you must require that the enclosing method is run within a scope, by adding a `using OxUnsupervised` parameter list." ) -trait OxUnsupervised: +trait OxUnsupervised extends ResourceScope: private[ox] def herd: ThreadHerd - private[ox] def finalizers: AtomicReference[List[() => Unit]] private[ox] def supervisor: Supervisor[Nothing] - private[ox] def addFinalizer(f: () => Unit): Unit = finalizers.updateAndGet(f :: _).discard private[ox] def parent: Option[OxUnsupervised] private[ox] def locals: ForkLocalMap end OxUnsupervised diff --git a/core/src/main/scala/ox/unsupervised.scala b/core/src/main/scala/ox/unsupervised.scala index 067004f3..3c7d1a0b 100644 --- a/core/src/main/scala/ox/unsupervised.scala +++ b/core/src/main/scala/ox/unsupervised.scala @@ -25,30 +25,6 @@ private[ox] def unsupervised[T](locals: ForkLocalMap, f: OxUnsupervised ?=> T): scopedWithCapability(OxError(NoOpSupervisor, NoErrorMode, Option(currentScope.get()), locals))(f) private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T = - def throwWithSuppressed(es: List[Throwable]): Nothing = - val e = es.head - es.tail.foreach(e.addSuppressed) - throw e - - def runFinalizers(result: Either[Throwable, T]): T = - val fs = capability.finalizers.get - if fs.isEmpty then result.fold(throw _, identity) - else - val es = uninterruptible { - fs.flatMap { f => - try - f(); None - catch case e: Throwable => Some(e) - } - } - - result match - case Left(e) => throwWithSuppressed(e :: es) - case Right(t) if es.isEmpty => t - case _ => throwWithSuppressed(es) - end if - end runFinalizers - def runWithCurrentScopeSet = val result = try @@ -59,8 +35,8 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T = catch case e: Throwable => Left(e) // running the finalizers only once we are sure that all child threads have been terminated, so that no new - // finalizers are added, and none are lost - runFinalizers(result) + // finalizers are added, and none are lost; registrations after the freeze (via leaked capabilities) throw + runFinalizers(capability, result) end runWithCurrentScopeSet val previousScope = currentScope.get() @@ -69,3 +45,31 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T = runWithCurrentScopeSet finally currentScope.set(previousScope) end scopedWithCapability + +/** Runs the scope's finalizers (in reverse registration order, uninterruptibly), freezing the finalizer list, so that later registrations + * fail with an exception (see [[ResourceScope.addFinalizer]]) instead of being silently lost. Returns the scope's result: the body + * exception is re-thrown with finalizer errors suppressed; finalizer errors alone fail the scope. + */ +private[ox] def runFinalizers[T](scope: ResourceScope, result: Either[Throwable, T]): T = + def throwWithSuppressed(es: List[Throwable]): Nothing = + val e = es.head + es.tail.foreach(e.addSuppressed) + throw e + + val fs = scope.finalizers.getAndSet(null) + val es = + if fs.isEmpty then Nil + else + uninterruptible { + fs.flatMap { f => + try + f(); None + catch case e: Throwable => Some(e) + } + } + + result match + case Left(e) => throwWithSuppressed(e :: es) + case Right(t) if es.isEmpty => t + case _ => throwWithSuppressed(es) +end runFinalizers diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index c1c30940..afda6d24 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -162,4 +162,16 @@ class ResourceTest extends AnyFlatSpec with Matchers: trail.get shouldBe Vector("allocate", "in scope", "release", "exception e2 (e1)") } + + "a leaked scope capability" should "throw when registering a resource after the scope ended" in { + val trail = Trail() + var leaked: OxUnsupervised = null + unsupervised { + leaked = summon[OxUnsupervised] + trail.add("in scope") + } + val e = the[IllegalStateException] thrownBy releaseAfterScope(trail.add("late"))(using leaked) + e.getMessage should include("has already ended") + trail.get shouldBe Vector("in scope") + } end ResourceTest From 4006170ebd5913a2682d0c19b4102ce4c77f69f2 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:53:34 +0000 Subject: [PATCH 02/10] Resource functions require ResourceScope; add binary-compat bridges The bridges keep the old JVM signatures via @targetName, with distinct Scala-level names to avoid overload ambiguity at in-package call sites. Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/resource.scala | 65 ++++++++++++++++------- core/src/test/scala/ox/ResourceTest.scala | 13 ++++- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/core/src/main/scala/ox/resource.scala b/core/src/main/scala/ox/resource.scala index 1fe84802..c60e706a 100644 --- a/core/src/main/scala/ox/resource.scala +++ b/core/src/main/scala/ox/resource.scala @@ -1,38 +1,67 @@ package ox -/** Use the given resource in the current concurrency scope. The resource is allocated using `acquire`, and released after the all forks in - * the scope complete (either successfully or with an error), using `release`. Releasing is [[uninterruptible]]. +import scala.annotation.targetName + +/** Use the given resource in the current scope. The resource is allocated using `acquire`, and released using `release` before the scope + * completes, in reverse registration order. For concurrency scopes, release happens after all forks started within the scope have + * completed (either successfully or with an error). Releasing is [[uninterruptible]]. + * + * If the scope has already ended (which can only happen when using a leaked, explicitly-passed capability), the resource is acquired, + * immediately released — so that cleanup is never lost — and an [[IllegalStateException]] is thrown. */ -def useInScope[T](acquire: => T)(release: T => Unit)(using OxUnsupervised): T = +def useInScope[T](acquire: => T)(release: T => Unit)(using rs: ResourceScope): T = val t = acquire - summon[OxUnsupervised].addFinalizer(() => release(t)) + try rs.addFinalizer(() => release(t)) + catch + case e: Throwable => + // the scope might have already ended (when using a leaked capability); not leaking the just-acquired resource + try uninterruptible(release(t)) + catch case e2: Throwable => e.addSuppressed(e2) + throw e t -/** Use the given resource, which implements [[AutoCloseable]], in the current concurrency scope. The resource is allocated using `acquire`, - * and released after the all forks in the scope complete (either successfully or with an error), using [[AutoCloseable.close()]]. - * Releasing is [[uninterruptible]]. +/** Use the given resource, which implements [[AutoCloseable]], in the current scope. The resource is allocated using `acquire`, and closed + * before the scope completes, in reverse registration order. For concurrency scopes, closing happens after all forks started within the + * scope have completed (either successfully or with an error). Releasing is [[uninterruptible]]. */ -def useCloseableInScope[T <: AutoCloseable](c: => T)(using OxUnsupervised): T = useInScope(c)(_.close()) +def useCloseableInScope[T <: AutoCloseable](c: => T)(using rs: ResourceScope): T = useInScope(c)(_.close()) -/** Release the given resource, by running the `release` code block. Releasing is done after all the forks in the scope complete (either - * successfully or with an error), but before the current concurrency scope completes. Releasing is [[uninterruptible]]. +/** Release the given resource, by running the `release` code block before the scope completes. For concurrency scopes, release happens + * after all forks started within the scope have completed (either successfully or with an error). Releasing is [[uninterruptible]]. */ -def releaseAfterScope(release: => Unit)(using OxUnsupervised): Unit = useInScope(())(_ => release) +def releaseAfterScope(release: => Unit)(using rs: ResourceScope): Unit = useInScope(())(_ => release) -/** Release the given resource, which implements [[AutoCloseable]], by running its `.close()` method. Releasing is done after all the forks - * in the scope complete (either successfully or with an error), but before the current concurrency scope completes. Releasing is - * [[uninterruptible]]. +/** Release the given resource, which implements [[AutoCloseable]], by running its `.close()` method before the scope completes. For + * concurrency scopes, closing happens after all forks started within the scope have completed (either successfully or with an error). + * Releasing is [[uninterruptible]]. */ -def releaseCloseableAfterScope(toRelease: AutoCloseable)(using OxUnsupervised): Unit = useInScope(())(_ => toRelease.close()) +def releaseCloseableAfterScope(toRelease: AutoCloseable)(using rs: ResourceScope): Unit = useInScope(())(_ => toRelease.close()) + +// binary-compatibility bridges for callers compiled against previous ox versions; remove in 2.0. The Scala-level +// names differ from the originals (to avoid overload ambiguity at in-package call sites), while @targetName restores +// the original JVM names, preserving linkage. + +@targetName("useInScope") +private[ox] def useInScopeCompat[T](acquire: => T)(release: T => Unit)(using ox: OxUnsupervised): T = + useInScope(acquire)(release) +@targetName("useCloseableInScope") +private[ox] def useCloseableInScopeCompat[T <: AutoCloseable](c: => T)(using ox: OxUnsupervised): T = + useCloseableInScope(c) +@targetName("releaseAfterScope") +private[ox] def releaseAfterScopeCompat(release: => Unit)(using ox: OxUnsupervised): Unit = + releaseAfterScope(release) +@targetName("releaseCloseableAfterScope") +private[ox] def releaseCloseableAfterScopeCompat(toRelease: AutoCloseable)(using ox: OxUnsupervised): Unit = + releaseCloseableAfterScope(toRelease) /** Use the given resource, acquired using `acquire` and released using `release` in the given `f` code block. Releasing is - * [[uninterruptible]]. To use multiple resources, consider creating a [[supervised]] scope and [[useInScope]] method. + * [[uninterruptible]]. To use multiple resources, consider creating a [[resourceScope]] and using the [[useInScope]] method. */ inline def use[R, T](inline acquire: R, inline release: R => Unit)(inline f: R => T): T = useInterruptible(acquire, r => uninterruptible(release(r)))(f) /** Use the given resource, acquired using `acquire` and released using `release` in the given `f` code block. Releasing might be - * interrupted. To use multiple resources, consider creating a [[supervised]] scope and [[useInScope]] method. + * interrupted. To use multiple resources, consider creating a [[resourceScope]] and using the [[useInScope]] method. * * Equivalent to a `try`-`finally` block. */ @@ -54,6 +83,6 @@ inline def useInterruptible[R, T](inline acquire: R, inline release: R => Unit)( end useInterruptible /** Use the given [[AutoCloseable]] resource, acquired using `acquire` in the given `f` code block. Releasing is [[uninterruptible]]. To use - * multiple resources, consider creating a [[supervised]] scope and [[useCloseableInScope]] method. + * multiple resources, consider creating a [[resourceScope]] and using the [[useCloseableInScope]] method. */ inline def useCloseable[R <: AutoCloseable, T](inline acquire: R)(inline f: R => T): T = use(acquire, _.close())(f) diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index afda6d24..175def96 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -172,6 +172,17 @@ class ResourceTest extends AnyFlatSpec with Matchers: } val e = the[IllegalStateException] thrownBy releaseAfterScope(trail.add("late"))(using leaked) e.getMessage should include("has already ended") - trail.get shouldBe Vector("in scope") + // the release block is run eagerly (so that cleanup is never lost), and the exception is thrown + trail.get shouldBe Vector("in scope", "late") + } + + it should "release the acquired resource when registration fails (scope already ended)" in { + val trail = Trail() + var leaked: OxUnsupervised = null + unsupervised { leaked = summon[OxUnsupervised] } + an[IllegalStateException] shouldBe thrownBy { + useInScope { trail.add("allocate"); 1 }(n => trail.add(s"release $n"))(using leaked) + } + trail.get shouldBe Vector("allocate", "release 1") } end ResourceTest From 897c6bb2d8af799853469f80d5b24712d5be4c34 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:55:03 +0000 Subject: [PATCH 03/10] Add resourceScope with a compile-time no-enclosing-concurrency-scope guard Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/resource.scala | 34 ++++++ core/src/test/scala/ox/ResourceTest.scala | 137 +++++++++++++++------- 2 files changed, 128 insertions(+), 43 deletions(-) diff --git a/core/src/main/scala/ox/resource.scala b/core/src/main/scala/ox/resource.scala index c60e706a..089fd0bd 100644 --- a/core/src/main/scala/ox/resource.scala +++ b/core/src/main/scala/ox/resource.scala @@ -1,6 +1,40 @@ package ox +import java.util.concurrent.atomic.AtomicReference +import scala.annotation.implicitNotFound import scala.annotation.targetName +import scala.util.NotGiven + +@implicitNotFound( + "resourceScope cannot be started here: a concurrency scope is visible, and forks started within the resource scope could outlive it. " + + "Extract the resourceScope usage to a method which doesn't take a `using Ox` parameter." +) +opaque type NoEnclosingConcurrencyScope = Unit + +inline given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = () + +/** Starts a new resource scope: within the given code block `f`, resources can be registered using [[useInScope]] and + * [[releaseAfterScope]]. They are released, in reverse registration order, once `f` completes (either successfully or with an exception). + * Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started, no threads are started while + * the body runs, and no [[ForkLocal]] values can be bound. The stdlib's analogue is `scala.util.Using.Manager`. + * + * Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope. Hence, a resource scope can + * only be started where no concurrency scope is visible (this is verified at compile-time): within a concurrency scope, resources can be + * registered directly; and forks started within a lexically visible resource scope could outlive it, using or registering resources + * after they have been released. For the same reason, the [[ResourceScope]] capability and the registered resources must not leak out of + * the scope: registration after the scope ends throws an [[IllegalStateException]]. + * + * When run within a concurrency scope created with a [[ForkLocal]] binding (via a capability-free method), the body and the finalizers + * see the fork-local values of the resource scope's level; finalizers registered from nested scopes (by explicitly passing the + * capability) do not see the nested scopes' bindings. + */ +def resourceScope[T](f: ResourceScope ?=> T)(using NoEnclosingConcurrencyScope): T = + val scope = new ResourceScope: + private[ox] val finalizers = new AtomicReference[List[() => Unit]](Nil) + val result = + try Right(f(using scope)) + catch case e: Throwable => Left(e) + runFinalizers(scope, result) /** Use the given resource in the current scope. The resource is allocated using `acquire`, and released using `release` before the scope * completes, in reverse registration order. For concurrency scopes, release happens after all forks started within the scope have diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index 175def96..5e354f18 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -6,48 +6,109 @@ import ox.* import ox.util.Trail class ResourceTest extends AnyFlatSpec with Matchers: - "useInScope" should "release resources after allocation" in { - val trail = Trail() + private val scopeKinds: List[(String, (ResourceScope ?=> Unit) => Unit)] = List( + "unsupervised" -> (f => unsupervised(f)), + "resourceScope" -> (f => resourceScope(f)) + ) + + for (kind, runScope) <- scopeKinds do + s"useInScope in $kind" should "release resources after allocation" in { + val trail = Trail() + + runScope { + val r = useInScope { trail.add("allocate"); 1 }(n => trail.add(s"release $n")) + r shouldBe 1 + trail.get shouldBe Vector("allocate") + } + trail.get shouldBe Vector("allocate", "release 1") + } - unsupervised { - val r = useInScope { trail.add("allocate"); 1 }(n => trail.add(s"release $n")) - r shouldBe 1 - trail.get shouldBe Vector("allocate") + it should "release resources in reverse order" in { + val trail = Trail() + + runScope { + val r1 = useInScope { trail.add("allocate 1"); 1 }(n => trail.add(s"release $n")) + val r2 = useInScope { trail.add("allocate 2"); 2 }(n => trail.add(s"release $n")) + r1 shouldBe 1 + r2 shouldBe 2 + trail.get shouldBe Vector("allocate 1", "allocate 2") + } + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1") } - trail.get shouldBe Vector("allocate", "release 1") - } - it should "release resources in reverse order" in { - val trail = Trail() + it should "release resources when there's an exception" in { + val trail = Trail() + + try + runScope { + val r1 = useInScope { + trail.add("allocate 1"); 1 + }(n => trail.add(s"release $n")) + val r2 = useInScope { + trail.add("allocate 2"); 2 + }(n => trail.add(s"release $n")) + r1 shouldBe 1 + r2 shouldBe 2 + throw new RuntimeException + } + catch case _ => trail.add("exception") + end try + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception") + } - unsupervised { - val r1 = useInScope { trail.add("allocate 1"); 1 }(n => trail.add(s"release $n")) - val r2 = useInScope { trail.add("allocate 2"); 2 }(n => trail.add(s"release $n")) - r1 shouldBe 1 - r2 shouldBe 2 - trail.get shouldBe Vector("allocate 1", "allocate 2") + it should "release registered resources" in { + val trail = Trail() + + runScope { + releaseAfterScope(trail.add("release")) + trail.add("in scope") + } + trail.get shouldBe Vector("in scope", "release") } - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1") - } + end for - it should "release resources when there's an exception" in { + "resourceScope" should "attach resources to the nearest scope when nested in a concurrency scope" in { val trail = Trail() + def inner(): Unit = resourceScope { + releaseAfterScope(trail.add("inner release")) + trail.add("inner body") + } + supervised { + releaseAfterScope(trail.add("outer release")) + inner() + trail.add("outer body") + } + trail.get shouldBe Vector("inner body", "inner release", "outer body", "outer release") + } - try - unsupervised { - val r1 = useInScope { - trail.add("allocate 1"); 1 - }(n => trail.add(s"release $n")) - val r2 = useInScope { - trail.add("allocate 2"); 2 - }(n => trail.add(s"release $n")) - r1 shouldBe 1 - r2 shouldBe 2 - throw new RuntimeException + it should "attach resources to the nearest scope when a concurrency scope is nested inside" in { + val trail = Trail() + resourceScope { + releaseAfterScope(trail.add("outer release")) + supervised { + releaseAfterScope(trail.add("inner release")) + trail.add("inner body") } - catch case _ => trail.add("exception") - end try - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception") + trail.add("outer body") + } + trail.get shouldBe Vector("inner body", "inner release", "outer body", "outer release") + } + + it should "throw when registering via a leaked capability after the scope ended" in { + var leaked: ResourceScope = null + resourceScope { leaked = summon[ResourceScope] } + an[IllegalStateException] shouldBe thrownBy(releaseAfterScope(())(using leaked)) + } + + it should "not compile within a visible concurrency scope, but compile via a capability-free method" in { + "supervised { resourceScope { } }" shouldNot typeCheck + "def m()(using Ox): Unit = resourceScope { }" shouldNot typeCheck + "def m(): Unit = resourceScope { }" should compile + "resourceScope { resourceScope { } }" should compile + } + + it should "not allow forking without a concurrency scope" in { + "resourceScope { forkDiscard { } }" shouldNot typeCheck } it should "release resources when there's an exception during releasing (normal resutl)" in { @@ -106,16 +167,6 @@ class ResourceTest extends AnyFlatSpec with Matchers: trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e3") } - it should "release registered resources" in { - val trail = Trail() - - unsupervised { - releaseAfterScope(trail.add("release")) - trail.add("in scope") - } - trail.get shouldBe Vector("in scope", "release") - } - it should "use a resource" in { val trail = Trail() From e5a5911ef2ded644c890b9a1584467d6879ab227 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:55:35 +0000 Subject: [PATCH 04/10] Pin ForkLocal interaction with resource scopes Co-Authored-By: Claude Fable 5 --- core/src/test/scala/ox/ResourceTest.scala | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index 5e354f18..e3be097e 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -111,6 +111,29 @@ class ResourceTest extends AnyFlatSpec with Matchers: "resourceScope { forkDiscard { } }" shouldNot typeCheck } + it should "see fork local values of its level, in body and finalizers" in { + val fl = ForkLocal("default") + val trail = Trail() + def scoped(): Unit = resourceScope { + releaseAfterScope(trail.add(s"release ${fl.get()}")) + trail.add(s"body ${fl.get()}") + } + fl.supervisedWhere("modified") { scoped() } + trail.get shouldBe Vector("body modified", "release modified") + } + + it should "not see nested scopes' fork local values in finalizers registered with an explicit capability" in { + val fl = ForkLocal("default") + val trail = Trail() + resourceScope { + val rs = summon[ResourceScope] + fl.supervisedWhere("modified") { + releaseAfterScope(trail.add(s"release ${fl.get()}"))(using rs) + } + } + trail.get shouldBe Vector("release default") + } + it should "release resources when there's an exception during releasing (normal resutl)" in { val trail = Trail() From 82604ea36c5d78f296a8b7166562c125805737bc Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:59:17 +0000 Subject: [PATCH 05/10] Format code Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/resource.scala | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/core/src/main/scala/ox/resource.scala b/core/src/main/scala/ox/resource.scala index 089fd0bd..010b92f2 100644 --- a/core/src/main/scala/ox/resource.scala +++ b/core/src/main/scala/ox/resource.scala @@ -15,18 +15,18 @@ inline given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEncl /** Starts a new resource scope: within the given code block `f`, resources can be registered using [[useInScope]] and * [[releaseAfterScope]]. They are released, in reverse registration order, once `f` completes (either successfully or with an exception). - * Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started, no threads are started while - * the body runs, and no [[ForkLocal]] values can be bound. The stdlib's analogue is `scala.util.Using.Manager`. + * Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started, no threads are started while the + * body runs, and no [[ForkLocal]] values can be bound. The stdlib's analogue is `scala.util.Using.Manager`. * - * Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope. Hence, a resource scope can - * only be started where no concurrency scope is visible (this is verified at compile-time): within a concurrency scope, resources can be - * registered directly; and forks started within a lexically visible resource scope could outlive it, using or registering resources - * after they have been released. For the same reason, the [[ResourceScope]] capability and the registered resources must not leak out of - * the scope: registration after the scope ends throws an [[IllegalStateException]]. + * Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope. Hence, a resource scope can only + * be started where no concurrency scope is visible (this is verified at compile-time): within a concurrency scope, resources can be + * registered directly; and forks started within a lexically visible resource scope could outlive it, using or registering resources after + * they have been released. For the same reason, the [[ResourceScope]] capability and the registered resources must not leak out of the + * scope: registration after the scope ends throws an [[IllegalStateException]]. * - * When run within a concurrency scope created with a [[ForkLocal]] binding (via a capability-free method), the body and the finalizers - * see the fork-local values of the resource scope's level; finalizers registered from nested scopes (by explicitly passing the - * capability) do not see the nested scopes' bindings. + * When run within a concurrency scope created with a [[ForkLocal]] binding (via a capability-free method), the body and the finalizers see + * the fork-local values of the resource scope's level; finalizers registered from nested scopes (by explicitly passing the capability) do + * not see the nested scopes' bindings. */ def resourceScope[T](f: ResourceScope ?=> T)(using NoEnclosingConcurrencyScope): T = val scope = new ResourceScope: @@ -53,6 +53,7 @@ def useInScope[T](acquire: => T)(release: T => Unit)(using rs: ResourceScope): T catch case e2: Throwable => e.addSuppressed(e2) throw e t +end useInScope /** Use the given resource, which implements [[AutoCloseable]], in the current scope. The resource is allocated using `acquire`, and closed * before the scope completes, in reverse registration order. For concurrency scopes, closing happens after all forks started within the From d73b26193f1b92354cac54a39d8b2276a829bfd8 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 14:59:57 +0000 Subject: [PATCH 06/10] Document resource scopes; make the guard given importless The NoEnclosingConcurrencyScope given moves to the type's companion, so that it's found via the implicit scope without any imports - top-level givens are not brought in by wildcard imports, which made resourceScope unusable outside of the ox package (caught by the docs compilation). Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/resource.scala | 4 ++- doc/other/dictionary.md | 3 ++ doc/utils/resources.md | 46 +++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/core/src/main/scala/ox/resource.scala b/core/src/main/scala/ox/resource.scala index 010b92f2..99cdcdba 100644 --- a/core/src/main/scala/ox/resource.scala +++ b/core/src/main/scala/ox/resource.scala @@ -11,7 +11,9 @@ import scala.util.NotGiven ) opaque type NoEnclosingConcurrencyScope = Unit -inline given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = () +object NoEnclosingConcurrencyScope: + // in the companion, so that it's found via the implicit scope of the type, without any imports + inline given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = () /** Starts a new resource scope: within the given code block `f`, resources can be registered using [[useInScope]] and * [[releaseAfterScope]]. They are released, in reverse registration order, once `f` completes (either successfully or with an exception). diff --git a/doc/other/dictionary.md b/doc/other/dictionary.md index 370b110a..db9cd7e7 100644 --- a/doc/other/dictionary.md +++ b/doc/other/dictionary.md @@ -28,6 +28,9 @@ Scope lifecycle: is reported. When the scope ends, all forks that are still running are cancelled * scope **completes**, once all forks complete and finalizers are run; then, the `supervised`, `supervisedError` or `unsupervised` method returns. +* a **resource scope** (created using `resourceScope`) allows registering resources, which are released when the + scope completes; it is not a concurrency scope: forks cannot be started, and there's no cancellation. Every + concurrency scope is also a resource scope Errors: * fork **failure**: when a fork fails with an exception diff --git a/doc/utils/resources.md b/doc/utils/resources.md index 3cd6d29e..cbc00818 100644 --- a/doc/utils/resources.md +++ b/doc/utils/resources.md @@ -24,10 +24,52 @@ To properly release resources when the entire application is interrupted, make s application's main entry point. ``` +## Resource scopes + +To manage multiple resources with a shared lifetime — without involving concurrency — use a **resource scope**. +Resources registered within the scope are released, in reverse registration order, once the scope's body completes +(successfully or with an exception). This is ox's analogue of `scala.util.Using.Manager`: + +```scala mdoc:compile-only +import ox.{resourceScope, useCloseableInScope} +import java.io.{FileReader, FileWriter} + +def process(): Unit = resourceScope { + val in = useCloseableInScope(new FileReader("in.txt")) + val out = useCloseableInScope(new FileWriter("out.txt")) + // both closed when the scope completes, out first + out.write(in.read()) +} +``` + +Any concurrency scope (e.g. `supervised`) is also a resource scope, so methods can declare exactly the capability +they need: `using ResourceScope` for attaching cleanup, without claiming the ability to fork. + +A resource scope can only be started where no concurrency scope is visible — this is verified at compile-time. The +reason: forks started within a lexically visible resource scope could outlive it, using resources after they have +been released (a concurrency scope doesn't have this problem, as it waits for all forks before releasing). Within a +concurrency scope, register resources directly instead. To use a resource scope e.g. in the body of a fork, extract +it to a method which doesn't take a `using Ox` parameter — a good practice +[in itself](../other/best-practices.md#use-using-ox-sparingly): + +```scala mdoc:compile-only +import ox.{forkDiscard, resourceScope, supervised, useCloseableInScope} +import java.io.FileReader + +def handleRequest(): Unit = resourceScope { + val in = useCloseableInScope(new FileReader("in.txt")) + println(s"Processing the request using: ${in.read()}") +} + +supervised { + forkDiscard(handleRequest()) +} +``` + ## Within a concurrency scope -Resources can be allocated within a concurrency scope. They will be released in reverse acquisition order, after all -forks started within the scope finish (but before the scope completes). E.g.: +Resources can also be allocated within a concurrency scope. They will be released in reverse acquisition order, after +all forks started within the scope finish (but before the scope completes). E.g.: ```scala mdoc:compile-only import ox.{supervised, useInScope} From acc8cd4b099ae8a86d756a383b26b9f9acab6703 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Mon, 20 Jul 2026 15:22:07 +0000 Subject: [PATCH 07/10] Apply review findings: parity tests, scaladoc conciseness, terminology - Parameterize the finalizer-error tests over both scope kinds, proving resourceScope's dedicated implementation shares runFinalizers semantics - Defer the delegator scaladocs to useInScope (canonical contract), removing the 4x-repeated concurrency-scope sentence - Align fork-completion wording with the dictionary (exception, not error); tighten the resourceScope scaladoc; drop restating comments - Document runFinalizers' single-shot contract; drop unneeded inline Co-Authored-By: Claude Fable 5 --- core/src/main/scala/ox/Ox.scala | 6 +- core/src/main/scala/ox/resource.scala | 39 +++---- core/src/main/scala/ox/unsupervised.scala | 7 +- core/src/test/scala/ox/ResourceTest.scala | 121 ++++++++++------------ doc/utils/resources.md | 6 +- 5 files changed, 81 insertions(+), 98 deletions(-) diff --git a/core/src/main/scala/ox/Ox.scala b/core/src/main/scala/ox/Ox.scala index e15ffd78..1bf97b74 100644 --- a/core/src/main/scala/ox/Ox.scala +++ b/core/src/main/scala/ox/Ox.scala @@ -8,7 +8,7 @@ import ox.internal.ThreadHerd import java.util.concurrent.atomic.AtomicReference import scala.annotation.implicitNotFound -/** Capability granted by a [[resourceScope]], as well as, via subtyping, by any concurrency scope ([[supervised]], [[supervisedError]], +/** Capability granted by a [[resourceScope]] and, via subtyping, by any concurrency scope ([[supervised]], [[supervisedError]], * [[unsupervised]]). * * Represents a capability to register resources (e.g. using [[useInScope]] or [[releaseAfterScope]]) to be released when the scope @@ -18,7 +18,7 @@ import scala.annotation.implicitNotFound * [[OxUnsupervised]], [[Ox]] */ @implicitNotFound( - "This operation must be run within a `resourceScope`, or any concurrency scope (`supervised`, `supervisedError`, `unsupervised`). " + + "This operation must be run within a `resourceScope`, or any concurrency scope (`supervised`, `supervisedError` or `unsupervised`). " + "Alternatively, you must require that the enclosing method is run within a scope, by adding a `using ResourceScope` parameter list." ) trait ResourceScope: @@ -51,7 +51,7 @@ trait OxUnsupervised extends ResourceScope: private[ox] def locals: ForkLocalMap end OxUnsupervised -/** Capability granted by an [[supervised]] or [[supervisedError]] concurrency scope. +/** Capability granted by a [[supervised]] or [[supervisedError]] concurrency scope. * * Represents a capability to: * - fork supervised or unsupervised, asynchronously running computations in a concurrency scope. Such forks can be created using diff --git a/core/src/main/scala/ox/resource.scala b/core/src/main/scala/ox/resource.scala index 99cdcdba..705552eb 100644 --- a/core/src/main/scala/ox/resource.scala +++ b/core/src/main/scala/ox/resource.scala @@ -13,22 +13,20 @@ opaque type NoEnclosingConcurrencyScope = Unit object NoEnclosingConcurrencyScope: // in the companion, so that it's found via the implicit scope of the type, without any imports - inline given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = () + given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = () /** Starts a new resource scope: within the given code block `f`, resources can be registered using [[useInScope]] and * [[releaseAfterScope]]. They are released, in reverse registration order, once `f` completes (either successfully or with an exception). - * Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started, no threads are started while the - * body runs, and no [[ForkLocal]] values can be bound. The stdlib's analogue is `scala.util.Using.Manager`. + * Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started and no [[ForkLocal]] values can + * be bound. The stdlib's analogue is `scala.util.Using.Manager`. * - * Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope. Hence, a resource scope can only - * be started where no concurrency scope is visible (this is verified at compile-time): within a concurrency scope, resources can be - * registered directly; and forks started within a lexically visible resource scope could outlive it, using or registering resources after - * they have been released. For the same reason, the [[ResourceScope]] capability and the registered resources must not leak out of the - * scope: registration after the scope ends throws an [[IllegalStateException]]. + * Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope, so within one you can register + * resources directly. Starting a resource scope there is disallowed (verified at compile-time), because forks started in a lexically + * visible resource scope could outlive it, using or registering resources after they've been released. For the same reason, the + * [[ResourceScope]] capability must not leak out of the scope: registration after the scope ends throws an [[IllegalStateException]]. * - * When run within a concurrency scope created with a [[ForkLocal]] binding (via a capability-free method), the body and the finalizers see - * the fork-local values of the resource scope's level; finalizers registered from nested scopes (by explicitly passing the capability) do - * not see the nested scopes' bindings. + * Finalizers run with the [[ForkLocal]] values in effect where `resourceScope` was called — the same values the body sees. A finalizer + * registered through a leaked capability from a nested [[ForkLocal]] binding does not see that nested binding. */ def resourceScope[T](f: ResourceScope ?=> T)(using NoEnclosingConcurrencyScope): T = val scope = new ResourceScope: @@ -38,9 +36,9 @@ def resourceScope[T](f: ResourceScope ?=> T)(using NoEnclosingConcurrencyScope): catch case e: Throwable => Left(e) runFinalizers(scope, result) -/** Use the given resource in the current scope. The resource is allocated using `acquire`, and released using `release` before the scope +/** Use the given resource in the current scope. The resource is allocated using `acquire`, and released using `release` when the scope * completes, in reverse registration order. For concurrency scopes, release happens after all forks started within the scope have - * completed (either successfully or with an error). Releasing is [[uninterruptible]]. + * completed (either successfully or with an exception). Releasing is [[uninterruptible]]. * * If the scope has already ended (which can only happen when using a leaked, explicitly-passed capability), the resource is acquired, * immediately released — so that cleanup is never lost — and an [[IllegalStateException]] is thrown. @@ -50,28 +48,19 @@ def useInScope[T](acquire: => T)(release: T => Unit)(using rs: ResourceScope): T try rs.addFinalizer(() => release(t)) catch case e: Throwable => - // the scope might have already ended (when using a leaked capability); not leaking the just-acquired resource try uninterruptible(release(t)) catch case e2: Throwable => e.addSuppressed(e2) throw e t end useInScope -/** Use the given resource, which implements [[AutoCloseable]], in the current scope. The resource is allocated using `acquire`, and closed - * before the scope completes, in reverse registration order. For concurrency scopes, closing happens after all forks started within the - * scope have completed (either successfully or with an error). Releasing is [[uninterruptible]]. - */ +/** As [[useInScope]], but the resource, which implements [[AutoCloseable]], is released using [[AutoCloseable.close()]]. */ def useCloseableInScope[T <: AutoCloseable](c: => T)(using rs: ResourceScope): T = useInScope(c)(_.close()) -/** Release the given resource, by running the `release` code block before the scope completes. For concurrency scopes, release happens - * after all forks started within the scope have completed (either successfully or with an error). Releasing is [[uninterruptible]]. - */ +/** As [[useInScope]], but nothing is acquired — only the `release` code block is registered, to be run when the scope completes. */ def releaseAfterScope(release: => Unit)(using rs: ResourceScope): Unit = useInScope(())(_ => release) -/** Release the given resource, which implements [[AutoCloseable]], by running its `.close()` method before the scope completes. For - * concurrency scopes, closing happens after all forks started within the scope have completed (either successfully or with an error). - * Releasing is [[uninterruptible]]. - */ +/** As [[releaseAfterScope]], but closes the given [[AutoCloseable]] resource. */ def releaseCloseableAfterScope(toRelease: AutoCloseable)(using rs: ResourceScope): Unit = useInScope(())(_ => toRelease.close()) // binary-compatibility bridges for callers compiled against previous ox versions; remove in 2.0. The Scala-level diff --git a/core/src/main/scala/ox/unsupervised.scala b/core/src/main/scala/ox/unsupervised.scala index 3c7d1a0b..b9e40aec 100644 --- a/core/src/main/scala/ox/unsupervised.scala +++ b/core/src/main/scala/ox/unsupervised.scala @@ -46,9 +46,10 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T = finally currentScope.set(previousScope) end scopedWithCapability -/** Runs the scope's finalizers (in reverse registration order, uninterruptibly), freezing the finalizer list, so that later registrations - * fail with an exception (see [[ResourceScope.addFinalizer]]) instead of being silently lost. Returns the scope's result: the body - * exception is re-thrown with finalizer errors suppressed; finalizer errors alone fail the scope. +/** Runs the scope's finalizers (in reverse registration order, uninterruptibly), first freezing the finalizer list (by setting it to + * `null`), so that later registrations fail with an exception (see [[ResourceScope.addFinalizer]]) instead of being silently lost. Must be + * called exactly once per scope. Returns the scope's result: the body exception is re-thrown with finalizer exceptions suppressed; + * finalizer exceptions alone fail the scope. */ private[ox] def runFinalizers[T](scope: ResourceScope, result: Either[Throwable, T]): T = def throwWithSuppressed(es: List[Throwable]): Nothing = diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index e3be097e..44532aa4 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -65,6 +65,61 @@ class ResourceTest extends AnyFlatSpec with Matchers: } trail.get shouldBe Vector("in scope", "release") } + + it should "release resources when there's an exception during releasing (normal result)" in { + val trail = Trail() + + try + runScope { + val r1 = useInScope { + trail.add("allocate 1"); + 1 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e1") + } + val r2 = useInScope { + trail.add("allocate 2"); + 2 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e2") + } + r1 shouldBe 1 + r2 shouldBe 2 + } + catch case e => trail.add(s"exception ${e.getMessage}") + end try + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e2") + } + + it should "release resources when there's an exception during releasing (exceptional result)" in { + val trail = Trail() + + try + runScope { + val r1 = useInScope { + trail.add("allocate 1"); + 1 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e1") + } + val r2 = useInScope { + trail.add("allocate 2"); + 2 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e2") + } + r1 shouldBe 1 + r2 shouldBe 2 + throw new RuntimeException("e3") + } + catch case e => trail.add(s"exception ${e.getMessage}") + end try + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e3") + } end for "resourceScope" should "attach resources to the nearest scope when nested in a concurrency scope" in { @@ -134,62 +189,6 @@ class ResourceTest extends AnyFlatSpec with Matchers: trail.get shouldBe Vector("release default") } - it should "release resources when there's an exception during releasing (normal resutl)" in { - val trail = Trail() - - try - unsupervised { - val r1 = useInScope { - trail.add("allocate 1"); - 1 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e1") - } - val r2 = useInScope { - trail.add("allocate 2"); - 2 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e2") - } - r1 shouldBe 1 - r2 shouldBe 2 - r1 + r2 - } - catch case e => trail.add(s"exception ${e.getMessage}") - end try - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e2") - } - - it should "release resources when there's an exception during releasing (exceptional resutl)" in { - val trail = Trail() - - try - unsupervised { - val r1 = useInScope { - trail.add("allocate 1"); - 1 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e1") - } - val r2 = useInScope { - trail.add("allocate 2"); - 2 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e2") - } - r1 shouldBe 1 - r2 shouldBe 2 - throw new RuntimeException("e3") - } - catch case e => trail.add(s"exception ${e.getMessage}") - end try - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e3") - } - it should "use a resource" in { val trail = Trail() @@ -238,16 +237,10 @@ class ResourceTest extends AnyFlatSpec with Matchers: } "a leaked scope capability" should "throw when registering a resource after the scope ended" in { - val trail = Trail() var leaked: OxUnsupervised = null - unsupervised { - leaked = summon[OxUnsupervised] - trail.add("in scope") - } - val e = the[IllegalStateException] thrownBy releaseAfterScope(trail.add("late"))(using leaked) + unsupervised { leaked = summon[OxUnsupervised] } + val e = the[IllegalStateException] thrownBy releaseAfterScope(())(using leaked) e.getMessage should include("has already ended") - // the release block is run eagerly (so that cleanup is never lost), and the exception is thrown - trail.get shouldBe Vector("in scope", "late") } it should "release the acquired resource when registration fails (scope already ended)" in { diff --git a/doc/utils/resources.md b/doc/utils/resources.md index cbc00818..bf819e54 100644 --- a/doc/utils/resources.md +++ b/doc/utils/resources.md @@ -13,10 +13,10 @@ useCloseable(new java.io.PrintWriter("test.txt")) { writer => } ``` -If a concurrency scope is available (e.g. `supervised`), or there are multiple resources to allocate, consider using the -approach described below, to avoid creating an additional syntactical scope. +If a concurrency scope is available (e.g. `supervised`), or there are multiple resources to allocate, consider using a +resource scope (described below), to avoid creating an additional syntactical scope. -Alternatively, you can use `useInterruptibly`, where the releasing might be interrupted, and which is equivalent to a +Alternatively, you can use `useInterruptible`, where the releasing might be interrupted, and which is equivalent to a `try`-`finally` block. ```{warning} From 9744500fe4dbfbbe2a84fb12289f353ac90c09ba Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 21 Jul 2026 11:29:02 +0000 Subject: [PATCH 08/10] Docs --- doc/other/dictionary.md | 7 +++---- doc/utils/resources.md | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/other/dictionary.md b/doc/other/dictionary.md index db9cd7e7..55003aab 100644 --- a/doc/other/dictionary.md +++ b/doc/other/dictionary.md @@ -4,7 +4,9 @@ How we use various terms throughout the codebase and the documentation (or at le Scopes: * **concurrency scope**: either `supervised` (default), `supervisedError` (permitting application errors), - or `unsupervised` + or `unsupervised`. Allow starting forks. Every concurrency scope is also a resource scope. +* **resource scope**: created using `resourceScope`, allow registering resources, which are released when the + scope completes * scope **body**: the code block passed to a concurrency scope (the `supervised`, `supervisedError` or `unsupervised` method) @@ -28,9 +30,6 @@ Scope lifecycle: is reported. When the scope ends, all forks that are still running are cancelled * scope **completes**, once all forks complete and finalizers are run; then, the `supervised`, `supervisedError` or `unsupervised` method returns. -* a **resource scope** (created using `resourceScope`) allows registering resources, which are released when the - scope completes; it is not a concurrency scope: forks cannot be started, and there's no cancellation. Every - concurrency scope is also a resource scope Errors: * fork **failure**: when a fork fails with an exception diff --git a/doc/utils/resources.md b/doc/utils/resources.md index bf819e54..bbed92ef 100644 --- a/doc/utils/resources.md +++ b/doc/utils/resources.md @@ -26,7 +26,7 @@ application's main entry point. ## Resource scopes -To manage multiple resources with a shared lifetime — without involving concurrency — use a **resource scope**. +To manage multiple resources with a shared lifetime - without involving concurrency - use a **resource scope**. Resources registered within the scope are released, in reverse registration order, once the scope's body completes (successfully or with an exception). This is ox's analogue of `scala.util.Using.Manager`: From 3f554e01e5f5a51cb610c325ecb61a9a24f8c3cd Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 21 Jul 2026 11:31:10 +0000 Subject: [PATCH 09/10] Trim overlapping resource tests Per a test-minimality audit: reverse-order subsumes after-allocation; the finalizer-error scenarios and the register-only case pin shared runFinalizers logic, so a single scope kind suffices; the two leaked-capability tests merge into one (throw + message + eager release). Co-Authored-By: Claude Fable 5 --- core/src/test/scala/ox/ResourceTest.scala | 140 ++++++++++------------ 1 file changed, 62 insertions(+), 78 deletions(-) diff --git a/core/src/test/scala/ox/ResourceTest.scala b/core/src/test/scala/ox/ResourceTest.scala index 44532aa4..187c7bb0 100644 --- a/core/src/test/scala/ox/ResourceTest.scala +++ b/core/src/test/scala/ox/ResourceTest.scala @@ -12,18 +12,7 @@ class ResourceTest extends AnyFlatSpec with Matchers: ) for (kind, runScope) <- scopeKinds do - s"useInScope in $kind" should "release resources after allocation" in { - val trail = Trail() - - runScope { - val r = useInScope { trail.add("allocate"); 1 }(n => trail.add(s"release $n")) - r shouldBe 1 - trail.get shouldBe Vector("allocate") - } - trail.get shouldBe Vector("allocate", "release 1") - } - - it should "release resources in reverse order" in { + s"useInScope in $kind" should "release resources in reverse order" in { val trail = Trail() runScope { @@ -56,71 +45,72 @@ class ResourceTest extends AnyFlatSpec with Matchers: trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception") } - it should "release registered resources" in { - val trail = Trail() + end for - runScope { - releaseAfterScope(trail.add("release")) - trail.add("in scope") - } - trail.get shouldBe Vector("in scope", "release") + "releaseAfterScope" should "release registered resources" in { + val trail = Trail() + + resourceScope { + releaseAfterScope(trail.add("release")) + trail.add("in scope") } + trail.get shouldBe Vector("in scope", "release") + } - it should "release resources when there's an exception during releasing (normal result)" in { - val trail = Trail() + "useInScope" should "release resources when there's an exception during releasing (normal result)" in { + val trail = Trail() - try - runScope { - val r1 = useInScope { - trail.add("allocate 1"); - 1 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e1") - } - val r2 = useInScope { - trail.add("allocate 2"); - 2 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e2") - } - r1 shouldBe 1 - r2 shouldBe 2 + try + resourceScope { + val r1 = useInScope { + trail.add("allocate 1"); + 1 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e1") } - catch case e => trail.add(s"exception ${e.getMessage}") - end try - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e2") - } + val r2 = useInScope { + trail.add("allocate 2"); + 2 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e2") + } + r1 shouldBe 1 + r2 shouldBe 2 + } + catch case e => trail.add(s"exception ${e.getMessage}") + end try + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e2") + } - it should "release resources when there's an exception during releasing (exceptional result)" in { - val trail = Trail() + it should "release resources when there's an exception during releasing (exceptional result)" in { + val trail = Trail() - try - runScope { - val r1 = useInScope { - trail.add("allocate 1"); - 1 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e1") - } - val r2 = useInScope { - trail.add("allocate 2"); - 2 - } { n => - trail.add(s"release $n") - throw new RuntimeException("e2") - } - r1 shouldBe 1 - r2 shouldBe 2 - throw new RuntimeException("e3") + try + resourceScope { + val r1 = useInScope { + trail.add("allocate 1"); + 1 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e1") } - catch case e => trail.add(s"exception ${e.getMessage}") - end try - trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e3") - } - end for + val r2 = useInScope { + trail.add("allocate 2"); + 2 + } { n => + trail.add(s"release $n") + throw new RuntimeException("e2") + } + r1 shouldBe 1 + r2 shouldBe 2 + throw new RuntimeException("e3") + } + catch case e => trail.add(s"exception ${e.getMessage}") + end try + trail.get shouldBe Vector("allocate 1", "allocate 2", "release 2", "release 1", "exception e3") + } "resourceScope" should "attach resources to the nearest scope when nested in a concurrency scope" in { val trail = Trail() @@ -236,20 +226,14 @@ class ResourceTest extends AnyFlatSpec with Matchers: trail.get shouldBe Vector("allocate", "in scope", "release", "exception e2 (e1)") } - "a leaked scope capability" should "throw when registering a resource after the scope ended" in { - var leaked: OxUnsupervised = null - unsupervised { leaked = summon[OxUnsupervised] } - val e = the[IllegalStateException] thrownBy releaseAfterScope(())(using leaked) - e.getMessage should include("has already ended") - } - - it should "release the acquired resource when registration fails (scope already ended)" in { + "a leaked scope capability" should "throw when registering after the scope ended, releasing the acquired resource" in { val trail = Trail() var leaked: OxUnsupervised = null unsupervised { leaked = summon[OxUnsupervised] } - an[IllegalStateException] shouldBe thrownBy { + val e = the[IllegalStateException] thrownBy { useInScope { trail.add("allocate"); 1 }(n => trail.add(s"release $n"))(using leaked) } + e.getMessage should include("has already ended") trail.get shouldBe Vector("allocate", "release 1") } end ResourceTest From b6bb537845ce93148b5143e35428b07514a66d49 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Tue, 21 Jul 2026 11:33:43 +0000 Subject: [PATCH 10/10] Unify the resource-scope docs: one section, two ways to obtain a scope Co-Authored-By: Claude Fable 5 --- doc/utils/resources.md | 64 ++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/doc/utils/resources.md b/doc/utils/resources.md index bbed92ef..e174768b 100644 --- a/doc/utils/resources.md +++ b/doc/utils/resources.md @@ -26,9 +26,13 @@ application's main entry point. ## Resource scopes -To manage multiple resources with a shared lifetime - without involving concurrency - use a **resource scope**. -Resources registered within the scope are released, in reverse registration order, once the scope's body completes -(successfully or with an exception). This is ox's analogue of `scala.util.Using.Manager`: +Resources can be attached to a **resource scope**: they are then released when the scope completes (successfully or +with an exception), in reverse registration order. Releasing is uninterruptible. Resources are registered using +`useInScope` (with acquire & release logic) or `useCloseableInScope` (for `AutoCloseable`s). A resource scope can be +obtained in two ways: + +* `resourceScope` starts a dedicated, resource-only scope - without involving concurrency. This is ox's analogue of + `scala.util.Using.Manager`: ```scala mdoc:compile-only import ox.{resourceScope, useCloseableInScope} @@ -42,34 +46,8 @@ def process(): Unit = resourceScope { } ``` -Any concurrency scope (e.g. `supervised`) is also a resource scope, so methods can declare exactly the capability -they need: `using ResourceScope` for attaching cleanup, without claiming the ability to fork. - -A resource scope can only be started where no concurrency scope is visible — this is verified at compile-time. The -reason: forks started within a lexically visible resource scope could outlive it, using resources after they have -been released (a concurrency scope doesn't have this problem, as it waits for all forks before releasing). Within a -concurrency scope, register resources directly instead. To use a resource scope e.g. in the body of a fork, extract -it to a method which doesn't take a `using Ox` parameter — a good practice -[in itself](../other/best-practices.md#use-using-ox-sparingly): - -```scala mdoc:compile-only -import ox.{forkDiscard, resourceScope, supervised, useCloseableInScope} -import java.io.FileReader - -def handleRequest(): Unit = resourceScope { - val in = useCloseableInScope(new FileReader("in.txt")) - println(s"Processing the request using: ${in.read()}") -} - -supervised { - forkDiscard(handleRequest()) -} -``` - -## Within a concurrency scope - -Resources can also be allocated within a concurrency scope. They will be released in reverse acquisition order, after -all forks started within the scope finish (but before the scope completes). E.g.: +* every concurrency scope (e.g. `supervised`) is also a resource scope; resources registered within one are released + after all forks started within the scope finish (but before the scope completes): ```scala mdoc:compile-only import ox.{supervised, useInScope} @@ -91,6 +69,30 @@ supervised { } ``` +Methods which only register resources can declare exactly the capability they need - `using ResourceScope` - without +claiming the ability to fork. + +A resource scope can only be started where no concurrency scope is visible - this is verified at compile-time. The +reason: forks started within a lexically visible resource scope could outlive it, using resources after they have +been released (a concurrency scope doesn't have this problem, as it waits for all forks before releasing). Within a +concurrency scope, register resources directly instead. To use a resource scope e.g. in the body of a fork, extract +it to a method which doesn't take a `using Ox` parameter - a good practice +[in itself](../other/best-practices.md#use-using-ox-sparingly): + +```scala mdoc:compile-only +import ox.{forkDiscard, resourceScope, supervised, useCloseableInScope} +import java.io.FileReader + +def handleRequest(): Unit = resourceScope { + val in = useCloseableInScope(new FileReader("in.txt")) + println(s"Processing the request using: ${in.read()}") +} + +supervised { + forkDiscard(handleRequest()) +} +``` + ### Release-only You can also register resources to be released (without acquisition logic), before the scope completes: