Skip to content
This repository was archived by the owner on Jan 28, 2025. It is now read-only.

Commit 31545dd

Browse files
author
Gregory Lureau
committed
Support 'suspend' methods with cooperative cancellation via AbortController/AbortSignal.
1 parent 60128e4 commit 31545dd

File tree

8 files changed

+176
-52
lines changed

8 files changed

+176
-52
lines changed

compiler/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ val kspVersion: String by project
1313
dependencies {
1414

1515
implementation(project(":lib"))
16+
implementation(project(":lib-coroutines"))
1617
implementation("com.squareup:kotlinpoet:1.10.2") {
1718
exclude(module = "kotlin-reflect")
1819
}

compiler/src/main/kotlin/deezer/kustomexport/compiler/js/CoroutinesExt.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,14 @@ import com.squareup.kotlinpoet.TypeName
2525
val coroutinesGlobalScope = ClassName("kotlinx.coroutines", "GlobalScope")
2626
val coroutinesPromiseFunc = MemberName("kotlinx.coroutines", "promise")
2727
val coroutinesAwait = MemberName("kotlinx.coroutines", "await")
28+
val coroutinesJob = ClassName("kotlinx.coroutines", "Job")
29+
30+
//val coroutinesScope = ClassName("kotlinx.coroutines", "CoroutineScope")
31+
val coroutinesContext = MemberName("kotlin.coroutines", "coroutineContext")
32+
val coroutinesContextJob = MemberName("kotlinx.coroutines", "job")
33+
val coroutinesCancellationException = ClassName("kotlinx.coroutines", "CancellationException")
2834
val coroutinesPromise = ClassName("kotlin.js", "Promise")
35+
val abortController = ClassName("", "AbortController")
36+
val abortSignal = ClassName("", "AbortSignal")
37+
2938
fun TypeName.asCoroutinesPromise() = coroutinesPromise.parameterizedBy(this)

compiler/src/main/kotlin/deezer/kustomexport/compiler/js/pattern/SharedTransformer.kt

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ import deezer.kustomexport.compiler.js.FormatString
2525
import deezer.kustomexport.compiler.js.FunctionDescriptor
2626
import deezer.kustomexport.compiler.js.MethodNameDisambiguation
2727
import deezer.kustomexport.compiler.js.PropertyDescriptor
28+
import deezer.kustomexport.compiler.js.abortController
29+
import deezer.kustomexport.compiler.js.abortSignal
2830
import deezer.kustomexport.compiler.js.asCoroutinesPromise
2931
import deezer.kustomexport.compiler.js.coroutinesAwait
32+
import deezer.kustomexport.compiler.js.coroutinesContext
33+
import deezer.kustomexport.compiler.js.coroutinesContextJob
3034
import deezer.kustomexport.compiler.js.coroutinesGlobalScope
3135
import deezer.kustomexport.compiler.js.coroutinesPromiseFunc
3236
import deezer.kustomexport.compiler.js.mapping.INDENTATION
@@ -65,14 +69,35 @@ fun FunctionDescriptor.buildWrappingFunction(
6569
fb.addParameter(param.name, param.type.exportedTypeName)
6670
}
6771
}
72+
if (!import && isSuspend) {
73+
fb.addParameter("abortSignal", abortSignal)
74+
}
6875

6976
if (body) {
70-
if (!import && isSuspend)fb.addCode("return %T.%M·{\n", coroutinesGlobalScope, coroutinesPromiseFunc)
77+
if (!import && isSuspend) {
78+
fb.addCode("return %T.%M·{\n", coroutinesGlobalScope, coroutinesPromiseFunc)
79+
}
80+
if (import && isSuspend) {
81+
fb.addStatement("val abortController = %T()", abortController)
82+
fb.addStatement("val abortSignal = abortController.signal")
83+
fb.addStatement(
84+
"%M.%M.invokeOnCompletion { abortController.abort() }",
85+
coroutinesContext,
86+
coroutinesContextJob
87+
)
88+
}
89+
if (!import && isSuspend) {
90+
fb.addStatement("abortSignal.onabort = { %M.%M.cancel() }", coroutinesContext, coroutinesContextJob)
91+
}
7192

7293
val funcName = if (import) funExportedName else name
73-
val params = parameters.fold(FormatString("")) { acc, item ->
94+
var params = parameters.fold(FormatString("")) { acc, item ->
7495
acc + "$INDENTATION${item.name} = ".toFormatString() + item.portMethod(!import) + ",\n"
7596
}
97+
if (import && isSuspend) {
98+
params += FormatString("${INDENTATION}abortSignal = abortSignal")
99+
}
100+
76101
//TODO: Opti : could save the local "result" variable here
77102
fb.addCode(
78103
("val result = $delegateName.$funcName(".toFormatString() +
@@ -86,6 +111,7 @@ fun FunctionDescriptor.buildWrappingFunction(
86111
returnType.portMethod(import, "result".toFormatString()) +
87112
(if (import && isSuspend) ".%M()".toFormatString(coroutinesAwait) else "".toFormatString())).asCode()
88113
)
114+
89115
if (!import && isSuspend) fb.addCode("\n}")
90116
}
91117
return fb.build()

lib-coroutines/build.gradle.kts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
plugins {
2+
kotlin("multiplatform")
3+
}
4+
5+
kotlin {
6+
js(IR) {
7+
browser()
8+
}
9+
jvm()
10+
ios()
11+
iosSimulatorArm64()
12+
tvos()
13+
watchos()
14+
15+
sourceSets {
16+
all {
17+
languageSettings.optIn("kotlin.RequiresOptIn")
18+
languageSettings.optIn("kotlin.js.ExperimentalJsExport")
19+
}
20+
}
21+
22+
targets.all {
23+
compilations.all {
24+
// Cannot enable rn due to native issue (stdlib included more than once)
25+
// may be related to https://youtrack.jetbrains.com/issue/KT-46636
26+
kotlinOptions.allWarningsAsErrors = false
27+
}
28+
}
29+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2022 Deezer.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing,
11+
* software distributed under the License is distributed on an
12+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
* KIND, either express or implied. See the License for the
14+
* specific language governing permissions and limitations
15+
* under the License.
16+
*/
17+
//package org.w3c.fetch // I want to rely on the external type, no need for namespace here
18+
19+
// Inspired from https://github.com/JetBrains/kotlin-wrappers/blob/master/kotlin-browser/src/main/kotlin/org/w3c/fetch/AbortController.kt
20+
21+
external class AbortController {
22+
/**
23+
* Returns the AbortSignal object associated with this object.
24+
*/
25+
val signal: AbortSignal
26+
27+
/**
28+
* Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.
29+
*/
30+
fun abort()
31+
}
32+
33+
/** A signal object that allows you to communicate with a DOM request (such as a Fetch) and abort it if required via an AbortController object. */
34+
external class AbortSignal {
35+
/**
36+
* Returns true if this AbortSignal's AbortController has signaled to abort, and false otherwise.
37+
*/
38+
val aborted: Boolean
39+
var onabort: ((event: dynamic) -> Unit)?
40+
}

samples/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ kotlin {
2727

2828
dependencies {
2929
implementation(project(":lib"))
30+
implementation(project(":lib-coroutines"))
3031
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
3132
}
3233
}

samples/src/commonMain/kotlin/sample/coroutines/Coroutines.kt

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,29 @@
1818
package sample.coroutines
1919

2020
import deezer.kustomexport.KustomExport
21-
import kotlinx.coroutines.CancellationException
2221
import kotlinx.coroutines.CoroutineScope
2322
import kotlinx.coroutines.Dispatchers
2423
import kotlinx.coroutines.SupervisorJob
2524
import kotlinx.coroutines.async
2625
import kotlinx.coroutines.delay
27-
import kotlinx.coroutines.ensureActive
28-
import kotlinx.coroutines.isActive
2926
import kotlinx.coroutines.job
3027
import kotlinx.coroutines.launch
3128
import kotlinx.coroutines.withContext
3229
import kotlinx.coroutines.withTimeout
3330
import kotlin.coroutines.coroutineContext
34-
import kotlin.js.Date
3531

3632
@KustomExport
3733
interface IComputer {
38-
suspend fun longCompute(abortSignal: AbortSignal): Int
34+
suspend fun longCompute(): Int
3935
}
4036

4137
@KustomExport
4238
class Computer : IComputer {
43-
override suspend fun longCompute(abortSignal: AbortSignal): Int {
39+
var completed = false
40+
override suspend fun longCompute(): Int {
4441
// listen abortSignal , if aborted => throw
4542
delay(1000)
43+
completed = true
4644
return 42
4745
}
4846
}
@@ -51,31 +49,24 @@ class Computer : IComputer {
5149
class ComputerTester(private val computer: IComputer) {
5250
suspend fun testAsync(): Int {
5351
return withContext(Dispatchers.Unconfined) {
54-
println(Date())
55-
val task1 = async { computer.longCompute() } //coroutineContext.job::isActive) }
56-
val task2 = async { computer.longCompute() } //coroutineContext.job::isActive) }
57-
58-
coroutineContext.job.invokeOnCompletion { error ->
59-
if (error is CancellationException) {
60-
//ctrler.abort()
61-
}
62-
}
52+
val task1 = async { computer.longCompute() }
53+
val task2 = async { computer.longCompute() }
6354
return@withContext task1.await() + task2.await()
6455
}
6556
}
6657

6758
suspend fun startAndCancelAfter(duration: Long) {
6859
withContext(Dispatchers.Unconfined) {
6960
withTimeout(duration) {
70-
computer.longCompute()// { coroutineContext.job.isActive }
61+
computer.longCompute()
7162
}
7263
}
7364
}
7465

7566
suspend fun startCancellable(): Cancellable {
7667
val coroutineScope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
7768
val job = coroutineScope.launch {
78-
computer.longCompute() //coroutineContext.job::isActive)
69+
computer.longCompute()
7970
}
8071

8172
return object : Cancellable {

samples/src/commonMain/kotlin/sample/coroutines/Coroutines.ts

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { assert, assertEquals, assertQuiet, assertEqualsQuiet } from "../shared_
33
import { sample } from '@kustom/Samples'
44

55
runTest("Coroutines", async () : Promise<void> => {
6-
console.log("start at " + Date())
6+
var neverAbortController = new AbortController()
7+
var neverAbortSignal = neverAbortController.signal
8+
79
var computer = new sample.coroutines.js.Computer()
8-
var res = await computer.longCompute()
10+
var res = await computer.longCompute(neverAbortSignal)
911
assertEquals(42, res, "execute Kotlin coroutines")
1012

11-
var p = computer.longCompute()
13+
var p = computer.longCompute(neverAbortSignal)
1214
.then((res) => {
1315
return res
1416
})
@@ -25,71 +27,96 @@ runTest("Coroutines", async () : Promise<void> => {
2527
}
2628

2729
var tester = new sample.coroutines.js.ComputerTester(new MyComputer())
28-
assertEquals(1998, await tester.testAsync(), "parallel setTimeout")
30+
assertEquals(1998, await tester.testAsync(neverAbortSignal), "parallel setTimeout")
2931

3032
class RejectComputer implements sample.coroutines.js.IComputer {
3133
async longCompute() {
3234
return new Promise<void>((resolve, reject) => {
3335
setTimeout(() => {
34-
//reject(new Error("bug"));
35-
//reject(undefined); // null, undefined, <nothing> == resolve()
36-
resolve()
37-
//resolve(55)
36+
reject(new Error("bug"));
3837
}, 1000);
3938
});
4039
}
4140
}
4241

4342
var rejectTester = new sample.coroutines.js.ComputerTester(new RejectComputer())
44-
await rejectTester.testAsync()
45-
assertEquals(0, await rejectTester.testAsync(), "parallel reject")
43+
try {
44+
await rejectTester.testAsync(neverAbortSignal)
45+
} catch(e) {
46+
assert(e instanceof Error, "reject throws an Error")
47+
}
48+
49+
cancelTypescriptPromiseFromKotlin()
50+
51+
cancelKotlinCoroutinesFromTypescript()
52+
})
4653

54+
async function cancelTypescriptPromiseFromKotlin() {
55+
var neverAbortController = new AbortController()
56+
var neverAbortSignal = neverAbortController.signal
4757

4858
class CancellableComputer implements sample.coroutines.js.IComputer {
59+
workAborted: boolean
4960
// Should be passed in parameters in the longCompute method for cooperative cancellation
5061
// Cancellation is not available yet.
51-
isJobActive() {
52-
return true
53-
}
54-
async longCompute() {
62+
async longCompute(abortSignal) {
5563
return new Promise((resolve, reject) => {
5664
var timeout = 5000
5765
var currTime = 0
58-
this.rec(this.isJobActive, resolve, reject, currTime, timeout)
66+
this.rec(abortSignal, resolve, reject, currTime, timeout)
5967
});
6068
}
61-
rec(isJobActive, resolve, reject, currTime, timeout) {
69+
rec(abortSignal, resolve, reject, currTime, timeout) {
6270
setTimeout(() => {
63-
console.log("rec " + currTime + " / " + timeout)
6471
if (currTime >= timeout) {
6572
resolve(1234)
6673
} else {
67-
if (isJobActive()) {
68-
this.rec(isJobActive, resolve, reject, currTime + 100, timeout)
74+
if (!abortSignal.aborted) {
75+
this.rec(abortSignal, resolve, reject, currTime + 100, timeout)
76+
} else {
77+
this.workAborted = true
6978
}
7079
}
7180
}, 100);
7281
}
7382
}
7483

75-
var cancellableTester = new sample.coroutines.js.ComputerTester(new CancellableComputer())
84+
var cancellableComputer = new CancellableComputer()
85+
var cancellableTester = new sample.coroutines.js.ComputerTester(cancellableComputer)
7686
try {
77-
console.log(Date() + " startAndCancelAfter")
78-
await cancellableTester.startAndCancelAfter(1000)
87+
var abortController = new AbortController()
88+
var abortSignal = neverAbortController.signal
89+
await cancellableTester.startAndCancelAfter(1000, abortSignal)
90+
assertQuiet(false, "TimeoutCancellationException should have been thrown")
7991
} catch (tce) {
8092
// this check is ok, but the Promise is not really cancelled, so not good enough...
81-
// assert(tce.name === "TimeoutCancellationException", "can cancel (timeout)")
93+
assert(tce.name === "TimeoutCancellationException", "can cancel (timeout throw CancellationException)")
94+
// Exception is thrown immediately when the signal is emitted, work is really stopped a few ms after that
95+
setTimeout(() => {
96+
assertEquals(true, cancellableComputer.workAborted, "can cancel (work is aborted)")
97+
}, 100);
8298
}
83-
//assertEquals(91, await infiniteTester.testAsync(), "parallel reject")
99+
}
84100

85-
//console.log(await new RejectComputer().longCompute())
101+
async function cancelKotlinCoroutinesFromTypescript() {
102+
var abortController = new AbortController()
103+
var abortSignal = abortController.signal
104+
var computer = new sample.coroutines.js.Computer()
105+
var promise = computer.longCompute(abortSignal) // Trigger promise, duration = 1s
106+
// Catch on promise is required, or else the test will stop before the end
107+
var cancellationException = null
108+
promise.catch((e) => {
109+
cancellationException = e
110+
//console.log("Catching the cancellation exception: " + e.name + " - " + e.message)
111+
})
112+
113+
setTimeout(() => {
114+
abortController.abort()
115+
}, 100);
86116

87-
//var cancellableTester = new sample.coroutines.js.ComputerTester(new InfiniteComputer())
88-
//var cancellable = cancellableTester.startCancellable()
89-
/*
90117
setTimeout(() => {
91-
console.log("time !")
92-
//cancellable.cancel()
93-
}, 500);
94-
*/
95-
})
118+
assertEquals(false, computer.completed, "can cancel Kotlin coroutines from Typescript (work has been cancelled and will never complete)")
119+
assertEqualsQuiet("JobCancellationException", cancellationException.name, "cancellation exception")
120+
assertEqualsQuiet("DeferredCoroutine was cancelled", cancellationException.message, "cancellation exception")
121+
}, 1200);
122+
}

0 commit comments

Comments
 (0)