CLOVIS
05/15/2026, 4:49 PMResourceScope marked as subclass opt-in required?phldavies
05/15/2026, 4:54 PMCLOVIS
05/15/2026, 4:55 PMCLOVIS
05/15/2026, 4:55 PMCLOVIS
05/15/2026, 4:57 PMResourceScopeYoussef Shoaib [MOD]
05/15/2026, 5:01 PMResourceScope whose onRelease is just a no-op. That's very inadvisable to me.
In general, it feels like you need to "know what you're doing" to implement ResourceScope and ensure that its contracts are followed properly (each block is called on release, in LIFO order, exactly once, with proper exception handling).
at least there should be a comment on it explaining why100%. I'll make a PR or something to update the KDoc.
It seems to me that it's impossible to integrate Resources into any existing DSL without implementingAre contexts not enough here? You can exposeResourceScope
ResourceScope by having it as a context in your DSL. You can also store it around in your custom DSL objects if you need access to it internally. I'm not sure that I see a huge need to implement ResourceScope on your own.Youssef Shoaib [MOD]
05/15/2026, 5:03 PMResourceScope. Tbh, that's a weaker argument now since there's just the one method, namely onRelease, but previously, we got bit by that.simon.vergauwen
05/15/2026, 5:08 PMCoroutineScope which official advice is to also never extend?
I.e. doing class MyService(..): CoroutineScope or class MyDSL(...): CoroutineScope is advised against. Composition should be preferred.
So I agree that context(ResourceScope) or exposing a val scope: ResourceScope is probably safer. Also yes, 2.4 is around the corner. Time to all go harder on context parameters π¬CLOVIS
05/15/2026, 5:08 PMYoussef Shoaib [MOD]
05/15/2026, 5:11 PMallocate method (I think that's what it's called) which lets you integrate with any other cleanup mechanism you might want. It is under opt-in as well, but at least it doesn't need you to keep track of cleanup order or anything like that.
From memory, you can do something like:
val (scope, onRelease) = resource { this }.allocate()
// pass scope wherever you need it
myFrameworkCleanupHandler.register { onRelease(...) }
(see also the implementation of resourceScope, which does exactly that!)
Having this as an option means that it's even less likely that you'll need to extend ResourceScope directlyCLOVIS
05/15/2026, 5:11 PMI didn't add the annotation but it feels the same aswhich official advice is to also never extend?CoroutineScope
CoroutineScope isn't marked with subclass opt-in. I think it's because inheritance-by-delegation is allowed and legitimate. Job is marked as opt-in though. Flow is marked subclass-opt-in because methods get added without default values.
All three of these types are very easy to extend though, they all have constructors, builders, etc. ResourceScope doesn't (AFAIK)CLOVIS
05/15/2026, 5:12 PMWell, ultimately, you can just opt-in if you're in the edge-case where it's really important for you to inherit from it! That's a very valid use case you have there.That's why I need a comment explaining the reasons and the guarantees π "We think you don't need it" is very different from "things will break if you do"
CLOVIS
05/15/2026, 5:13 PMYoussef Shoaib [MOD]
05/15/2026, 5:13 PMCLOVIS
05/15/2026, 5:15 PMYoussef Shoaib [MOD]
05/15/2026, 5:17 PMallocate wasn't clearly the tool to use here.
Just to check, do you think it does the trick for you here?
My thinking now is we should have a clear message saying "hey, if you're thinking of implementing this, maybe use allocate instead" and then after list all the guarantees that a ResourceScope implementation is supposed to follow (LIFO, handling errors)
allocate doesn't need you to be aware of those guarantees. All that it needs is that you call its onClose eventually, and do so exactly once.simon.vergauwen
05/15/2026, 5:18 PMCLOVIS
05/15/2026, 5:19 PMYoussef Shoaib [MOD]
05/15/2026, 5:20 PMCLOVIS
05/15/2026, 5:20 PMallocate KDoc? I'm on mobile, it's not easy to findphldavies
05/15/2026, 5:22 PMval (scope, release) = @OptIn(DelicateCoroutinesApi::class) resource { this }.allocate()CLOVIS
05/15/2026, 5:22 PMYoussef Shoaib [MOD]
05/15/2026, 5:23 PMallocate instead:
fun TestDsl.makeResourceScope(): ResourceScope {
val (scope, release) = resource { this }.allocate()
launch(Dispatchers.Unconfined) {
cleanUp("Release resource on success", onFailure = false) {
release(ExitCase.Completed)
}
cleanUp("Release resource on failure", onSuccess = false) {
release(ExitCase(RuntimeException("The real exception which caused the test to fail is unknown")))
}
}
return scope
}
If that's how Prepared functions, then that's very fair, but it can obviously be surprising. This subtlety of behaviour, I think, makes the opt-in a reasonable requirementsimon.vergauwen
05/15/2026, 5:24 PMit's more convenient if the behavior matches Prepared's fixtures behavior, which don't have this fall through behaviorAgreed.
Youssef Shoaib [MOD]
05/15/2026, 5:25 PMresourceScope is implemented! That's largely because I wanted to hide away the implementation class from public API, and allocate was already there!phldavies
05/15/2026, 5:25 PMCLOVIS
05/15/2026, 5:26 PMYoussef Shoaib [MOD]
05/15/2026, 5:28 PMrelease here is a function returned by allocate. Under the hood, allocate creates this resource scope that stores all the onRelease functions in a list. The release function returned encapsulates all the tricky logic to run them in proper order and do error handling.
We simply schedule release to run onCleanup, so all the onRelease actions will already be in the relevant listCLOVIS
05/15/2026, 5:29 PMYoussef Shoaib [MOD]
05/15/2026, 5:30 PMallocate lets you worry about just a singular release function that you need to schedule to run eventually, and it deals with everything else.CLOVIS
05/15/2026, 5:30 PMYoussef Shoaib [MOD]
05/15/2026, 5:31 PMthe provided ResourceScope registers Prepared-style finalizers, then it's fine I believe.phldavies
05/15/2026, 5:31 PMprepared {
makeResourceScope().block()
}
surely when used this register a per-prepared resourcescope and tear down that entire prepared uniformly?CLOVIS
05/15/2026, 5:31 PMCLOVIS
05/15/2026, 5:32 PMYoussef Shoaib [MOD]
05/15/2026, 5:32 PMresourceScope behaves are best suited for an application-style environment where you absolutely want every finalizer to run no matter what. I can see why tests might want different behaviour
> If the user creates resources and Prepared finalizers, the order won't be LIFO
That's a good point! In this case, either implement ResourceScope on your own, or maybe make Prepared finalizers use the same ResourceScope under the hood (although I can see that being difficult).CLOVIS
05/15/2026, 5:33 PMphldavies
05/15/2026, 5:34 PMcontext(ResourceScope) Prepared<T>.bind(): T that installs the prepared finalizer into the resource scope instead of the testdslYoussef Shoaib [MOD]
05/15/2026, 5:34 PMregisterFinalizer callback that by default uses your own implementation, but in an arrow-specific block uses ResourceScope. At this point, you might as well just accept the opt-in I think.CLOVIS
05/15/2026, 5:35 PMcleanUp behaves differently whether the compiler can find an instance of ResourceScope or not, which may vary even within a single test.Youssef Shoaib [MOD]
05/15/2026, 5:36 PMAutoCloseScope and ResourceScope? arrow-autoclose is already a thing, so I don't imagine arrow-fx-resources would be a difficult pill to swallow (and it'd be very, very tiny)CLOVIS
05/15/2026, 5:37 PMYoussef Shoaib [MOD]
05/15/2026, 5:38 PMio.github package. Surely that brings more credibility /sYoussef Shoaib [MOD]
05/15/2026, 5:39 PMcleanUp function ultimately stores finalizers somewhere and then iterates over them, so maybe you can abstract over that and use ResourceScope when it's available, and your own implementation otherwise.CLOVIS
05/15/2026, 5:42 PMCLOVIS
05/15/2026, 5:42 PMCLOVIS
05/15/2026, 5:43 PMphldavies
05/15/2026, 5:43 PMYoussef Shoaib [MOD]
05/15/2026, 5:44 PMResourceScope could be? I think other parts are more out-there though and would never be added (Either, Raise, a lot of the fx libraries).
I wouldn't want every part of Arrow in the stdlib in the slightest. Which is funny because, at the same time, I think most projects would benefit from using Arrow one way or another. Personally, I reach for Arrow less often than I reach for a lot of the stdlib, but when I do, it's always an incredible additionCLOVIS
05/15/2026, 5:46 PMCLOVIS
05/15/2026, 5:46 PMphldavies
05/15/2026, 5:47 PMpreparedResource { } you shouldn't be able to "get" a prepared using val a by myOtherPrepared but you can bind it, which plays nicely with the different scoping - outside of preparedResource it will be lifecycle-managed by TestDsl, but inside it collaborates with ResourceScope.Youssef Shoaib [MOD]
05/15/2026, 5:49 PMRaise vs rich errors). Rich errors are quick and snappy for short lines of code, and for storing data around, while Raise is great for larger blocks of code. In some sense, rich errors eat into the market for Option and Either way more than they do for RaiseYoussef Shoaib [MOD]
05/15/2026, 5:50 PMFinalizers implementation, I don't see why tweaking the behaviour slightly to recover from finalizer errors would be unexpected. After all, finalizers are expected to be ran and to clean up resources, so it could be surprising that they don't run if another finalizer fails.
If you do change the behaviour, then now the behaviour is basically identical to resourceScope, so you can freely swap between the two as the basis of your implementation. Your finalizers would then be in the right order.
Btw, your implementation could be greatly simplified. For one, you can capture the TestDsl inside the finalizer internally. Then, you don't need to store the metadata about whether the finalizer should run on success or failure, instead accessing it directly thru the TestDsl inside the block. Finally, you can println inside the block as well. This'd turn your Finalizers class into basically a ResourceScope implementationCLOVIS
05/15/2026, 5:52 PMoutside ofThat doesn't match the mental model ofit will be lifecycle-managed by TestDsl, but inside it collaborates with ResourceScope.preparedResource
prepared {} . prepared {} is to declare fixtures that are used by the test, they are allowed to outlast their own call until the end of that specific test execution. For example, you can write
val db by prepared {
val db = createDatabase()
launchInBackground {
db.keepActive()
}
cleanUp("db") { db.close() }
db
}CLOVIS
05/15/2026, 5:53 PMNot to steer the conversation too far away from the topic, but I think they're different things (That is my understanding as well, but that is not how they are described in the KEEP nor in the blog posts, nor is it how they're viewed by the community.vs rich errors). Rich errors are quick and snappy for short lines of code, and for storing data around, whileRaiseis great for larger blocks of code. In some sense, rich errors eat into the market forRaiseandOptionway more than they do forEitherRaise
Youssef Shoaib [MOD]
05/15/2026, 5:56 PMAny? and unsafe casts, or wrapping your values in `Option`s everywhereYoussef Shoaib [MOD]
05/15/2026, 6:14 PMinternal class Finalizers {
typealias Finalizer = suspend (Boolean) -> Unit
private val lock = Mutex()
private val finalizers = ArrayList<Finalizer>()
suspend fun register(block: Finalizer) {
lock.withLock("register") { finalizers.add(block) }
}
suspend fun executeAllFinalizers(successful: Boolean) {
finalizers.asReversed().forEach { it(successful) }
}
}
suspend fun TestDsl.cleanUp(
name: String,
onSuccess: Boolean = true,
onFailure: Boolean = true,
block: suspend TestDsl.() -> Unit,
) {
environment.finalizers.register { successful ->
if (!(successful && it.onSuccess || !successful && it.onFailure)) return@register
println("Β» Finalizing '$name'")
block()
}
}
and you can easily see that Finalizers can be turned to an interface, and a ResourceScope-based implementation becomes very simple to writeCLOVIS
05/15/2026, 6:18 PMI still see them as a big upgrade for library uses since they let you efficiently have "extra null-like values" without degrading your code to using100%. For that use-case, they seem amazing. However there's a lot more to error handling than just that.and unsafe casts, or wrapping your values in `Option`s everywhereAny?
Youssef Shoaib [MOD]
05/22/2026, 1:23 PMRaise, or at least a core implementation piece of it, is needed for Lexical Aborts in lifetimes! Of course, these are just design notes, but this means that a Raise-like feature is being discussed in an official KEEP! In fact, it's a key part of it, since the Stacks library cannot work without it. This also gives us the potential for better optimized Raise-ing code since lifetimes can allow for preventing allocations in some cases (and thus maybe some raise calls can turn into simple returns).
To clarify, if lifetimes were in Kotlin right now, a raise function like merge can be written very simply as follows:
fun interface Raise<in E> { fun raise(r: E): Nothing }
inline fun <R> merge(block: local Raise<R>.() -> R): R = block { return it }
(I'm using merge because it's the simplest raise builder, but all the other ones are expressible in terms of merge and some try-`catch`es).