diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c2ba68c48..ef57c51c43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,11 +263,11 @@ jobs: shell: bash run: sbt +update - - name: Setup NodeJS v18 LTS + - name: Setup NodeJS v20 LTS if: matrix.ci == 'ciJS' - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Install jsdom and source-map-support if: matrix.ci == 'ciJS' diff --git a/build.sbt b/build.sbt index d57bc23aec..7d803c8433 100644 --- a/build.sbt +++ b/build.sbt @@ -22,6 +22,7 @@ import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.firefox.{FirefoxOptions, FirefoxProfile} import org.scalajs.jsenv.nodejs.NodeJSEnv import org.scalajs.jsenv.selenium.SeleniumJSEnv +import org.scalajs.linker.interface.OutputPatterns import sbtcrossproject.CrossProject import scala.scalanative.build._ @@ -295,6 +296,14 @@ lazy val useJSEnv = settingKey[JSEnv]("Use Node.js or a headless browser for running Scala.js tests") Global / useJSEnv := NodeJS +lazy val nodeJSWasmEnv = new NodeJSEnv( + NodeJSEnv + .Config() + .withArgs(List("--experimental-wasm-exnref")) + .withEnv(Map("WASM_MODE" -> "true")) + .withSourceMap(true) +) + ThisBuild / jsEnv := { useJSEnv.value match { case NodeJS => new NodeJSEnv(NodeJSEnv.Config().withSourceMap(true)) @@ -309,6 +318,8 @@ ThisBuild / jsEnv := { val options = new ChromeOptions() options.setHeadless(true) new SeleniumJSEnv(options) + case WASM => + nodeJSWasmEnv } } @@ -985,6 +996,24 @@ lazy val tests: CrossProject = crossProject(JSPlatform, JVMPlatform, NativePlatf githubWorkflowArtifactUpload := false ) .jsSettings( + Test / jsEnv := new NodeJSEnv( + NodeJSEnv + .Config() + .withArgs( + List( + "--experimental-wasm-exnref", + "--experimental-wasm-imported-strings", + "--turboshaft-wasm" + )) + .withSourceMap(true) + ), + scalaJSLinkerConfig ~= { + _.withExperimentalUseWebAssembly(true) + .withModuleKind(ModuleKind.ESModule) + .withOutputPatterns(OutputPatterns.fromJSFile("%s.mjs")) + .withClosureCompiler(false) + .withOptimizer(false) // disable Optimizer for WASM + }, Compile / scalaJSUseMainModuleInitializer := true, Compile / mainClass := Some("catseffect.examples.JSRunner"), // The default configured mapSourceURI is used for trace filtering diff --git a/core/js/src/main/scala/cats/effect/Platform.scala b/core/js/src/main/scala/cats/effect/Platform.scala index 4692c89c1c..be0f37bcd2 100644 --- a/core/js/src/main/scala/cats/effect/Platform.scala +++ b/core/js/src/main/scala/cats/effect/Platform.scala @@ -20,6 +20,7 @@ private object Platform { final val isJs = true final val isJvm = false final val isNative = false + final val isWasm = true class static extends scala.annotation.Annotation } diff --git a/core/js/src/main/scala/cats/effect/tracing/TracingConstants.scala b/core/js/src/main/scala/cats/effect/tracing/TracingConstants.scala index ad4872c93a..d0e70c25f0 100644 --- a/core/js/src/main/scala/cats/effect/tracing/TracingConstants.scala +++ b/core/js/src/main/scala/cats/effect/tracing/TracingConstants.scala @@ -22,13 +22,16 @@ import scala.scalajs.js private[effect] object TracingConstants { private[this] final val stackTracingMode: String = - process.env("CATS_EFFECT_TRACING_MODE").filterNot(_.isEmpty).getOrElse { - if (js.typeOf(js.Dynamic.global.process) != "undefined" - && js.typeOf(js.Dynamic.global.process.release) != "undefined" - && js.Dynamic.global.process.release.name == "node".asInstanceOf[js.Any]) - "cached" - else - "none" + try { + if (js.typeOf(js.Dynamic.global.process) != "undefined" && + js.typeOf(js.Dynamic.global.process.env) != "undefined") { + val env = js.Dynamic.global.process.env + if (js.typeOf(env.selectDynamic("CATS_EFFECT_TRACING_MODE")) != "undefined") { + env.CATS_EFFECT_TRACING_MODE.toString + } else "" + } else "" + } catch { + case _: Throwable => "" } final val isCachedStackTracing: Boolean = stackTracingMode.equalsIgnoreCase("cached") @@ -36,4 +39,39 @@ private[effect] object TracingConstants { final val isFullStackTracing: Boolean = stackTracingMode.equalsIgnoreCase("full") final val isStackTracing: Boolean = isFullStackTracing || isCachedStackTracing + + final val isWasm: Boolean = + System.getenv("WASM_MODE") == "true" || + js.typeOf(js.Dynamic.global.WebAssembly) != "undefined" + + // Singleton event for identical functions + final val WASM_IDENTICAL_EVENT: TracingEvent = + TracingEvent.WasmTrace(Array.empty, isIdentical = true) + + // Cache for unique traces + private[this] val uniqueTraceCache = + if (isWasm) js.Dictionary.empty[TracingEvent] + else null + + def createUniqueWasmTrace(): TracingEvent = { + if (isWasm) { + val event = TracingEvent.WasmTrace(Array.empty) + uniqueTraceCache(event.hashCode().toString) = event + event + } else { + TracingEvent.WasmTrace(Array.empty) + } + } + + def markAsIdentical(f: AnyRef): Unit = { + if (isWasm) { + f.asInstanceOf[js.Dynamic].__cats_effect_identical = true + } + } + + def isIdenticalFunction(f: AnyRef): Boolean = { + isWasm && + js.typeOf(f.asInstanceOf[js.Dynamic].__cats_effect_identical) == "boolean" && + f.asInstanceOf[js.Dynamic].__cats_effect_identical.asInstanceOf[Boolean] + } } diff --git a/core/js/src/main/scala/cats/effect/tracing/TracingPlatform.scala b/core/js/src/main/scala/cats/effect/tracing/TracingPlatform.scala index c2fdb11596..2a708d365a 100644 --- a/core/js/src/main/scala/cats/effect/tracing/TracingPlatform.scala +++ b/core/js/src/main/scala/cats/effect/tracing/TracingPlatform.scala @@ -23,48 +23,94 @@ import scala.reflect.NameTransformer import scala.scalajs.{js, LinkingInfo} private[tracing] abstract class TracingPlatform { self: Tracing.type => + import TracingConstants._ private[this] val cache = mutable.Map.empty[Any, TracingEvent].withDefaultValue(null) - private[this] val function0Property = - js.Object.getOwnPropertyNames((() => ()).asInstanceOf[js.Object])(0) - private[this] val function1Property = - js.Object.getOwnPropertyNames(((_: Unit) => ()).asInstanceOf[js.Object])(0) - import TracingConstants._ + private[this] lazy val function0Property: String = { + if (isWasm) "" + else { + try { + js.Object.getOwnPropertyNames((() => ()).asInstanceOf[js.Object])(0) + } catch { + case _: Throwable => "" + } + } + } + + private[this] lazy val function1Property: String = { + if (isWasm) "" + else { + try { + js.Object.getOwnPropertyNames(((_: Unit) => ()).asInstanceOf[js.Object])(0) + } catch { + case _: Throwable => "" + } + } + } def calculateTracingEvent[A](f: Function0[A]): TracingEvent = { - calculateTracingEvent( - f.asInstanceOf[js.Dynamic].selectDynamic(function0Property).toString()) + if (isWasm) { + if (isIdenticalFunction(f.asInstanceOf[AnyRef])) WASM_IDENTICAL_EVENT + else createUniqueWasmTrace() + } else { + try { + calculateTracingEvent( + f.asInstanceOf[js.Dynamic].selectDynamic(function0Property).toString()) + } catch { + case _: Throwable => null + } + } } def calculateTracingEvent[A, B](f: Function1[A, B]): TracingEvent = { - calculateTracingEvent( - f.asInstanceOf[js.Dynamic].selectDynamic(function1Property).toString()) + if (isWasm) { + if (isIdenticalFunction(f.asInstanceOf[AnyRef])) WASM_IDENTICAL_EVENT + else createUniqueWasmTrace() + } else { + try { + calculateTracingEvent( + f.asInstanceOf[js.Dynamic].selectDynamic(function1Property).toString()) + } catch { + case _: Throwable => null + } + } } - // We could have a catch-all for non-functions, but explicitly enumerating makes sure we handle each case correctly def calculateTracingEvent[F[_], A, B](cont: Cont[F, A, B]): TracingEvent = { - calculateTracingEvent(cont.getClass()) + if (isWasm) createUniqueWasmTrace() + else { + try { + calculateTracingEvent(cont.getClass()) + } catch { + case _: Throwable => null + } + } } private[this] final val calculateTracingEvent: Any => TracingEvent = { - if (LinkingInfo.developmentMode) { + if (isWasm) { key => + if (isIdenticalFunction(key.asInstanceOf[AnyRef])) WASM_IDENTICAL_EVENT + else TracingEvent.WasmTrace(Array.empty) + } else if (LinkingInfo.developmentMode) { if (isCachedStackTracing) { key => - val current = cache(key) - if (current eq null) { - val event = buildEvent() - cache(key) = event - event - } else current + try { + val current = cache(key) + if (current eq null) { + val event = buildEvent() + cache(key) = event + event + } else current + } catch { + case _: Throwable => null + } } else if (isFullStackTracing) _ => buildEvent() else _ => null - } else - _ => null + } else { _ => null } } - // These filters require properly-configured source maps private[this] final val stackTraceFileNameFilter: Array[String] = Array( "githubusercontent.com/typelevel/cats-effect/", "githubusercontent.com/typelevel/cats/", @@ -73,17 +119,19 @@ private[tracing] abstract class TracingPlatform { self: Tracing.type => ) private[this] def isInternalFile(fileName: String): Boolean = { - var i = 0 - val len = stackTraceFileNameFilter.length - while (i < len) { - if (fileName.contains(stackTraceFileNameFilter(i))) - return true - i += 1 + if (fileName == null) false + else { + var i = 0 + val len = stackTraceFileNameFilter.length + while (i < len) { + if (fileName.contains(stackTraceFileNameFilter(i))) + return true + i += 1 + } + false } - false } - // These filters target Firefox private[this] final val stackTraceMethodNameFilter: Array[String] = Array( "_Lcats_effect_", "_jl_", @@ -91,14 +139,17 @@ private[tracing] abstract class TracingPlatform { self: Tracing.type => ) private[this] def isInternalMethod(methodName: String): Boolean = { - var i = 0 - val len = stackTraceMethodNameFilter.length - while (i < len) { - if (methodName.contains(stackTraceMethodNameFilter(i))) - return true - i += 1 + if (methodName == null) false + else { + var i = 0 + val len = stackTraceMethodNameFilter.length + while (i < len) { + if (methodName.contains(stackTraceMethodNameFilter(i))) + return true + i += 1 + } + false } - false } private[tracing] def applyStackTraceFilter( @@ -106,21 +157,21 @@ private[tracing] abstract class TracingPlatform { self: Tracing.type => callSiteMethodName: String, callSiteFileName: String): Boolean = { - // anonymous lambdas can only be distinguished by Scala source-location, if available def isInternalScalaFile = (callSiteFileName ne null) && !callSiteFileName.endsWith(".js") && isInternalFile( callSiteFileName) - // this is either a lambda or we are in Firefox def isInternalJSCode = callSiteClassName == "" && (isInternalScalaFile || isInternalMethod(callSiteMethodName)) - isInternalJSCode || isInternalClass(callSiteClassName) // V8 class names behave like Java + isInternalJSCode || isInternalClass(callSiteClassName) } private[tracing] def decodeMethodName(name: String): String = { - val junk = name.indexOf("__") // Firefox artifacts - NameTransformer.decode(if (junk == -1) name else name.substring(0, junk)) + if (name == null) "" + else { + val junk = name.indexOf("__") + NameTransformer.decode(if (junk == -1) name else name.substring(0, junk)) + } } - } diff --git a/core/jvm/src/main/scala/cats/effect/Platform.scala b/core/jvm/src/main/scala/cats/effect/Platform.scala index 954b9b9333..a13195df08 100644 --- a/core/jvm/src/main/scala/cats/effect/Platform.scala +++ b/core/jvm/src/main/scala/cats/effect/Platform.scala @@ -20,6 +20,7 @@ private object Platform { final val isJs = false final val isJvm = true final val isNative = false + final val isWasm = false type static = org.typelevel.scalaccompat.annotation.static3 class safePublish extends scala.annotation.Annotation diff --git a/core/native/src/main/scala/cats/effect/Platform.scala b/core/native/src/main/scala/cats/effect/Platform.scala index a058e77a48..81b103ac3e 100644 --- a/core/native/src/main/scala/cats/effect/Platform.scala +++ b/core/native/src/main/scala/cats/effect/Platform.scala @@ -20,6 +20,7 @@ private object Platform { final val isJs = false final val isJvm = false final val isNative = true + final val isWasm = false class static extends scala.annotation.Annotation type safePublish = scala.scalanative.annotation.safePublish diff --git a/core/native/src/main/scala/cats/effect/tracing/TracingConstants.scala b/core/native/src/main/scala/cats/effect/tracing/TracingConstants.scala index 5551ebeefa..c7a1cbfcf0 100644 --- a/core/native/src/main/scala/cats/effect/tracing/TracingConstants.scala +++ b/core/native/src/main/scala/cats/effect/tracing/TracingConstants.scala @@ -14,8 +14,7 @@ * limitations under the License. */ -package cats.effect -package tracing +package cats.effect.tracing private[effect] object TracingConstants { @@ -26,5 +25,9 @@ private[effect] object TracingConstants { final val isFullStackTracing: Boolean = stackTracingMode.equalsIgnoreCase("full") - final val isStackTracing = isFullStackTracing || isCachedStackTracing + final val isStackTracing: Boolean = isFullStackTracing || isCachedStackTracing + + // Native doesn't need WASM-specific constants since it's not running in WASM mode + final val WASM_IDENTICAL_FUNCTION: AnyRef = new Object() + final val WASM_IDENTICAL_EVENT: TracingEvent = new TracingEvent.StackTrace() } diff --git a/core/native/src/main/scala/cats/effect/tracing/TracingPlatform.scala b/core/native/src/main/scala/cats/effect/tracing/TracingPlatform.scala index 022de5d9b0..f9e9912274 100644 --- a/core/native/src/main/scala/cats/effect/tracing/TracingPlatform.scala +++ b/core/native/src/main/scala/cats/effect/tracing/TracingPlatform.scala @@ -16,6 +16,8 @@ package cats.effect.tracing +import cats.effect.kernel.Cont + import scala.annotation.nowarn import scala.scalanative.meta.LinktimeInfo @@ -27,22 +29,38 @@ private[tracing] abstract class TracingPlatform { self: Tracing.type => private[this] val cache = new ConcurrentHashMap[Class[?], TracingEvent] - def calculateTracingEvent(key: Any): TracingEvent = + def calculateTracingEvent[A](f: Function0[A]): TracingEvent = { if (LinktimeInfo.debugMode) { - if (isCachedStackTracing) { - val cls = key.getClass - val current = cache.get(cls) - if (current eq null) { - val event = buildEvent() - cache.put(cls, event) - event - } else current - } else if (isFullStackTracing) { - buildEvent() - } else { - null - } + calculateTracingEvent(f.getClass()) } else null + } + + def calculateTracingEvent[A, B](f: Function1[A, B]): TracingEvent = { + if (LinktimeInfo.debugMode) { + calculateTracingEvent(f.getClass()) + } else null + } + + def calculateTracingEvent[F[_], A, B](cont: Cont[F, A, B]): TracingEvent = { + if (LinktimeInfo.debugMode) { + calculateTracingEvent(cont.getClass()) + } else null + } + + private[this] def calculateTracingEvent(cls: Class[?]): TracingEvent = { + if (isCachedStackTracing) { + val current = cache.get(cls) + if (current eq null) { + val event = buildEvent() + cache.put(cls, event) + event + } else current + } else if (isFullStackTracing) { + buildEvent() + } else { + null + } + } @nowarn("msg=never used") private[tracing] def applyStackTraceFilter( @@ -52,5 +70,4 @@ private[tracing] abstract class TracingPlatform { self: Tracing.type => isInternalClass(callSiteClassName) private[tracing] def decodeMethodName(name: String): String = name - } diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index ea9b46b37b..7e936f5f60 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -215,7 +215,9 @@ private final class IOFiber[A]( * either because the entire IO is done, or because this branch is done * and execution is continuing asynchronously in a different runloop invocation. */ - if (_cur0 eq IO.EndFiber) { + val cur0 = if (_cur0 eq null) IO.Error(new NullPointerException()) else _cur0 + + if (cur0 eq IO.EndFiber) { return } @@ -1193,7 +1195,14 @@ private final class IOFiber[A]( * Registers the suspended fiber in the global suspended fiber bag. */ private[this] def monitor(): WeakBag.Handle = { - runtime.fiberMonitor.monitorSuspended(this) + if (Platform.isWasm) { + // Return a dummy handle for WASM + new WeakBag.Handle { + def deregister(): Unit = () + }.asInstanceOf[WeakBag.Handle] + } else { + runtime.fiberMonitor.monitorSuspended(this) + } } /** @@ -1534,7 +1543,7 @@ private final class IOFiber[A]( } private[this] def pushTracingEvent(te: TracingEvent): Unit = { - if (te ne null) { + if ((te ne null) && (tracingEvents ne null) && !Platform.isWasm) { tracingEvents.push(te) } } @@ -1542,15 +1551,16 @@ private final class IOFiber[A]( // overrides the AtomicReference#toString override def toString: String = { val state = if (suspended.get()) "SUSPENDED" else if (isDone) "COMPLETED" else "RUNNING" - val tracingEvents = this.tracingEvents - - // There are race conditions here since a running fiber is writing to `tracingEvents`, - // but we don't worry about those since we are just looking for a single `TraceEvent` - // which references user-land code - val opAndCallSite = - Tracing.getFrames(tracingEvents).headOption.map(frame => s": $frame").getOrElse("") - - s"cats.effect.IOFiber@${System.identityHashCode(this).toHexString} $state$opAndCallSite" + if (Platform.isWasm) { + s"cats.effect.IOFiber@${System.identityHashCode(this).toHexString} $state" + } else { + val tracingEvents = this.tracingEvents + val opAndCallSite = + if (tracingEvents ne null) { + Tracing.getFrames(tracingEvents).headOption.map(frame => s": $frame").getOrElse("") + } else "" + s"cats.effect.IOFiber@${System.identityHashCode(this).toHexString} $state$opAndCallSite" + } } private[effect] def isDone: Boolean = diff --git a/core/shared/src/main/scala/cats/effect/tracing/TracingEvent.scala b/core/shared/src/main/scala/cats/effect/tracing/TracingEvent.scala index f7e9302e61..b05fcbbbcf 100644 --- a/core/shared/src/main/scala/cats/effect/tracing/TracingEvent.scala +++ b/core/shared/src/main/scala/cats/effect/tracing/TracingEvent.scala @@ -16,8 +16,25 @@ package cats.effect.tracing -private[effect] sealed trait TracingEvent extends Serializable +private[effect] sealed trait TracingEvent extends Serializable { + def getStackTrace(): Array[StackTraceElement] +} private[effect] object TracingEvent { - final class StackTrace extends Throwable with TracingEvent + final class StackTrace extends Throwable with TracingEvent { + override def getStackTrace(): Array[StackTraceElement] = super.getStackTrace() + } + + final class WasmTrace private[tracing] ( + val stackTrace: Array[StackTraceElement], + val isIdentical: Boolean = false + ) extends TracingEvent { + override def getStackTrace(): Array[StackTraceElement] = stackTrace + override def toString: String = s"WasmTrace(${stackTrace.mkString(",")})" + } + + object WasmTrace { + def apply(stackTrace: Array[StackTraceElement], isIdentical: Boolean = false): WasmTrace = + new WasmTrace(stackTrace, isIdentical) + } } diff --git a/package.json b/package.json index 5e16dc247e..722d34d290 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "devDependencies": { "source-map-support": "^0.5.19" } diff --git a/project/JSEnv.scala b/project/JSEnv.scala index 66813b9be1..3457a97494 100644 --- a/project/JSEnv.scala +++ b/project/JSEnv.scala @@ -19,4 +19,5 @@ object JSEnv { case object Firefox extends JSEnv case object Chrome extends JSEnv case object NodeJS extends JSEnv + case object WASM extends JSEnv }