Skip to content

Commit fd71b52

Browse files
adamwclaude
andauthored
Add resource-only scopes (resourceScope / ResourceScope) (#480)
Adds **resource scopes**: scopes which allow attaching resources (`useInScope` / `releaseAfterScope` style), but are not full concurrency scopes — no forking, no threads started while the body runs. Every concurrency scope can be used where a resource scope is expected (via subtyping: `OxUnsupervised extends ResourceScope`). This is ox's analogue of `scala.util.Using.Manager`, and lets methods declare exactly the capability they need (`using ResourceScope` — attach cleanup, without claiming the ability to fork). Design highlights (adversarially critiqued before implementation): - **Compile-time guard**: `resourceScope` cannot be started where a concurrency scope is lexically visible (via a `NotGiven`-based given with a custom error message) — forks started within a lexically visible resource scope could outlive it. The recommended structure is extracting the scope to a capability-free method. - **Finalizer-list freeze**: registering a resource after its scope ended (possible only via explicitly-leaked capabilities) now throws `IllegalStateException` instead of being silently lost; `useInScope` releases the just-acquired resource in that case, so cleanup is never lost. Applies uniformly to concurrency scopes. - **Binary compatibility preserved**: the four resource functions now take `using ResourceScope`; bridges with the old `using OxUnsupervised` JVM signatures are kept via `@targetName` (with distinct Scala-level names, avoiding overload ambiguity) — MiMa passes with no new filters. Source compatibility is unaffected. - Nesting/`ForkLocal` semantics pinned by tests: resources attach to the nearest enclosing scope; resource-scope finalizers see the fork-local values of the scope's own level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a41b841 commit fd71b52

6 files changed

Lines changed: 305 additions & 104 deletions

File tree

core/src/main/scala/ox/Ox.scala

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ import ox.internal.ThreadHerd
88
import java.util.concurrent.atomic.AtomicReference
99
import scala.annotation.implicitNotFound
1010

11+
/** Capability granted by a [[resourceScope]] and, via subtyping, by any concurrency scope ([[supervised]], [[supervisedError]],
12+
* [[unsupervised]]).
13+
*
14+
* Represents a capability to register resources (e.g. using [[useInScope]] or [[releaseAfterScope]]) to be released when the scope
15+
* completes. Does not allow forking.
16+
*
17+
* @see
18+
* [[OxUnsupervised]], [[Ox]]
19+
*/
20+
@implicitNotFound(
21+
"This operation must be run within a `resourceScope`, or any concurrency scope (`supervised`, `supervisedError` or `unsupervised`). " +
22+
"Alternatively, you must require that the enclosing method is run within a scope, by adding a `using ResourceScope` parameter list."
23+
)
24+
trait ResourceScope:
25+
// contains null once the scope's finalizers have been run; registration then throws (see addFinalizer)
26+
private[ox] def finalizers: AtomicReference[List[() => Unit]]
27+
private[ox] def addFinalizer(f: () => Unit): Unit =
28+
finalizers.updateAndGet {
29+
case null => throw new IllegalStateException("Cannot register a resource: the scope to which it would be attached has already ended")
30+
case fs => f :: fs
31+
}.discard
32+
end ResourceScope
33+
1134
/** Capability granted by an [[unsupervised]] concurrency scope (as well as, via subtyping, by [[supervised]] and [[supervisedError]]).
1235
*
1336
* Represents a capability to:
@@ -21,16 +44,14 @@ import scala.annotation.implicitNotFound
2144
@implicitNotFound(
2245
"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."
2346
)
24-
trait OxUnsupervised:
47+
trait OxUnsupervised extends ResourceScope:
2548
private[ox] def herd: ThreadHerd
26-
private[ox] def finalizers: AtomicReference[List[() => Unit]]
2749
private[ox] def supervisor: Supervisor[Nothing]
28-
private[ox] def addFinalizer(f: () => Unit): Unit = finalizers.updateAndGet(f :: _).discard
2950
private[ox] def parent: Option[OxUnsupervised]
3051
private[ox] def locals: ForkLocalMap
3152
end OxUnsupervised
3253

33-
/** Capability granted by an [[supervised]] or [[supervisedError]] concurrency scope.
54+
/** Capability granted by a [[supervised]] or [[supervisedError]] concurrency scope.
3455
*
3556
* Represents a capability to:
3657
* - fork supervised or unsupervised, asynchronously running computations in a concurrency scope. Such forks can be created using
Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,93 @@
11
package ox
22

3-
/** Use the given resource in the current concurrency scope. The resource is allocated using `acquire`, and released after the all forks in
4-
* the scope complete (either successfully or with an error), using `release`. Releasing is [[uninterruptible]].
3+
import java.util.concurrent.atomic.AtomicReference
4+
import scala.annotation.implicitNotFound
5+
import scala.annotation.targetName
6+
import scala.util.NotGiven
7+
8+
@implicitNotFound(
9+
"resourceScope cannot be started here: a concurrency scope is visible, and forks started within the resource scope could outlive it. " +
10+
"Extract the resourceScope usage to a method which doesn't take a `using Ox` parameter."
11+
)
12+
opaque type NoEnclosingConcurrencyScope = Unit
13+
14+
object NoEnclosingConcurrencyScope:
15+
// in the companion, so that it's found via the implicit scope of the type, without any imports
16+
given noEnclosingConcurrencyScope(using NotGiven[OxUnsupervised]): NoEnclosingConcurrencyScope = ()
17+
18+
/** Starts a new resource scope: within the given code block `f`, resources can be registered using [[useInScope]] and
19+
* [[releaseAfterScope]]. They are released, in reverse registration order, once `f` completes (either successfully or with an exception).
20+
* Releasing is [[uninterruptible]]. A resource scope is not a concurrency scope: no forks can be started and no [[ForkLocal]] values can
21+
* be bound. The stdlib's analogue is `scala.util.Using.Manager`.
22+
*
23+
* Any concurrency scope ([[supervised]], [[supervisedError]], [[unsupervised]]) is also a resource scope, so within one you can register
24+
* resources directly. Starting a resource scope there is disallowed (verified at compile-time), because forks started in a lexically
25+
* visible resource scope could outlive it, using or registering resources after they've been released. For the same reason, the
26+
* [[ResourceScope]] capability must not leak out of the scope: registration after the scope ends throws an [[IllegalStateException]].
27+
*
28+
* Finalizers run with the [[ForkLocal]] values in effect where `resourceScope` was called — the same values the body sees. A finalizer
29+
* registered through a leaked capability from a nested [[ForkLocal]] binding does not see that nested binding.
30+
*/
31+
def resourceScope[T](f: ResourceScope ?=> T)(using NoEnclosingConcurrencyScope): T =
32+
val scope = new ResourceScope:
33+
private[ox] val finalizers = new AtomicReference[List[() => Unit]](Nil)
34+
val result =
35+
try Right(f(using scope))
36+
catch case e: Throwable => Left(e)
37+
runFinalizers(scope, result)
38+
39+
/** Use the given resource in the current scope. The resource is allocated using `acquire`, and released using `release` when the scope
40+
* completes, in reverse registration order. For concurrency scopes, release happens after all forks started within the scope have
41+
* completed (either successfully or with an exception). Releasing is [[uninterruptible]].
42+
*
43+
* If the scope has already ended (which can only happen when using a leaked, explicitly-passed capability), the resource is acquired,
44+
* immediately released — so that cleanup is never lost — and an [[IllegalStateException]] is thrown.
545
*/
6-
def useInScope[T](acquire: => T)(release: T => Unit)(using OxUnsupervised): T =
46+
def useInScope[T](acquire: => T)(release: T => Unit)(using rs: ResourceScope): T =
747
val t = acquire
8-
summon[OxUnsupervised].addFinalizer(() => release(t))
48+
try rs.addFinalizer(() => release(t))
49+
catch
50+
case e: Throwable =>
51+
try uninterruptible(release(t))
52+
catch case e2: Throwable => e.addSuppressed(e2)
53+
throw e
954
t
55+
end useInScope
1056

11-
/** Use the given resource, which implements [[AutoCloseable]], in the current concurrency scope. The resource is allocated using `acquire`,
12-
* and released after the all forks in the scope complete (either successfully or with an error), using [[AutoCloseable.close()]].
13-
* Releasing is [[uninterruptible]].
14-
*/
15-
def useCloseableInScope[T <: AutoCloseable](c: => T)(using OxUnsupervised): T = useInScope(c)(_.close())
57+
/** As [[useInScope]], but the resource, which implements [[AutoCloseable]], is released using [[AutoCloseable.close()]]. */
58+
def useCloseableInScope[T <: AutoCloseable](c: => T)(using rs: ResourceScope): T = useInScope(c)(_.close())
1659

17-
/** Release the given resource, by running the `release` code block. Releasing is done after all the forks in the scope complete (either
18-
* successfully or with an error), but before the current concurrency scope completes. Releasing is [[uninterruptible]].
19-
*/
20-
def releaseAfterScope(release: => Unit)(using OxUnsupervised): Unit = useInScope(())(_ => release)
60+
/** As [[useInScope]], but nothing is acquired — only the `release` code block is registered, to be run when the scope completes. */
61+
def releaseAfterScope(release: => Unit)(using rs: ResourceScope): Unit = useInScope(())(_ => release)
2162

22-
/** Release the given resource, which implements [[AutoCloseable]], by running its `.close()` method. Releasing is done after all the forks
23-
* in the scope complete (either successfully or with an error), but before the current concurrency scope completes. Releasing is
24-
* [[uninterruptible]].
25-
*/
26-
def releaseCloseableAfterScope(toRelease: AutoCloseable)(using OxUnsupervised): Unit = useInScope(())(_ => toRelease.close())
63+
/** As [[releaseAfterScope]], but closes the given [[AutoCloseable]] resource. */
64+
def releaseCloseableAfterScope(toRelease: AutoCloseable)(using rs: ResourceScope): Unit = useInScope(())(_ => toRelease.close())
65+
66+
// binary-compatibility bridges for callers compiled against previous ox versions; remove in 2.0. The Scala-level
67+
// names differ from the originals (to avoid overload ambiguity at in-package call sites), while @targetName restores
68+
// the original JVM names, preserving linkage.
69+
70+
@targetName("useInScope")
71+
private[ox] def useInScopeCompat[T](acquire: => T)(release: T => Unit)(using ox: OxUnsupervised): T =
72+
useInScope(acquire)(release)
73+
@targetName("useCloseableInScope")
74+
private[ox] def useCloseableInScopeCompat[T <: AutoCloseable](c: => T)(using ox: OxUnsupervised): T =
75+
useCloseableInScope(c)
76+
@targetName("releaseAfterScope")
77+
private[ox] def releaseAfterScopeCompat(release: => Unit)(using ox: OxUnsupervised): Unit =
78+
releaseAfterScope(release)
79+
@targetName("releaseCloseableAfterScope")
80+
private[ox] def releaseCloseableAfterScopeCompat(toRelease: AutoCloseable)(using ox: OxUnsupervised): Unit =
81+
releaseCloseableAfterScope(toRelease)
2782

2883
/** Use the given resource, acquired using `acquire` and released using `release` in the given `f` code block. Releasing is
29-
* [[uninterruptible]]. To use multiple resources, consider creating a [[supervised]] scope and [[useInScope]] method.
84+
* [[uninterruptible]]. To use multiple resources, consider creating a [[resourceScope]] and using the [[useInScope]] method.
3085
*/
3186
inline def use[R, T](inline acquire: R, inline release: R => Unit)(inline f: R => T): T =
3287
useInterruptible(acquire, r => uninterruptible(release(r)))(f)
3388

3489
/** Use the given resource, acquired using `acquire` and released using `release` in the given `f` code block. Releasing might be
35-
* interrupted. To use multiple resources, consider creating a [[supervised]] scope and [[useInScope]] method.
90+
* interrupted. To use multiple resources, consider creating a [[resourceScope]] and using the [[useInScope]] method.
3691
*
3792
* Equivalent to a `try`-`finally` block.
3893
*/
@@ -54,6 +109,6 @@ inline def useInterruptible[R, T](inline acquire: R, inline release: R => Unit)(
54109
end useInterruptible
55110

56111
/** Use the given [[AutoCloseable]] resource, acquired using `acquire` in the given `f` code block. Releasing is [[uninterruptible]]. To use
57-
* multiple resources, consider creating a [[supervised]] scope and [[useCloseableInScope]] method.
112+
* multiple resources, consider creating a [[resourceScope]] and using the [[useCloseableInScope]] method.
58113
*/
59114
inline def useCloseable[R <: AutoCloseable, T](inline acquire: R)(inline f: R => T): T = use(acquire, _.close())(f)

core/src/main/scala/ox/unsupervised.scala

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,6 @@ private[ox] def unsupervised[T](locals: ForkLocalMap, f: OxUnsupervised ?=> T):
2525
scopedWithCapability(OxError(NoOpSupervisor, NoErrorMode, Option(currentScope.get()), locals))(f)
2626

2727
private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T =
28-
def throwWithSuppressed(es: List[Throwable]): Nothing =
29-
val e = es.head
30-
es.tail.foreach(e.addSuppressed)
31-
throw e
32-
33-
def runFinalizers(result: Either[Throwable, T]): T =
34-
val fs = capability.finalizers.get
35-
if fs.isEmpty then result.fold(throw _, identity)
36-
else
37-
val es = uninterruptible {
38-
fs.flatMap { f =>
39-
try
40-
f(); None
41-
catch case e: Throwable => Some(e)
42-
}
43-
}
44-
45-
result match
46-
case Left(e) => throwWithSuppressed(e :: es)
47-
case Right(t) if es.isEmpty => t
48-
case _ => throwWithSuppressed(es)
49-
end if
50-
end runFinalizers
51-
5228
def runWithCurrentScopeSet =
5329
val result =
5430
try
@@ -59,8 +35,8 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T =
5935
catch case e: Throwable => Left(e)
6036

6137
// running the finalizers only once we are sure that all child threads have been terminated, so that no new
62-
// finalizers are added, and none are lost
63-
runFinalizers(result)
38+
// finalizers are added, and none are lost; registrations after the freeze (via leaked capabilities) throw
39+
runFinalizers(capability, result)
6440
end runWithCurrentScopeSet
6541

6642
val previousScope = currentScope.get()
@@ -69,3 +45,32 @@ private[ox] def scopedWithCapability[T](capability: Ox)(f: Ox ?=> T): T =
6945
runWithCurrentScopeSet
7046
finally currentScope.set(previousScope)
7147
end scopedWithCapability
48+
49+
/** Runs the scope's finalizers (in reverse registration order, uninterruptibly), first freezing the finalizer list (by setting it to
50+
* `null`), so that later registrations fail with an exception (see [[ResourceScope.addFinalizer]]) instead of being silently lost. Must be
51+
* called exactly once per scope. Returns the scope's result: the body exception is re-thrown with finalizer exceptions suppressed;
52+
* finalizer exceptions alone fail the scope.
53+
*/
54+
private[ox] def runFinalizers[T](scope: ResourceScope, result: Either[Throwable, T]): T =
55+
def throwWithSuppressed(es: List[Throwable]): Nothing =
56+
val e = es.head
57+
es.tail.foreach(e.addSuppressed)
58+
throw e
59+
60+
val fs = scope.finalizers.getAndSet(null)
61+
val es =
62+
if fs.isEmpty then Nil
63+
else
64+
uninterruptible {
65+
fs.flatMap { f =>
66+
try
67+
f(); None
68+
catch case e: Throwable => Some(e)
69+
}
70+
}
71+
72+
result match
73+
case Left(e) => throwWithSuppressed(e :: es)
74+
case Right(t) if es.isEmpty => t
75+
case _ => throwWithSuppressed(es)
76+
end runFinalizers

0 commit comments

Comments
 (0)