diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api
index 9e49aaab49..fd61348223 100644
--- a/workflow-runtime/api/workflow-runtime.api
+++ b/workflow-runtime/api/workflow-runtime.api
@@ -5,6 +5,7 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -34,6 +35,7 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -58,6 +60,7 @@ public abstract interface class com/squareup/workflow1/WorkflowInterceptor {
public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public abstract fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public abstract fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public abstract fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public abstract fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public abstract fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public abstract fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -69,6 +72,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls {
public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public static fun onRenderAndSnapshot (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public static fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public static fun onSessionCancelled (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public static fun onSessionStarted (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public static fun onSnapshotState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public static fun onSnapshotStateWithChildren (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt
index 58bb7af703..95b38f0cf0 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt
@@ -2,8 +2,8 @@ package com.squareup.workflow1
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
/**
* A [WorkflowInterceptor] that just prints all method calls using [log].
@@ -14,9 +14,14 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor {
session: WorkflowSession
) {
invokeSafely("logBeforeMethod") { logBeforeMethod("onInstanceStarted", session) }
- workflowScope.coroutineContext[Job]!!.invokeOnCompletion {
- invokeSafely("logAfterMethod") { logAfterMethod("onInstanceStarted", session) }
- }
+ }
+
+ override fun
onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ invokeSafely("logAfterMethod") { logAfterMethod("onInstanceStarted", session) }
}
override fun onInitialState(
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt
index 2347b3531a..d10a3b2682 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt
@@ -2,6 +2,7 @@ package com.squareup.workflow1
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
@@ -75,6 +76,19 @@ public interface WorkflowInterceptor {
session: WorkflowSession
): Unit = Unit
+ /**
+ * Called when the session is ending, when the Workflow's [CoroutineScope] is being cancelled.
+ *
+ * @param cause The cause of the cancellation if non-null.
+ * @param droppedActions Any actions that were queued in this node's channel at the time of
+ * cancellation.
+ */
+ public fun
onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ): Unit = Unit
+
/**
* Intercepts calls to [StatefulWorkflow.initialState].
*/
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt
index f5c4052277..4be61b63e9 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt
@@ -11,6 +11,7 @@ import com.squareup.workflow1.WorkflowInterceptor
import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor
import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlin.reflect.KType
@@ -32,6 +33,20 @@ internal class ChainedWorkflowInterceptor(
interceptors.forEach { it.onSessionStarted(workflowScope, session) }
}
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ interceptors.forEach {
+ it.onSessionCancelled(
+ cause = cause,
+ droppedActions = droppedActions,
+ session = session
+ )
+ }
+ }
+
override fun onInitialState(
props: P,
snapshot: Snapshot?,
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
index 82d7ebe7ce..1c82b4e171 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
@@ -265,10 +265,22 @@ internal class WorkflowNode(
/**
* Cancels this state machine host, and any coroutines started as children of it.
*
- * This must be called when the caller will no longer call [registerTreeActionSelectors]. It is an error to call [registerTreeActionSelectors]
- * after calling this method.
+ * This must be called when the caller will no longer call [registerTreeActionSelectors].
+ * It is an error to call [registerTreeActionSelectors] after calling this method.
*/
fun cancel(cause: CancellationException? = null) {
+ val hangingActions = mutableListOf>()
+ // This will only be non-null if there is an action buffered and ready.
+ var nextAction = eventActionsChannel.tryReceive().getOrNull()
+ while (nextAction != null) {
+ hangingActions.add(nextAction)
+ nextAction = eventActionsChannel.tryReceive().getOrNull()
+ }
+ interceptor.onSessionCancelled(
+ cause = cause,
+ droppedActions = hangingActions,
+ session = this
+ )
coroutineContext.cancel(cause)
lastRendering = NullableInitBox()
}
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt
index 44eddb03f5..b3455cbf3b 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptorTest.kt
@@ -19,7 +19,21 @@ internal class SimpleLoggingWorkflowInterceptorTest {
interceptor.onSessionStarted(scope, TestWorkflowSession)
scope.cancel()
- assertEquals(ErrorLoggingInterceptor.EXPECTED_ERRORS, interceptor.errors)
+ // Only the first, since we don't get cancellation directly from the scope cancellation.
+ // For that we use onSessionCancelled()
+ assertEquals(listOf(ErrorLoggingInterceptor.EXPECTED_ERRORS.first()), interceptor.errors)
+ }
+
+ @Test fun onSessionCancelled_handles_logging_exceptions() {
+ val interceptor = ErrorLoggingInterceptor()
+ interceptor.onSessionCancelled(
+ cause = null,
+ droppedActions = emptyList(),
+ session = TestWorkflowSession
+ )
+
+ // Only the second error, since onSessionCancelled only calls logAfterMethod
+ assertEquals(listOf(ErrorLoggingInterceptor.EXPECTED_ERRORS.last()), interceptor.errors)
}
@Test fun onInitialState_handles_logging_exceptions() {
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt
index b9c09d349c..c6f7bce89b 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/WorkflowInterceptorTest.kt
@@ -175,6 +175,63 @@ internal class WorkflowInterceptorTest {
intercepted.render("props", "string", RenderContext(fakeContext, workflow))
}
+ @Test fun intercept_intercepts_onSessionCancelled() {
+ val recorder = RecordingWorkflowInterceptor()
+ val session = object : WorkflowSession {
+ override val identifier: WorkflowIdentifier = TestWorkflow.identifier
+ override val renderKey: String = ""
+ override val sessionId: Long = 0
+ override val parent: WorkflowSession? = null
+ override val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG
+ override val workflowTracer: WorkflowTracer? = null
+ override val runtimeContext: CoroutineContext = EmptyCoroutineContext
+ }
+
+ recorder.onSessionCancelled(
+ cause = null,
+ droppedActions = emptyList(),
+ session = session
+ )
+
+ // SimpleLoggingWorkflowInterceptor logs "onInstanceStarted" for onSessionCancelled
+ assertEquals(
+ listOf("END|onInstanceStarted"),
+ recorder.consumeEventNames()
+ )
+ }
+
+ @Test fun intercept_passes_dropped_actions_to_onSessionCancelled() {
+ var capturedDroppedActions: List>? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: kotlinx.coroutines.CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ }
+ }
+ val session = object : WorkflowSession {
+ override val identifier: WorkflowIdentifier = TestWorkflow.identifier
+ override val renderKey: String = ""
+ override val sessionId: Long = 0
+ override val parent: WorkflowSession? = null
+ override val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG
+ override val workflowTracer: WorkflowTracer? = null
+ override val runtimeContext: CoroutineContext = EmptyCoroutineContext
+ }
+ val testAction = action("TestAction") { state = "modified" }
+
+ interceptor.onSessionCancelled(
+ cause = null,
+ droppedActions = listOf(testAction),
+ session = session
+ )
+
+ assertEquals(1, capturedDroppedActions!!.size)
+ assertEquals("TestAction", capturedDroppedActions!![0].debuggingName)
+ }
+
private val Workflow<*, *, *>.session: WorkflowSession
get() = object : WorkflowSession {
override val identifier: WorkflowIdentifier = this@session.identifier
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt
index a8f88aca42..ad293f5aa3 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptorTest.kt
@@ -70,6 +70,8 @@ internal class ChainedWorkflowInterceptorTest {
session: WorkflowSession
) {
events += "started1"
+ // We can't use onSessionCancelled because this is completed when the coroutine from
+ // launch() below finishes, so onSessionCancelled is never called by the runtime.
workflowScope.coroutineContext[Job]!!.invokeOnCompletion {
events += "cancelled1"
}
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
index bbe7d70ea7..525ff06adb 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
@@ -802,9 +802,14 @@ internal class WorkflowNodeTest {
) {
interceptedScope = workflowScope
interceptedSession = session
- workflowScope.coroutineContext[Job]!!.invokeOnCompletion {
- cancellationException = it!!
- }
+ }
+
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ cancellationException = cause!!
}
}
val workflow = Workflow.rendering(Unit)
@@ -843,9 +848,14 @@ internal class WorkflowNodeTest {
) {
interceptedScope = workflowScope
interceptedSession = session
- workflowScope.coroutineContext[Job]!!.invokeOnCompletion {
- cancellationException = it!!
- }
+ }
+
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ cancellationException = cause!!
}
}
val workflow = Workflow.rendering(Unit)
@@ -1182,8 +1192,8 @@ internal class WorkflowNodeTest {
val workflow = Workflow.stateful>>(
initialState = { "initial" },
render = { _, renderState ->
- renderState to actionSink.contraMap {
- action("") { state = "$state->$it" }
+ renderState to actionSink.contraMap { value ->
+ action("") { state = "$state->$value" }
}
}
)
@@ -1413,6 +1423,264 @@ internal class WorkflowNodeTest {
assertEquals(3, third)
}
+ @Test fun cancel_with_no_pending_actions_returns_empty_list() {
+ var capturedDroppedActions: List>? = null
+ var capturedCause: CancellationException? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ capturedCause = cause
+ }
+ }
+ val workflow = Workflow.stateless {}
+ val node = WorkflowNode(
+ id = workflow.id(),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ val cause = CancellationException("test cancellation")
+ node.cancel(cause)
+
+ assertSame(cause, capturedCause)
+ assertEquals(emptyList(), capturedDroppedActions)
+ }
+
+ @Test fun cancel_with_single_pending_action_returns_that_action() {
+ var capturedDroppedActions: List>? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ }
+ }
+ lateinit var capturedSink: Sink
+ val workflow = Workflow.stateless> {
+ val sink = actionSink.contraMap { value: String ->
+ action("TestAction($value)") { setOutput(value) }
+ }
+ capturedSink = sink
+ sink
+ }
+ val node = WorkflowNode(
+ id = workflow.id(),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ capturedSink.send("action1")
+
+ node.cancel()
+
+ assertEquals(1, capturedDroppedActions!!.size)
+ assertTrue(capturedDroppedActions!![0].debuggingName.startsWith("TestAction(action1)"))
+ }
+
+ @Test fun cancel_with_multiple_pending_actions_returns_all_in_order() {
+ var capturedDroppedActions: List>? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ }
+ }
+ lateinit var capturedSink: Sink
+ val workflow = Workflow.stateless> {
+ val sink = actionSink.contraMap { value: String ->
+ action("TestAction($value)") { setOutput(value) }
+ }
+ capturedSink = sink
+ sink
+ }
+ val node = WorkflowNode(
+ id = workflow.id(),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ capturedSink.send("action1")
+ capturedSink.send("action2")
+ capturedSink.send("action3")
+
+ node.cancel()
+
+ assertEquals(3, capturedDroppedActions!!.size)
+ assertTrue(capturedDroppedActions!![0].debuggingName.startsWith("TestAction(action1)"))
+ assertTrue(capturedDroppedActions!![1].debuggingName.startsWith("TestAction(action2)"))
+ assertTrue(capturedDroppedActions!![2].debuggingName.startsWith("TestAction(action3)"))
+ }
+
+ @Test fun cancel_does_not_apply_pending_actions() {
+ var capturedDroppedActions: List>? = null
+ var actionApplied = false
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ }
+ }
+ lateinit var capturedSink: Sink
+ val workflow = Workflow.stateful>(
+ initialState = { "initial" },
+ render = { _, state ->
+ val sink = actionSink.contraMap { value: String ->
+ action("TestAction($value)") {
+ actionApplied = true
+ this.state = "$state->$value"
+ }
+ }
+ capturedSink = sink
+ sink
+ }
+ )
+ val node = WorkflowNode(
+ id = workflow.id(),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ capturedSink.send("action1")
+
+ assertFalse(actionApplied)
+ node.cancel()
+
+ // Action should not have been applied
+ assertFalse(actionApplied)
+ assertEquals(1, capturedDroppedActions!!.size)
+ }
+
+ @Test fun cancel_with_null_cause_passes_null_to_interceptor() {
+ var capturedCause: CancellationException? = CancellationException("placeholder")
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedCause = cause
+ }
+ }
+ val workflow = Workflow.stateless {}
+ val node = WorkflowNode(
+ id = workflow.id(),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ node.cancel(null)
+
+ assertNull(capturedCause)
+ }
+
+ @Test fun cancel_passes_correct_session_to_interceptor() {
+ var capturedSession: WorkflowSession? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedSession = session
+ }
+ }
+ val workflow = Workflow.stateless {}
+ val node = WorkflowNode(
+ id = workflow.id(key = "test-key"),
+ workflow = workflow.asStatefulWorkflow(),
+ initialProps = Unit,
+ snapshot = null,
+ interceptor = interceptor,
+ baseContext = Unconfined,
+ parent = TestSession(99)
+ )
+
+ node.render(workflow.asStatefulWorkflow(), Unit)
+ node.cancel()
+
+ assertEquals(workflow.identifier, capturedSession!!.identifier)
+ assertEquals("test-key", capturedSession!!.renderKey)
+ assertEquals(99, capturedSession!!.parent!!.sessionId)
+ }
+
+ @Test fun cancel_with_event_handler_actions_returns_them() {
+ var capturedDroppedActions: List>? = null
+ val interceptor = object : WorkflowInterceptor {
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ capturedDroppedActions = droppedActions
+ }
+ }
+ val workflow = object : StringEventWorkflow() {
+ override fun initialState(
+ props: String,
+ snapshot: Snapshot?
+ ): String = props
+
+ override fun render(
+ renderProps: String,
+ renderState: String,
+ context: RenderContext
+ ): (String) -> Unit {
+ return context.eventHandler("handler") { event -> setOutput(event) }
+ }
+ }
+ val node = WorkflowNode(
+ workflow.id(),
+ workflow,
+ "",
+ null,
+ context,
+ interceptor = interceptor
+ )
+
+ val eventSink = node.render(workflow, "")
+ eventSink("event1")
+ eventSink("event2")
+
+ node.cancel()
+
+ assertEquals(2, capturedDroppedActions!!.size)
+ // Event handler actions have a specific format
+ assertTrue(capturedDroppedActions!![0].debuggingName.startsWith("eH: handler"))
+ assertTrue(capturedDroppedActions!![1].debuggingName.startsWith("eH: handler"))
+ }
+
private class TestSession(override val sessionId: Long = 0) : WorkflowSession {
override val identifier: WorkflowIdentifier = Workflow.rendering(Unit).identifier
override val renderKey: String = ""
diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api
index 0c82de88a5..a4b8eea512 100644
--- a/workflow-testing/api/workflow-testing.api
+++ b/workflow-testing/api/workflow-testing.api
@@ -5,6 +5,7 @@ public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
diff --git a/workflow-tracing/api/workflow-tracing.api b/workflow-tracing/api/workflow-tracing.api
index 73d9a9b6a8..23045dccdf 100644
--- a/workflow-tracing/api/workflow-tracing.api
+++ b/workflow-tracing/api/workflow-tracing.api
@@ -172,6 +172,7 @@ public final class com/squareup/workflow1/tracing/WorkflowRuntimeMonitor : com/s
public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot;
public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
+ public fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
@@ -212,6 +213,7 @@ public abstract class com/squareup/workflow1/tracing/WorkflowRuntimeTracer : com
public fun onRootPropsChanged (Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public final fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V
public fun onRuntimeUpdateEnhanced (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;ZLcom/squareup/workflow1/tracing/ConfigSnapshot;)V
+ public final fun onSessionCancelled (Ljava/util/concurrent/CancellationException;Ljava/util/List;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public final fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V
public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot;
public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot;
diff --git a/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeMonitor.kt b/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeMonitor.kt
index 318659cca3..45d938dea9 100644
--- a/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeMonitor.kt
+++ b/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeMonitor.kt
@@ -29,9 +29,9 @@ import com.squareup.workflow1.tracing.RenderCause.RootPropsChanged
import com.squareup.workflow1.tracing.RenderCause.WaitingForOutput
import com.squareup.workflow1.tracing.WorkflowRuntimeMonitor.ActionType.CascadeAction
import com.squareup.workflow1.tracing.WorkflowRuntimeMonitor.ActionType.QueuedAction
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
import kotlin.time.Duration.Companion.nanoseconds
/**
@@ -84,12 +84,15 @@ public class WorkflowRuntimeMonitor(
) {
onWorkflowStarted(session)
chainedWorkflowRuntimeTracer.onWorkflowSessionStarted(workflowScope, session)
+ }
- val workflowJob = workflowScope.coroutineContext[Job]!!
- workflowJob.invokeOnCompletion {
- onWorkflowStopped(session.sessionId)
- chainedWorkflowRuntimeTracer.onWorkflowSessionStopped(session.sessionId)
- }
+ override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ onWorkflowStopped(session.sessionId)
+ chainedWorkflowRuntimeTracer.onWorkflowSessionStopped(session.sessionId)
}
/**
diff --git a/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeTracer.kt b/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeTracer.kt
index 43ffb89718..8edc4208d4 100644
--- a/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeTracer.kt
+++ b/workflow-tracing/src/main/java/com/squareup/workflow1/tracing/WorkflowRuntimeTracer.kt
@@ -1,9 +1,11 @@
package com.squareup.workflow1.tracing
import androidx.collection.LongObjectMap
+import com.squareup.workflow1.WorkflowAction
import com.squareup.workflow1.WorkflowInterceptor
import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
/**
@@ -97,6 +99,18 @@ public abstract class WorkflowRuntimeTracer : WorkflowInterceptor {
super.onSessionStarted(workflowScope, session)
}
+ /**
+ * Prevents [WorkflowRuntimeTracer]s from overriding this method, they should use
+ * [onWorkflowSessionStopped] instead.
+ */
+ final override fun onSessionCancelled(
+ cause: CancellationException?,
+ droppedActions: List>,
+ session: WorkflowSession
+ ) {
+ super.onSessionCancelled(cause, droppedActions, session)
+ }
+
/**
* Prevent [WorkflowRuntimeTracer] from overriding this function, as they should use
* [onRuntimeUpdateEnhanced] instead.