Skip to content

Commit 61946f8

Browse files
committed
Scala.js: Support js.async and js.await, including JSPI on Wasm.
This is forward port of the compiler changes in the two commits of the Scala.js PR scala-js/scala-js#5130 --- We add support for a new pair of primitive methods, `js.async` and `js.await`. They correspond to JavaScript `async` functions and `await` expressions. `js.await(p)` awaits a `Promise`, but it must be directly scoped within a `js.async { ... }` block. At the IR level, `js.await(p)` directly translates to a dedicated IR node `JSAwait(arg)`. `js.async` blocks don't have a direct representation. Instead, the IR models `async function`s and `async =>`functions, as `Closure`s with an additionnal `async` flag. This corresponds to the JavaScript model for `async/await`. A `js.async { body }` block therefore corresponds to an immediately-applied `async Closure`, which in JavaScript would be written as `(async () => body)()`. --- We then optionally allow orphan `js.await(p)` on WebAssembly. With the JavaScript Promise Integration (JSPI), there can be as many frames as we want between a `WebAssembly.promising` function and the corresponding `WebAssembly.Suspending` calls. The former is introduced by our `js.async` blocks, and the latter by calls to `js.await`. Normally, `js.await` must be directly enclosed within a `js.async` block. This ensures that it can be compiled to a JavaScript `async` function and `await` expression. We introduce a sort of "language import" to allow "orphan awaits". This way, we can decouple the `js.await` calls from their `js.async` blocks. The generated IR will then only link when targeting WebAssembly. Technically, `js.await` requires an implicit `js.AwaitPermit`. The default one only allows `js.await` directly inside `js.async`, and we can import a stronger one that allows orphan awaits. There must still be a `js.async` block *dynamically* enclosing any `js.await` call (on the call stack), without intervening JS frame. If that dynamic property is not satisfied, a `WebAssembly.SuspendError` gets thrown. This last point is not yet implemented in Node.js at the time of this commit; instead such a situation traps. The corresponding test is therefore currently ignored. --- Since these features fundamentally depend on a) the target ES version and b) the WebAssembly backend, we add more configurations of Scala.js to the Scala 3 CI.
1 parent ce3fcb3 commit 61946f8

File tree

6 files changed

+121
-5
lines changed

6 files changed

+121
-5
lines changed

.github/workflows/ci.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ jobs:
155155
156156
- name: Scala.js Test
157157
run: |
158-
./project/scripts/sbt ";sjsSandbox/run ;sjsSandbox/test ;sjsJUnitTests/test ;set sjsJUnitTests/scalaJSLinkerConfig ~= switchToESModules ;sjsJUnitTests/test ;sjsCompilerTests/test"
158+
./project/scripts/sbt ";sjsSandbox/run ;sjsSandbox/test ;sjsJUnitTests/test ;set sjsJUnitTests/scalaJSLinkerConfig ~= switchToESModules ;sjsJUnitTests/test"
159+
./project/scripts/sbt ";sjsJUnitTests/clean ;set sjsJUnitTests/scalaJSLinkerConfig ~= switchToLatestESVersion ;sjsJUnitTests/test"
160+
./project/scripts/sbt ";sjsJUnitTests/clean ;set sjsJUnitTests/scalaJSLinkerConfig ~= switchToLatestESVersion ;set Global/enableWebAssembly := true; sjsJUnitTests/test"
161+
./project/scripts/sbt ";sjsCompilerTests/test"
159162
160163
test_windows_fast:
161164
runs-on: [self-hosted, Windows]

compiler/src/dotty/tools/backend/sjs/JSCodeGen.scala

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class JSCodeGen()(using genCtx: Context) {
7777

7878
val currentClassSym = new ScopedVar[Symbol]
7979
private val delambdafyTargetDefDefs = new ScopedVar[MutableSymbolMap[DefDef]]
80+
private val methodsAllowingJSAwait = new ScopedVar[mutable.Set[Symbol]]
8081
private val currentMethodSym = new ScopedVar[Symbol]
8182
private val localNames = new ScopedVar[LocalNameGenerator]
8283
private val thisLocalVarName = new ScopedVar[Option[LocalName]]
@@ -91,6 +92,7 @@ class JSCodeGen()(using genCtx: Context) {
9192
withScopedVars(
9293
currentClassSym := null,
9394
delambdafyTargetDefDefs := null,
95+
methodsAllowingJSAwait := null,
9496
currentMethodSym := null,
9597
localNames := null,
9698
thisLocalVarName := null,
@@ -267,6 +269,7 @@ class JSCodeGen()(using genCtx: Context) {
267269
withScopedVars(
268270
currentClassSym := sym,
269271
delambdafyTargetDefDefs := MutableSymbolMap(),
272+
methodsAllowingJSAwait := mutable.Set.empty,
270273
) {
271274
val tree = if (sym.isJSType) {
272275
if (!sym.is(Trait) && sym.isNonNativeJSClass)
@@ -2424,6 +2427,7 @@ class JSCodeGen()(using genCtx: Context) {
24242427
withScopedVars(
24252428
currentClassSym := sym,
24262429
delambdafyTargetDefDefs := MutableSymbolMap(),
2430+
methodsAllowingJSAwait := mutable.Set.empty,
24272431
) {
24282432
genNonNativeJSClass(typeDef)
24292433
}
@@ -4073,6 +4077,54 @@ class JSCodeGen()(using genCtx: Context) {
40734077
// js.import.meta
40744078
js.JSImportMeta()
40754079

4080+
case JS_ASYNC =>
4081+
// js.async(arg)
4082+
assert(args.size == 1,
4083+
s"Expected exactly 1 argument for JS primitive $code but got " +
4084+
s"${args.size} at $pos")
4085+
4086+
def extractStatsAndClosure(arg: Tree): (List[Tree], Closure) = (arg: @unchecked) match
4087+
case arg: Closure =>
4088+
(Nil, arg)
4089+
case Block(outerStats, expr) =>
4090+
val (innerStats, closure) = extractStatsAndClosure(expr)
4091+
(outerStats ::: innerStats, closure)
4092+
4093+
val (stats, fun @ Closure(_, target, _)) = extractStatsAndClosure(args.head)
4094+
methodsAllowingJSAwait += target.symbol
4095+
val genStats = stats.map(genStat(_))
4096+
val asyncExpr = genClosure(fun) match {
4097+
case js.NewLambda(_, closure: js.Closure)
4098+
if closure.params.isEmpty && closure.resultType == jstpe.AnyType =>
4099+
val newFlags = closure.flags.withTyped(false).withAsync(true)
4100+
js.JSFunctionApply(closure.copy(flags = newFlags), Nil)
4101+
case other =>
4102+
throw FatalError(
4103+
s"Unexpected tree generated for the Function0 argument to js.async at ${tree.sourcePos}: $other")
4104+
}
4105+
js.Block(genStats, asyncExpr)
4106+
4107+
case JS_AWAIT =>
4108+
// js.await(arg)
4109+
val (arg, permitValue) = genArgs2
4110+
if (!methodsAllowingJSAwait.contains(currentMethodSym)) {
4111+
// This is an orphan await
4112+
if (!(args(1).tpe <:< jsdefn.WasmJSPI_allowOrphanJSAwaitModuleClassRef)) {
4113+
report.error(
4114+
"Illegal use of js.await().\n" +
4115+
"It can only be used inside a js.async {...} block, without any lambda,\n" +
4116+
"by-name argument or nested method in-between.\n" +
4117+
"If you compile for WebAssembly, you can allow arbitrary js.await()\n" +
4118+
"calls by adding the following import:\n" +
4119+
"import scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait",
4120+
tree.sourcePos)
4121+
}
4122+
}
4123+
/* In theory we should evaluate `permit` after `arg` but before the `JSAwait`.
4124+
* It *should* always be side-effect-free, though, so we just discard it.
4125+
*/
4126+
js.JSAwait(arg)
4127+
40764128
case DYNAMIC_IMPORT =>
40774129
// runtime.dynamicImport
40784130
assert(args.size == 1,

compiler/src/dotty/tools/backend/sjs/JSDefinitions.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ final class JSDefinitions()(using Context) {
4242
def JSPackage_undefined(using Context) = JSPackage_undefinedR.symbol
4343
@threadUnsafe lazy val JSPackage_dynamicImportR = ScalaJSJSPackageClass.requiredMethodRef("dynamicImport")
4444
def JSPackage_dynamicImport(using Context) = JSPackage_dynamicImportR.symbol
45+
@threadUnsafe lazy val JSPackage_asyncR = ScalaJSJSPackageClass.requiredMethodRef("async")
46+
def JSPackage_async(using Context) = JSPackage_asyncR.symbol
47+
@threadUnsafe lazy val JSPackage_awaitR = ScalaJSJSPackageClass.requiredMethodRef("await")
48+
def JSPackage_await(using Context) = JSPackage_awaitR.symbol
4549

4650
@threadUnsafe lazy val JSNativeAnnotType: TypeRef = requiredClassRef("scala.scalajs.js.native")
4751
def JSNativeAnnot(using Context) = JSNativeAnnotType.symbol.asClass
@@ -210,6 +214,10 @@ final class JSDefinitions()(using Context) {
210214
@threadUnsafe lazy val Special_unwrapFromThrowableR = SpecialPackageClass.requiredMethodRef("unwrapFromThrowable")
211215
def Special_unwrapFromThrowable(using Context) = Special_unwrapFromThrowableR.symbol
212216

217+
@threadUnsafe lazy val WasmJSPIModuleRef = requiredModuleRef("scala.scalajs.js.wasm.JSPI")
218+
@threadUnsafe lazy val WasmJSPI_allowOrphanJSAwaitModuleRef = requiredModuleRef("scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait")
219+
def WasmJSPI_allowOrphanJSAwaitModuleClassRef(using Context) = WasmJSPIModuleRef.select(WasmJSPI_allowOrphanJSAwaitModuleRef.symbol)
220+
213221
@threadUnsafe lazy val WrappedArrayType: TypeRef = requiredClassRef("scala.scalajs.js.WrappedArray")
214222
def WrappedArrayClass(using Context) = WrappedArrayType.symbol.asClass
215223

compiler/src/dotty/tools/backend/sjs/JSPrimitives.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ object JSPrimitives {
3030
inline val JS_IMPORT = JS_NEW_TARGET + 1 // js.import.apply(specifier)
3131
inline val JS_IMPORT_META = JS_IMPORT + 1 // js.import.meta
3232

33-
inline val CONSTRUCTOROF = JS_IMPORT_META + 1 // runtime.constructorOf(clazz)
33+
inline val JS_ASYNC = JS_IMPORT_META + 1 // js.async
34+
inline val JS_AWAIT = JS_ASYNC + 1 // js.await
35+
36+
inline val CONSTRUCTOROF = JS_AWAIT + 1 // runtime.constructorOf(clazz)
3437
inline val CREATE_INNER_JS_CLASS = CONSTRUCTOROF + 1 // runtime.createInnerJSClass
3538
inline val CREATE_LOCAL_JS_CLASS = CREATE_INNER_JS_CLASS + 1 // runtime.createLocalJSClass
3639
inline val WITH_CONTEXTUAL_JS_CLASS_VALUE = CREATE_LOCAL_JS_CLASS + 1 // runtime.withContextualJSClassValue
@@ -110,6 +113,8 @@ class JSPrimitives(ictx: Context) extends DottyPrimitives(ictx) {
110113

111114
addPrimitive(jsdefn.JSPackage_typeOf, TYPEOF)
112115
addPrimitive(jsdefn.JSPackage_native, JS_NATIVE)
116+
addPrimitive(jsdefn.JSPackage_async, JS_ASYNC)
117+
addPrimitive(jsdefn.JSPackage_await, JS_AWAIT)
113118

114119
addPrimitive(defn.BoxedUnit_UNIT, UNITVAL)
115120

project/Build.scala

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import com.typesafe.tools.mima.plugin.MimaPlugin.autoImport._
3333
import org.scalajs.sbtplugin.ScalaJSPlugin
3434
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._
3535

36+
import org.scalajs.linker.interface.ESVersion
37+
3638
import sbtbuildinfo.BuildInfoPlugin
3739
import sbtbuildinfo.BuildInfoPlugin.autoImport._
3840
import sbttastymima.TastyMiMaPlugin
@@ -2772,7 +2774,9 @@ object Build {
27722774
case FullOptStage => (Test / fullLinkJS / scalaJSLinkerConfig).value
27732775
}
27742776

2775-
if (linkerConfig.moduleKind != ModuleKind.NoModule && !linkerConfig.closureCompiler)
2777+
val isWebAssembly = linkerConfig.experimentalUseWebAssembly
2778+
2779+
if (linkerConfig.moduleKind != ModuleKind.NoModule && !linkerConfig.closureCompiler && !isWebAssembly)
27762780
Seq(baseDirectory.value / "test-require-multi-modules")
27772781
else
27782782
Nil
@@ -2800,6 +2804,8 @@ object Build {
28002804

28012805
val moduleKind = linkerConfig.moduleKind
28022806
val hasModules = moduleKind != ModuleKind.NoModule
2807+
val hasAsyncAwait = linkerConfig.esFeatures.esVersion >= ESVersion.ES2017
2808+
val isWebAssembly = linkerConfig.experimentalUseWebAssembly
28032809

28042810
def conditionally(cond: Boolean, subdir: String): Seq[File] =
28052811
if (!cond) Nil
@@ -2829,9 +2835,12 @@ object Build {
28292835

28302836
++ conditionally(!hasModules, "js/src/test/require-no-modules")
28312837
++ conditionally(hasModules, "js/src/test/require-modules")
2832-
++ conditionally(hasModules && !linkerConfig.closureCompiler, "js/src/test/require-multi-modules")
2838+
++ conditionally(hasModules && !linkerConfig.closureCompiler && !isWebAssembly, "js/src/test/require-multi-modules")
28332839
++ conditionally(moduleKind == ModuleKind.ESModule, "js/src/test/require-dynamic-import")
28342840
++ conditionally(moduleKind == ModuleKind.ESModule, "js/src/test/require-esmodule")
2841+
2842+
++ conditionally(hasAsyncAwait, "js/src/test/require-async-await")
2843+
++ conditionally(hasAsyncAwait && isWebAssembly, "js/src/test/require-orphan-await")
28352844
)
28362845
},
28372846

project/DottyJSPlugin.scala

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,34 @@ import sbt.Keys.*
66
import org.scalajs.sbtplugin.ScalaJSPlugin
77
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._
88

9-
import org.scalajs.linker.interface.StandardConfig
9+
import org.scalajs.linker.interface.{ESVersion, StandardConfig}
10+
11+
import org.scalajs.jsenv.nodejs.NodeJSEnv
1012

1113
object DottyJSPlugin extends AutoPlugin {
1214

1315
object autoImport {
1416
val switchToESModules: StandardConfig => StandardConfig =
1517
config => config.withModuleKind(ModuleKind.ESModule)
18+
19+
val switchToLatestESVersion: StandardConfig => StandardConfig =
20+
config => config.withESFeatures(_.withESVersion(ESVersion.ES2021))
21+
22+
val enableWebAssembly: SettingKey[Boolean] =
23+
settingKey("enable all the configuration items required for WebAssembly")
1624
}
1725

26+
import autoImport._
27+
1828
val writePackageJSON = taskKey[Unit](
1929
"Write package.json to configure module type for Node.js")
2030

2131
override def requires: Plugins = ScalaJSPlugin
2232

33+
override def globalSettings: Seq[Setting[_]] = Def.settings(
34+
enableWebAssembly := false,
35+
)
36+
2337
override def projectSettings: Seq[Setting[_]] = Def.settings(
2438

2539
/* #11709 Remove the dependency on scala3-library that ScalaJSPlugin adds.
@@ -40,6 +54,31 @@ object DottyJSPlugin extends AutoPlugin {
4054
// Typecheck the Scala.js IR found on the classpath
4155
scalaJSLinkerConfig ~= (_.withCheckIR(true)),
4256

57+
// Maybe configure WebAssembly
58+
scalaJSLinkerConfig := {
59+
val prev = scalaJSLinkerConfig.value
60+
if (enableWebAssembly.value) {
61+
prev
62+
.withModuleKind(ModuleKind.ESModule)
63+
.withExperimentalUseWebAssembly(true)
64+
} else {
65+
prev
66+
}
67+
},
68+
jsEnv := {
69+
val baseConfig = NodeJSEnv.Config()
70+
val config = if (enableWebAssembly.value) {
71+
baseConfig.withArgs(List(
72+
"--experimental-wasm-exnref",
73+
"--experimental-wasm-imported-strings", // for JS string builtins
74+
"--experimental-wasm-jspi", // for JSPI, used by async/await
75+
))
76+
} else {
77+
baseConfig
78+
}
79+
new NodeJSEnv(config)
80+
},
81+
4382
Compile / jsEnvInput := (Compile / jsEnvInput).dependsOn(writePackageJSON).value,
4483
Test / jsEnvInput := (Test / jsEnvInput).dependsOn(writePackageJSON).value,
4584

0 commit comments

Comments
 (0)