From 301bb5ba4c31b4d5f442f860be9db53b9ad3c7d0 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Thu, 25 Jun 2026 02:04:23 -0400 Subject: [PATCH] DataConnectGrpcRPCsUnitTest.kt: fix resource leaks --- .../core/DataConnectGrpcRPCsUnitTest.kt | 216 ++++++++++++------ .../firebase/dataconnect/testutil/Cleanups.kt | 137 +++++++++++ .../dataconnect/testutil/CleanupsUnitTest.kt | 164 +++++++++++++ 3 files changed, 441 insertions(+), 76 deletions(-) create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Cleanups.kt create mode 100644 firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/CleanupsUnitTest.kt diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCsUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCsUnitTest.kt index 6d4f22e0de8..8b3129f431a 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCsUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCsUnitTest.kt @@ -27,7 +27,7 @@ import com.google.firebase.dataconnect.sqlite.DataConnectCacheDatabase.SqliteSeq import com.google.firebase.dataconnect.sqlite.QueryResultArb import com.google.firebase.dataconnect.sqlite.QueryResultArb.EntityRepeatPolicy.INTER_SAMPLE_MUTATED import com.google.firebase.dataconnect.sqlite.hydratedStructWithMutatedEntityValuesFrom -import com.google.firebase.dataconnect.testutil.CleanupsRule +import com.google.firebase.dataconnect.testutil.Cleanups import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule import com.google.firebase.dataconnect.testutil.DataConnectPath import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcStreamingServer @@ -97,11 +97,13 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import java.io.File +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.random.Random import kotlin.time.Duration import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.updateAndGet @@ -117,11 +119,10 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) -class DataConnectGrpcRPCsUnitTest { +open class DataConnectGrpcRPCsUnitTest { @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() @get:Rule val temporaryFolder = TemporaryFolder() - @get:Rule val cleanups = CleanupsRule() private val mockLogger = newMockLogger("s3nx74epqj") private val requestIdArb = Arb.dataConnect.requestId() @@ -153,11 +154,18 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb().orNull(nullProbability = 0.2), ) { (sample1, sample2), fetchPolicy1, authToken, appCheckToken, cache -> - val response1 = sample1.hydratedStruct.toExecuteQueryResponse() - val response2 = sample2.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + + val response1 = sample1.hydratedStruct.toExecuteQueryResponse() + val response2 = sample2.hydratedStruct.toExecuteQueryResponse() + + val server = startServer() + cleanups.register(server) - startServer().use { server -> val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) + cleanups.register(dataConnectGrpcRPCs) + val request = operationNameVariablesPairArb.bind() server.nextResponse = response1 @@ -201,14 +209,18 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { (fetchPolicy1, fetchPolicy2), authToken, appCheckToken, cache -> - val (sample1, sample2) = - QueryResultArb(entityCountRange = 0..5, entityRepeatPolicy = INTER_SAMPLE_MUTATED) - .pair() - .bind() - val (request1, request2) = operationNameVariablesPairArb.distinctPair().bind() - - startServer().use { server -> + Cleanups().use { cleanups -> + cleanups.register(cache) + val (sample1, sample2) = + QueryResultArb(entityCountRange = 0..5, entityRepeatPolicy = INTER_SAMPLE_MUTATED) + .pair() + .bind() + val (request1, request2) = operationNameVariablesPairArb.distinctPair().bind() + + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) + cleanups.register(dataConnectGrpcRPCs) server.nextResponse = sample1.toExecuteQueryResponse() dataConnectGrpcRPCs.executeQuery( @@ -255,8 +267,12 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { authToken, appCheckToken, cache -> - startServer().use { server -> + Cleanups().use { cleanups -> + cleanups.register(cache) + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() val exception = @@ -290,8 +306,11 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.authTokenResult().orNull(nullProbability = 0.3), Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), ) { authToken, appCheckToken -> - startServer().use { server -> + Cleanups().use { cleanups -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = null) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() val exception = @@ -335,10 +354,14 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { sample, (fetchPolicy1, fetchPolicy2), authToken, appCheckToken, cache -> - startServer().use { server -> + Cleanups().use { cleanups -> + cleanups.register(cache) + val server = startServer() + cleanups.register(server) val response = sample.hydratedStruct.toExecuteQueryResponse() server.nextResponse = response val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() val result1 = @@ -390,12 +413,16 @@ class DataConnectGrpcRPCsUnitTest { cacheArb(), ) { (fetchPolicy1, fetchPolicy2, fetchPolicy3, fetchPolicy4), authToken, appCheckToken, cache -> - startServer().use { server -> + Cleanups().use { cleanups -> + cleanups.register(cache) + val server = startServer() + cleanups.register(server) val queryResultArb = QueryResultArb(entityCountRange = 0..5, entityRepeatPolicy = INTER_SAMPLE_MUTATED) val sample1 = queryResultArb.bind() val sample2 = queryResultArb.bind() val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) + cleanups.register(dataConnectGrpcRPCs) val distinctExecuteQueryRequestArb = operationNameVariablesPairArb.distinct() val request1 = distinctExecuteQueryRequestArb.bind() val request2 = distinctExecuteQueryRequestArb.bind() @@ -463,10 +490,13 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.authTokenResult().orNull(nullProbability = 0.3), Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), ) { sample, fetchPolicy, authToken, appCheckToken -> - val response = sample.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + val response = sample.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = null) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response @@ -497,10 +527,14 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { sample, fetchPolicy, authToken, appCheckToken, cache -> - val response = sample.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + val response = sample.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response @@ -532,11 +566,15 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { (sample1, sample2), authToken, appCheckToken, cache -> - val response1 = sample1.hydratedStruct.toExecuteQueryResponse() - val response2 = sample2.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + val response1 = sample1.hydratedStruct.toExecuteQueryResponse() + val response2 = sample2.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response1 @@ -586,10 +624,14 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(), ) { sample, fetchPolicy, authToken, appCheckToken, cache -> - val response = sample.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + val response = sample.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response @@ -631,10 +673,14 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(maxAge = Arb.constant(Duration.ZERO)), // always stale ) { sample, fetchPolicy1, authToken, appCheckToken, cache -> - val response = sample.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + val response = sample.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response @@ -683,10 +729,14 @@ class DataConnectGrpcRPCsUnitTest { Arb.dataConnect.appCheckTokenResult().orNull(nullProbability = 0.3), cacheArb(maxAge = Arb.constant(Duration.ZERO)), // always stale ) { sample, fetchPolicy1, authToken, appCheckToken, cache -> - val response = sample.hydratedStruct.toExecuteQueryResponse() + Cleanups().use { cleanups -> + cleanups.register(cache) + val response = sample.hydratedStruct.toExecuteQueryResponse() - startServer().use { server -> + val server = startServer() + cleanups.register(server) val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache = cache) + cleanups.register(dataConnectGrpcRPCs) val request = operationNameVariablesPairArb.bind() server.nextResponse = response @@ -720,40 +770,51 @@ class DataConnectGrpcRPCsUnitTest { @Test fun `connect() lazily sends init request on subscribe`() = runTest { checkAll(propTestConfig, cacheArb().orNull(nullProbability = 0.2)) { cache -> - val server = InProcessDataConnectGrpcStreamingServer() - val cleanupsRegistration = cleanups.register(server) - server.open() - val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) - val callerSdkType = Arb.enum().bind() - - server.events.test { - val stream = dataConnectGrpcRPCs.connect(randomSource()) - expectNoEvents() - - val subscriptionFlow = - stream.subscribe( - "req1", - "opName", - StructProto.getDefaultInstance(), - callerSdkType, - ) - expectNoEvents() + Cleanups().use { cleanups -> + cleanups.register(cache) + + val server = InProcessDataConnectGrpcStreamingServer() + cleanups.register(server) + server.open() + val dataConnectGrpcRPCs = newDataConnectGrpcRPCs(server, cache) + cleanups.register(dataConnectGrpcRPCs) + val callerSdkType = Arb.enum().bind() + + server.events.test { + val stream = dataConnectGrpcRPCs.connect(randomSource()) + expectNoEvents() + + val subscriptionFlow = + stream.subscribe( + "req1", + "opName", + StructProto.getDefaultInstance(), + callerSdkType, + ) + expectNoEvents() - backgroundScope.launch(CallerSdkTypeElement(callerSdkType)) { subscriptionFlow.collect() } - val streamRequest: StreamRequest = awaitUntilInitStreamRequest().streamRequest + val backgroundCollectJob = + backgroundScope.launch(CallerSdkTypeElement(callerSdkType)) { + subscriptionFlow.collect() + } + cleanups.registerSuspending { backgroundCollectJob.cancelAndJoin() } + val streamRequest: StreamRequest = awaitUntilInitStreamRequest().streamRequest - withClue("streamRequest=${streamRequest.print().value}") { - withClue("requestId") { streamRequest.requestId shouldBe "init" } - withClue("name") { streamRequest.name shouldBe dataConnectGrpcRPCs.connectorResourceName } - withClue("requestKindCase") { - streamRequest.requestKindCase shouldBe StreamRequest.RequestKindCase.REQUESTKIND_NOT_SET + withClue("streamRequest=${streamRequest.print().value}") { + withClue("requestId") { streamRequest.requestId shouldBe "init" } + withClue("name") { + streamRequest.name shouldBe dataConnectGrpcRPCs.connectorResourceName + } + withClue("requestKindCase") { + streamRequest.requestKindCase shouldBe + StreamRequest.RequestKindCase.REQUESTKIND_NOT_SET + } } + + backgroundCollectJob.cancelAndJoin() + cancelAndIgnoreRemainingEvents() } - cancelAndIgnoreRemainingEvents() } - - server.close() - cleanups.unregister(cleanupsRegistration) } } @@ -803,7 +864,8 @@ class DataConnectGrpcRPCsUnitTest { } override fun close() { - grpcServer.shutdown() + grpcServer.shutdownNow() + grpcServer.awaitTermination(10, TimeUnit.SECONDS) } } @@ -847,22 +909,18 @@ class DataConnectGrpcRPCsUnitTest { private fun PropertyContext.newDataConnectGrpcRPCsForLocalhostServerOnPort( port: Int, cache: DataConnectCache? - ): DataConnectGrpcRPCs { - val dataConnectGrpcRPCs = - DataConnectGrpcRPCs( - context = RuntimeEnvironment.getApplication(), - host = "localhost:$port", - sslEnabled = false, - connectorResourceName = connectorResourceNameArb.bind(), - nonBlockingCoroutineDispatcher = Dispatchers.Default, - blockingCoroutineDispatcher = Dispatchers.IO, - grpcMetadata = grpcMetadataArb.bind(), - cache = cache, - parentLogger = mockLogger, - ) - cleanups.registerSuspending { dataConnectGrpcRPCs.close() } - return dataConnectGrpcRPCs - } + ): DataConnectGrpcRPCs = + DataConnectGrpcRPCs( + context = RuntimeEnvironment.getApplication(), + host = "localhost:$port", + sslEnabled = false, + connectorResourceName = connectorResourceNameArb.bind(), + nonBlockingCoroutineDispatcher = Dispatchers.Default, + blockingCoroutineDispatcher = Dispatchers.IO, + grpcMetadata = grpcMetadataArb.bind(), + cache = cache, + parentLogger = mockLogger, + ) private fun cacheArb( maxAge: Arb = @@ -940,3 +998,9 @@ private fun listValueFromPath(path: DataConnectPath): ListValueProto { private fun T.sequenced(): SequencedReference = SequencedReference(nextSequenceNumber(), this) + +private fun Cleanups.register(cache: DataConnectCache?) = registerSuspending { cache?.close() } + +private fun Cleanups.register(dataConnectGrpcRPCs: DataConnectGrpcRPCs) = registerSuspending { + dataConnectGrpcRPCs.close() +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Cleanups.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Cleanups.kt new file mode 100644 index 00000000000..a6291aec4d7 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Cleanups.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.firebase.dataconnect.testutil + +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.runBlocking + +class Cleanups : AutoCloseable { + + private val state = AtomicReference(State.Open()) + + fun register(autoCloseable: AutoCloseable) { + register(name = autoCloseable::class.qualifiedName, autoCloseable::close) + } + + fun register(cleanup: () -> Unit) { + register(name = null, cleanup) + } + + fun register(name: String?, cleanup: () -> Unit) { + register(SynchronousCleanup(name, cleanup)) + } + + fun registerSuspending(cleanup: suspend () -> Unit) { + registerSuspending(name = null, cleanup) + } + + fun registerSuspending(name: String?, cleanup: suspend () -> Unit) { + register(SuspendingCleanup(name, cleanup)) + } + + private fun register(cleanup: Cleanup) { + while (true) { + when (val currentState = state.get()) { + is State.Open -> + if (state.compareAndSet(currentState, currentState.withAppended(cleanup))) { + return + } + State.Closed, + State.Closing -> + error( + "failed to register cleanup with name=${cleanup.name}: " + + "close() has been called [n3jsqd4d2f]" + ) + } + } + } + + override fun close() { + val cleanups = transitionToClosing() + + var firstException: Throwable? = null + + cleanups.asReversed().forEach { cleanup -> + val result = runCatching { cleanup.cleanup() } + val exception = result.exceptionOrNull() + if (exception != null) { + println("WARNING [e2cf4gwrrx]: cleanup with name=${cleanup.name} failed: $exception") + if (firstException == null) { + firstException = exception + } else { + firstException.addSuppressed(exception) + } + } + } + + if (!state.compareAndSet(State.Closing, State.Closed)) { + error("internal error pnf3zhderx: transition to closed state failed") + } + + if (firstException != null) { + throw firstException + } + } + + private fun transitionToClosing(): List { + while (true) { + when (val currentState = state.get()) { + State.Closed, + State.Closing -> error("close() has already been called [hfteew3829]") + is State.Open -> + if (state.compareAndSet(currentState, State.Closing)) { + return currentState.cleanups + } + } + } + } + + interface Cleanup { + val name: String? + fun cleanup() + } + + private class SynchronousCleanup(override val name: String?, val action: () -> Unit) : Cleanup { + override fun cleanup() { + action() + } + } + + private class SuspendingCleanup(override val name: String?, val action: suspend () -> Unit) : + Cleanup { + override fun cleanup() { + runBlocking { action() } + } + } + + private sealed interface State { + class Open private constructor(val cleanups: List) : State { + constructor() : this(emptyList()) + + fun withAppended(cleanup: Cleanup) = Open(cleanups.plus(cleanup)) + + override fun toString() = "Open" + } + + object Closing : State { + override fun toString() = "Closing" + } + + object Closed : State { + override fun toString() = "Closed" + } + } +} diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/CleanupsUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/CleanupsUnitTest.kt new file mode 100644 index 00000000000..503a22ce929 --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/CleanupsUnitTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.firebase.dataconnect.testutil + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CleanupsUnitTest { + + @Test + fun `register autoCloseable executes close on close`() { + val cleanups = Cleanups() + val closed = AtomicBoolean(false) + val autoCloseable = AutoCloseable { closed.set(true) } + + cleanups.register(autoCloseable) + closed.get() shouldBe false + + cleanups.close() + closed.get() shouldBe true + } + + @Test + fun `register lambda executes lambda on close`() { + val cleanups = Cleanups() + val closed = AtomicBoolean(false) + + cleanups.register { closed.set(true) } + closed.get() shouldBe false + + cleanups.close() + closed.get() shouldBe true + } + + @Test + fun `register named lambda executes lambda on close`() { + val cleanups = Cleanups() + val closed = AtomicBoolean(false) + + cleanups.register(name = "test-cleanup") { closed.set(true) } + closed.get() shouldBe false + + cleanups.close() + closed.get() shouldBe true + } + + @Test + fun `registerSuspending lambda executes lambda on close`() = runTest { + val cleanups = Cleanups() + val closed = AtomicBoolean(false) + + cleanups.registerSuspending { closed.set(true) } + closed.get() shouldBe false + + cleanups.close() + closed.get() shouldBe true + } + + @Test + fun `registerSuspending named lambda executes lambda on close`() = runTest { + val cleanups = Cleanups() + val closed = AtomicBoolean(false) + + cleanups.registerSuspending(name = "test-suspending") { closed.set(true) } + closed.get() shouldBe false + + cleanups.close() + closed.get() shouldBe true + } + + @Test + fun `cleanups run in reverse order of registration`() { + val cleanups = Cleanups() + val executionOrder = mutableListOf() + + cleanups.register { executionOrder.add("first") } + cleanups.register { executionOrder.add("second") } + cleanups.register { executionOrder.add("third") } + + cleanups.close() + + executionOrder shouldContainExactly listOf("third", "second", "first") + } + + @Test + fun `close multiple times throws IllegalStateException`() { + val cleanups = Cleanups() + cleanups.close() + + val exception = shouldThrow { cleanups.close() } + exception.message shouldContainWithNonAbuttingText "hfteew3829" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "already been called" + } + + @Test + fun `register after close throws IllegalStateException`() { + val cleanups = Cleanups() + cleanups.close() + + val exception = shouldThrow { cleanups.register {} } + exception.message shouldContainWithNonAbuttingText "n3jsqd4d2f" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "failed to register cleanup" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "close() has been called" + } + + @Test + fun `registerSuspending after close throws IllegalStateException`() { + val cleanups = Cleanups() + cleanups.close() + + val exception = shouldThrow { cleanups.registerSuspending {} } + exception.message shouldContainWithNonAbuttingText "n3jsqd4d2f" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "failed to register cleanup" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "close() has been called" + } + + @Test + fun `cleanup throws exception does not prevent other cleanups from running`() { + val cleanups = Cleanups() + val executionOrder = mutableListOf() + + cleanups.register { executionOrder.add("first") } + cleanups.register { throw RuntimeException("fail") } + cleanups.register { executionOrder.add("third") } + + val exception = shouldThrow { cleanups.close() } + exception.message shouldBe "fail" + executionOrder shouldContainExactly listOf("third", "first") + } + + @Test + fun `multiple cleanups throw exception accumulates them as suppressed`() { + val cleanups = Cleanups() + val executionOrder = mutableListOf() + + cleanups.register { executionOrder.add("first") } + cleanups.register { throw RuntimeException("fail 1") } + cleanups.register { throw RuntimeException("fail 2") } + cleanups.register { executionOrder.add("fourth") } + + val exception = shouldThrow { cleanups.close() } + exception.message shouldBe "fail 2" + exception.suppressed.toList().map { it.message } shouldContainExactly listOf("fail 1") + executionOrder shouldContainExactly listOf("fourth", "first") + } +}