lazyDefer introduced
diff --git a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Builders.kt b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Builders.kt
index a8f4c9c..efd1651 100644
--- a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Builders.kt
+++ b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Builders.kt
@@ -21,7 +21,10 @@
* See [newCoroutineContext] for a description of debugging facilities that are available for newly created coroutine.
*/
fun launch(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit): Job =
- StandaloneCoroutine(newCoroutineContext(context)).also { block.startCoroutine(it, it) }
+ StandaloneCoroutine(newCoroutineContext(context)).apply {
+ initParentJob(context[Job])
+ block.startCoroutine(this, this)
+ }
/**
* Calls the specified suspending block with a given coroutine context, suspends until it completes, and returns
@@ -54,10 +57,10 @@
@Throws(InterruptedException::class)
public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
val currentThread = Thread.currentThread()
- val privateEventLoop = if (context[ContinuationInterceptor] == null)
- EventLoopImpl(currentThread) else null
+ val privateEventLoop = if (context[ContinuationInterceptor] == null) EventLoopImpl(currentThread) else null
val newContext = newCoroutineContext(context + (privateEventLoop ?: EmptyCoroutineContext))
val coroutine = BlockingCoroutine<T>(newContext, currentThread, privateEventLoop != null)
+ coroutine.initParentJob(context[Job])
privateEventLoop?.initParentJob(coroutine)
block.startCoroutine(coroutine, coroutine)
return coroutine.joinBlocking()
@@ -66,13 +69,11 @@
// --------------- implementation ---------------
private class StandaloneCoroutine(
- val newContext: CoroutineContext
-) : AbstractCoroutine<Unit>(newContext) {
- init { initParentJob(newContext[Job]) }
-
+ val parentContext: CoroutineContext
+) : AbstractCoroutine<Unit>(parentContext) {
override fun afterCompletion(state: Any?) {
// note the use of the parent's job context below!
- if (state is CompletedExceptionally) handleCoroutineException(newContext, state.cancelReason)
+ if (state is CompletedExceptionally) handleCoroutineException(parentContext, state.cancelReason)
}
}
@@ -84,13 +85,11 @@
}
private class BlockingCoroutine<T>(
- newContext: CoroutineContext,
+ context: CoroutineContext,
val blockedThread: Thread,
val hasPrivateEventLoop: Boolean
-) : AbstractCoroutine<T>(newContext) {
- val eventLoop: EventLoop? = newContext[ContinuationInterceptor] as? EventLoop
-
- init { initParentJob(newContext[Job]) }
+) : AbstractCoroutine<T>(context) {
+ val eventLoop: EventLoop? = context[ContinuationInterceptor] as? EventLoop
override fun afterCompletion(state: Any?) {
if (Thread.currentThread() != blockedThread)
diff --git a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/CoroutineScope.kt b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/CoroutineScope.kt
index 3629f2d..3fc38c0 100644
--- a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/CoroutineScope.kt
+++ b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/CoroutineScope.kt
@@ -25,8 +25,8 @@
* It stores the result of continuation in the state of the job.
*/
@Suppress("LeakingThis")
-public abstract class AbstractCoroutine<in T>(newContext: CoroutineContext) : JobSupport(), Continuation<T>, CoroutineScope {
- override val context: CoroutineContext = newContext + this // merges this job into this context
+public abstract class AbstractCoroutine<in T>(context: CoroutineContext) : JobSupport(), Continuation<T>, CoroutineScope {
+ override val context: CoroutineContext = context + this // merges this job into this context
final override fun resume(value: T) {
while (true) { // lock-free loop on state
diff --git a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Deferred.kt b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Deferred.kt
index 70a5335..1348d3e 100644
--- a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Deferred.kt
+++ b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Deferred.kt
@@ -6,6 +6,7 @@
/**
* Deferred value is conceptually a non-blocking cancellable future.
* It is created with [defer] coroutine builder.
+ * It is in [active][isActive] state while the value is being computed.
*/
public interface Deferred<out T> : Job {
/**
@@ -35,20 +36,33 @@
* in which case the [Job] of the resulting coroutine is a child of the job of the parent coroutine.
*/
public fun <T> defer(context: CoroutineContext, block: suspend CoroutineScope.() -> T) : Deferred<T> =
- DeferredCoroutine<T>(newCoroutineContext(context)).also { block.startCoroutine(it, it) }
+ DeferredCoroutine<T>(newCoroutineContext(context)).apply {
+ initParentJob(context[Job])
+ block.startCoroutine(this, this)
+ }
-private class DeferredCoroutine<T>(
- newContext: CoroutineContext
-) : AbstractCoroutine<T>(newContext), Deferred<T> {
- init { initParentJob(newContext[Job]) }
+internal open class DeferredCoroutine<T>(
+ context: CoroutineContext
+) : AbstractCoroutine<T>(context), Deferred<T> {
+ protected open fun start(): Boolean = false // LazyDeferredCoroutine overrides
@Suppress("UNCHECKED_CAST")
suspend override fun await(): T {
// quick check if already complete (avoid extra object creation)
- val state = getState()
- if (state !is Active) {
- if (state is CompletedExceptionally) throw state.exception
- return state as T
+ getState().let { state ->
+ if (state !is Active) {
+ if (state is CompletedExceptionally) throw state.exception
+ return state as T
+ }
+ }
+ if (start()) { // LazyDeferredCoroutine overrides
+ // recheck state (may have started & already completed
+ getState().let { state ->
+ if (state !is Active) {
+ if (state is CompletedExceptionally) throw state.exception
+ return state as T
+ }
+ }
}
// Note: await is cancellable itself!
return awaitGetValue()
diff --git a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Job.kt b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Job.kt
index f3fd0a3..87f7305 100644
--- a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Job.kt
+++ b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Job.kt
@@ -139,7 +139,7 @@
}
// invoke at most once after construction after all other initialization
- protected fun initParentJob(parent: Job?) {
+ public fun initParentJob(parent: Job?) {
if (parent == null) return
check(registration == null)
// directly pass HandlerNode to parent scope to optimize one closure object (see makeNode)
@@ -155,9 +155,11 @@
expect as ActiveList // assert type
require(update !is Active) // only active -> inactive transition is allowed
if (!STATE.compareAndSet(this, expect, update)) return false
- // #1. Unregister from parent job
+ // #1. Update linked state before invoking completion handlers
+ onStateUpdate(update)
+ // #2. Unregister from parent job
registration?.unregister() // volatile read registration _after_ state was updated
- // #2 Invoke completion handlers
+ // #3. Invoke completion handlers
val reason = (update as? CompletedExceptionally)?.cancelReason
var completionException: Throwable? = null
expect.forEach<JobNode> { node ->
@@ -167,7 +169,7 @@
completionException?.apply { addSuppressed(ex) } ?: run { completionException = ex }
}
}
- // #3 Do other (overridable) processing
+ // #4. Do other (overridable) processing after completion handlers
completionException?.let { handleCompletionException(it) }
afterCompletion(update)
return true
@@ -197,6 +199,11 @@
}
/**
+ * Override to make linked state changes before completion handlers are invoked.
+ */
+ protected open fun onStateUpdate(update: Any?) {}
+
+ /**
* Override to process any exceptions that were encountered while invoking [onCompletion] handlers.
*/
protected open fun handleCompletionException(closeException: Throwable) {
diff --git a/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/LazyDeferred.kt b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/LazyDeferred.kt
new file mode 100644
index 0000000..3121d4a
--- /dev/null
+++ b/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/LazyDeferred.kt
@@ -0,0 +1,112 @@
+package kotlinx.coroutines.experimental
+
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.startCoroutine
+
+/**
+ * Lazy deferred value is conceptually a non-blocking cancellable future that is started on
+ * the first [await] or [start] invocation.
+ * It is created with [lazyDefer] coroutine builder.
+ *
+ * Unlike a simple [Deferred] value, a lazy deferred value has three states:
+ * * _Pending_ -- before the starts of the coroutine ([isActive] is `true`, but [isComputing] is `false`).
+ * * _Computing_ -- while computing the value ([isActive] is `true` and [isComputing] is `true`).
+ * * _Complete_ -- when done computing the value ([isActive] is `false` and [isComputing] is `false`).
+ *
+ * If this lazy deferred value is [cancelled][cancel], then it becomes immediately complete and
+ * cancels ongoing computation coroutine if it was started.
+ */
+public interface LazyDeferred<T> : Deferred<T> {
+ /**
+ * Returns `true` if the coroutine is computing its value.
+ */
+ public val isComputing: Boolean
+
+ /**
+ * Starts coroutine to compute this lazily deferred value. The result `true` if this invocation actually
+ * started coroutine or `false` if it was already started or cancelled.
+ */
+ public fun start(): Boolean
+}
+
+/**
+ * Lazily starts new coroutine on the first [await][Deferred.await] or [start][LazyDeferred.start] invocation
+ * on the resulting [LazyDeferred].
+ * The running coroutine is cancelled when the resulting value is [cancelled][Job.cancel].
+ *
+ * The [context] for the new coroutine must be explicitly specified.
+ * See [CoroutineDispatcher] for the standard [context] implementations that are provided by `kotlinx.coroutines`.
+ * The [context][CoroutineScope.context] of the parent coroutine from its [scope][CoroutineScope] may be used,
+ * in which case the [Job] of the resulting coroutine is a child of the job of the parent coroutine.
+ */
+public fun <T> lazyDefer(context: CoroutineContext, block: suspend CoroutineScope.() -> T) : LazyDeferred<T> =
+ LazyDeferredCoroutine<T>(newCoroutineContext(context), block).apply {
+ initParentJob(context[Job])
+ }
+
+private class LazyDeferredCoroutine<T>(
+ context: CoroutineContext,
+ val block: suspend CoroutineScope.() -> T
+) : DeferredCoroutine<T>(context), LazyDeferred<T> {
+
+ @Volatile
+ var lazyState: Int = STATE_PENDING
+
+ companion object {
+ private val STATE_PENDING = 0
+ private val STATE_COMPUTING = 1
+ private val STATE_COMPLETE = 2
+
+ private val LAZY_STATE: AtomicIntegerFieldUpdater<LazyDeferredCoroutine<*>> =
+ AtomicIntegerFieldUpdater.newUpdater(LazyDeferredCoroutine::class.java, "lazyState")
+ }
+
+ /*
+ === State linking & linearization of the overall state ===
+
+ There are two state variables in this object and they have to update atomically from the standpoint of
+ external observer:
+ 1. Job.state that is used by isActive function.
+ 2. lazyState that is used to make sure coroutine starts at most once.
+ External observer must see only three states, not four, i.e. it should not be able
+ to see `isActive == false`, but `isComputing == true`.
+
+ On completion/cancellation state variables are updated in this order:
+ a) state <- complete (isComplete starts returning true)
+ b) lazyState <- STATE_COMPLETE (see onStateUpdate)
+ This is why, `isComputing` checks state variables in reverse order:
+ a) lazyState is checked _first_
+ b) isActive is checked after it
+ This way cancellation/completion is atomic w.r.t to all state functions.
+
+ `start` function also has to check lazyState _before_ isActive.
+ */
+
+ override val isComputing: Boolean get() = lazyState == STATE_COMPUTING && isActive
+
+ override fun start(): Boolean {
+ while (true) { // lock-free loop on lazyState
+ when (lazyState) { // volatile read
+ STATE_PENDING -> {
+ if (isActive) { // then volatile read Job.state (inside isActive)
+ // can try to start
+ if (LAZY_STATE.compareAndSet(this, STATE_PENDING, STATE_COMPUTING)) {
+ block.startCoroutine(this, this)
+ return true
+ }
+ } else {
+ // cannot start -- already complete -- help update lazyState
+ lazyState = STATE_COMPLETE
+ return false
+ }
+ }
+ else -> return false
+ }
+ }
+ }
+
+ override fun onStateUpdate(update: Any?) {
+ lazyState = STATE_COMPLETE
+ }
+}
diff --git a/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/CoroutinesTest.kt b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/CoroutinesTest.kt
index b2b2b11..c387047 100644
--- a/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/CoroutinesTest.kt
+++ b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/CoroutinesTest.kt
@@ -1,37 +1,9 @@
package kotlinx.coroutines.experimental
-import org.junit.After
import org.junit.Test
import java.io.IOException
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
-import java.util.concurrent.atomic.AtomicReference
-class CoroutinesTest {
- var actionIndex = AtomicInteger()
- var finished = AtomicBoolean()
- var error = AtomicReference<Throwable>()
-
- fun expect(index: Int) {
- val wasIndex = actionIndex.incrementAndGet()
- check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" }
- }
-
- fun expectUnreached() {
- throw IllegalStateException("Should not be reached").also { error.compareAndSet(null, it) }
- }
-
- fun finish(index: Int) {
- expect(index)
- finished.set(true)
- }
-
- @After
- fun onCompletion() {
- error.get()?.let { throw it }
- check(finished.get()) { "Expecting that 'finish(...)' was invoked, but it was not" }
- }
-
+class CoroutinesTest : TestBase() {
@Test
fun testSimple() = runBlocking {
expect(1)
@@ -187,53 +159,4 @@
}
finish(5)
}
-
- @Test
- fun testDeferSimple(): Unit = runBlocking {
- expect(1)
- val d = defer(context) {
- expect(2)
- 42
- }
- expect(3)
- check(d.await() == 42)
- finish(4)
- }
-
- @Test
- fun testDeferAndYield(): Unit = runBlocking {
- expect(1)
- val d = defer(context) {
- expect(2)
- yield()
- expect(4)
- 42
- }
- expect(3)
- check(d.await() == 42)
- finish(5)
- }
-
- @Test(expected = IOException::class)
- fun testDeferSimpleException(): Unit = runBlocking {
- expect(1)
- val d = defer(context) {
- expect(2)
- throw IOException()
- }
- finish(3)
- d.await() // will throw IOException
- }
-
- @Test(expected = IOException::class)
- fun testDeferAndYieldException(): Unit = runBlocking {
- expect(1)
- val d = defer(context) {
- expect(2)
- yield()
- throw IOException()
- }
- finish(3)
- d.await() // will throw IOException
- }
}
diff --git a/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/DeferTest.kt b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/DeferTest.kt
new file mode 100644
index 0000000..c63821e
--- /dev/null
+++ b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/DeferTest.kt
@@ -0,0 +1,63 @@
+package kotlinx.coroutines.experimental
+
+import org.junit.Test
+import java.io.IOException
+
+class DeferTest : TestBase() {
+ @Test
+ fun testSimple(): Unit = runBlocking {
+ expect(1)
+ val d = defer(context) {
+ expect(2)
+ 42
+ }
+ expect(3)
+ check(!d.isActive)
+ check(d.await() == 42)
+ expect(4)
+ check(d.await() == 42) // second await -- same result
+ finish(5)
+ }
+
+ @Test
+ fun testDeferAndYield(): Unit = runBlocking {
+ expect(1)
+ val d = defer(context) {
+ expect(2)
+ yield()
+ expect(4)
+ 42
+ }
+ expect(3)
+ check(d.isActive)
+ check(d.await() == 42)
+ check(!d.isActive)
+ expect(5)
+ check(d.await() == 42) // second await -- same result
+ finish(6)
+ }
+
+ @Test(expected = IOException::class)
+ fun testSimpleException(): Unit = runBlocking {
+ expect(1)
+ val d = defer(context) {
+ expect(2)
+ throw IOException()
+ }
+ finish(3)
+ d.await() // will throw IOException
+ }
+
+ @Test(expected = IOException::class)
+ fun testDeferAndYieldException(): Unit = runBlocking {
+ expect(1)
+ val d = defer(context) {
+ expect(2)
+ yield()
+ finish(4)
+ throw IOException()
+ }
+ expect(3)
+ d.await() // will throw IOException
+ }
+}
\ No newline at end of file
diff --git a/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/LazyDeferTest.kt b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/LazyDeferTest.kt
new file mode 100644
index 0000000..f3fecbf
--- /dev/null
+++ b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/LazyDeferTest.kt
@@ -0,0 +1,166 @@
+package kotlinx.coroutines.experimental
+
+import org.junit.Test
+import java.io.IOException
+
+class LazyDeferTest : TestBase() {
+ @Test
+ fun testSimple(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ check(d.await() == 42)
+ check(!d.isActive && !d.isComputing)
+ expect(4)
+ check(d.await() == 42) // second await -- same result
+ finish(5)
+ }
+
+ @Test
+ fun testLazyDeferAndYield(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ yield() // this has not effect, because parent coroutine is waiting
+ expect(4)
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ check(d.await() == 42)
+ check(!d.isActive && !d.isComputing)
+ expect(5)
+ check(d.await() == 42) // second await -- same result
+ finish(6)
+ }
+
+ @Test
+ fun testLazyDeferAndYield2(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(5)
+ yield() // yield to the second child
+ expect(7)
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ launch(context) { // see how it looks from another coroutine
+ expect(3)
+ check(d.isActive && !d.isComputing)
+ yield()
+ expect(6)
+ check(d.isActive && d.isComputing)
+ }
+ expect(4)
+ check(d.isActive && !d.isComputing)
+ check(d.await() == 42)
+ check(!d.isActive && !d.isComputing)
+ finish(8)
+ }
+
+ @Test(expected = IOException::class)
+ fun testSimpleException(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ finish(3)
+ throw IOException()
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ d.await() // will throw IOException
+ }
+
+ @Test(expected = IOException::class)
+ fun testLazyDeferAndYieldException(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ yield() // this has not effect, because parent coroutine is waiting
+ finish(4)
+ throw IOException()
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ d.await() // will throw IOException
+ }
+
+ @Test
+ fun testCatchException(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ throw IOException()
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ try {
+ d.await() // will throw IOException
+ } catch (e: IOException) {
+ check(!d.isActive && !d.isComputing)
+ expect(4)
+ }
+ finish(5)
+ }
+
+ @Test
+ fun testStart(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ check(d.start())
+ check(!d.isActive && !d.isComputing)
+ expect(4)
+ check(!d.start())
+ check(d.await() == 42) // await sees result
+ finish(5)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun testCancelBeforeStart(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expectUnreached()
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ check(d.cancel())
+ check(!d.isActive && !d.isComputing)
+ check(!d.cancel())
+ check(!d.start())
+ finish(3)
+ check(d.await() == 42) // await shall throw CancellationException
+ expectUnreached()
+ }
+
+ @Test(expected = CancellationException::class)
+ fun testCancelWhileComputing(): Unit = runBlocking {
+ expect(1)
+ val d = lazyDefer(context) {
+ expect(3)
+ yield()
+ expectUnreached()
+ 42
+ }
+ expect(2)
+ check(d.isActive && !d.isComputing)
+ check(d.start())
+ check(d.isActive && d.isComputing)
+ expect(4)
+ check(d.cancel())
+ check(!d.isActive && !d.isComputing)
+ check(!d.cancel())
+ finish(5)
+ check(d.await() == 42) // await shall throw CancellationException
+ expectUnreached()
+ }
+}
\ No newline at end of file
diff --git a/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/TestBase.kt b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/TestBase.kt
new file mode 100644
index 0000000..fc861ba
--- /dev/null
+++ b/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/TestBase.kt
@@ -0,0 +1,33 @@
+package kotlinx.coroutines.experimental
+
+import org.junit.After
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+open class TestBase {
+ var actionIndex = AtomicInteger()
+ var finished = AtomicBoolean()
+ var error = AtomicReference<Throwable>()
+
+ fun expect(index: Int) {
+ val wasIndex = actionIndex.incrementAndGet()
+ check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" }
+ }
+
+ fun expectUnreached() {
+ throw IllegalStateException("Should not be reached").also { error.compareAndSet(null, it) }
+ }
+
+ fun finish(index: Int) {
+ expect(index)
+ finished.set(true)
+ }
+
+ @After
+ fun onCompletion() {
+ error.get()?.let { throw it }
+ check(finished.get()) { "Expecting that 'finish(...)' was invoked, but it was not" }
+ }
+
+}
\ No newline at end of file