From d78e5af79849d3636df465280d450d0d0df900c2 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Thu, 13 Nov 2025 10:26:53 -0500 Subject: [PATCH] Add onSessionCancelled --- workflow-runtime/api/workflow-runtime.api | 4 + .../SimpleLoggingWorkflowInterceptor.kt | 13 +- .../squareup/workflow1/WorkflowInterceptor.kt | 14 + .../internal/ChainedWorkflowInterceptor.kt | 15 + .../workflow1/internal/WorkflowNode.kt | 16 +- .../SimpleLoggingWorkflowInterceptorTest.kt | 16 +- .../workflow1/WorkflowInterceptorTest.kt | 57 ++++ .../ChainedWorkflowInterceptorTest.kt | 2 + .../workflow1/internal/WorkflowNodeTest.kt | 284 +++++++++++++++++- workflow-testing/api/workflow-testing.api | 1 + workflow-tracing/api/workflow-tracing.api | 2 + .../tracing/WorkflowRuntimeMonitor.kt | 15 +- .../tracing/WorkflowRuntimeTracer.kt | 14 + 13 files changed, 432 insertions(+), 21 deletions(-) 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.