From 6f1868ab771eb77d89c4f7a8b09a17b46e66b4a2 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Thu, 4 Jul 2024 15:01:05 -0700 Subject: [PATCH] feat(fs): support for `copyFile` methods - feat: implement `copyFile` methods in `fs` and `fs/promises` - feat: initial `GuestExecutor` support - feat: guest executor infra - test: add tests for `copyFile` methods - fix: reduce requisite syscalls for file reads - fix: encoding parameter should default to utf8 for `readFile` - fix: bugfixes for behavior in callback behavior for `fs` - fix: support for UTF-32 in `fs` module - fix: js error handling in `fs` module - fix: dependency base for gvm tests with executor - chore: update `runtime` module with new facade exports - chore: unwire direct executor from `fs` - chore: update `graalvm` module pin - chore: wire in guest executors Signed-off-by: Sam Gammon --- packages/engine/build.gradle.kts | 20 + packages/engine/detekt-baseline.xml | 1 + .../runtime/gvm/test/GuestTestExecutor.kt | 19 + packages/graalvm-py/build.gradle.kts | 1 + .../kotlin/elide/runtime/gvm/PythonTest.kt | 7 +- .../internals/AbstractPythonIntrinsicTest.kt | 5 +- .../test/kotlin/elide/runtime/gvm/RubyTest.kt | 5 +- .../ruby/AbstractRubyIntrinsicTest.kt | 5 +- packages/graalvm/README.md | 5 + packages/graalvm/api/graalvm.api | 80 ++- packages/graalvm/build.gradle.kts | 3 + packages/graalvm/detekt-baseline.xml | 1 + .../kotlin/elide/runtime/gvm/GuestError.kt | 22 + .../elide/runtime/gvm/GuestExecution.kt | 81 +++ .../kotlin/elide/runtime/gvm/GuestExecutor.kt | 53 ++ .../gvm/internals/intrinsics/js/JsError.kt | 64 +- .../internals/intrinsics/js/JsPromiseImpl.kt | 39 +- .../runtime/gvm/internals/node/NodeStdlib.kt | 15 - .../gvm/internals/node/fs/NodeFilesystem.kt | 504 +++++++++------ .../kotlin/elide/runtime/gvm/js/JavaScript.kt | 6 +- .../elide/runtime/intrinsics/js/JsPromise.kt | 30 +- .../elide/runtime/intrinsics/js/err/Error.kt | 70 +- .../intrinsics/js/node/FilesystemAPI.kt | 99 ++- .../js/node/FilesystemPromiseAPI.kt | 47 +- .../elide/embedded/runtime/js/js.modules.tar | Bin 256000 -> 256000 bytes .../runtime/gvm/internals/AbstractDualTest.kt | 4 +- .../internals/js/AbstractJsIntrinsicTest.kt | 10 +- .../internals/js/node/AbstractNodeFsTest.kt | 38 ++ .../internals/js/node/NodeFsPromisesTest.kt | 178 +++++- .../gvm/internals/js/node/NodeFsTest.kt | 597 +++++++++++++++++- .../elide/runtime/gvm/js/AbstractJsTest.kt | 5 +- .../gvm/js/node/NodeModuleConformanceTest.kt | 4 +- packages/runtime/README.md | 4 + .../tool/cli/cmd/repl/ToolShellCommand.kt | 26 +- packages/server/build.gradle.kts | 1 + runtime | 2 +- 36 files changed, 1743 insertions(+), 308 deletions(-) create mode 100644 packages/engine/src/test/kotlin/elide/runtime/gvm/test/GuestTestExecutor.kt create mode 100644 packages/graalvm/README.md create mode 100644 packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestError.kt create mode 100644 packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecution.kt create mode 100644 packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecutor.kt create mode 100644 packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/AbstractNodeFsTest.kt create mode 100644 packages/runtime/README.md diff --git a/packages/engine/build.gradle.kts b/packages/engine/build.gradle.kts index 9f8b5f6b05..00707bf6d7 100644 --- a/packages/engine/build.gradle.kts +++ b/packages/engine/build.gradle.kts @@ -15,6 +15,7 @@ import elide.internal.conventions.publishing.publish plugins { kotlin("jvm") + kotlin("kapt") kotlin("plugin.serialization") alias(libs.plugins.micronaut.minimal.library) alias(libs.plugins.micronaut.graalvm) @@ -54,4 +55,23 @@ dependencies { api(libs.graalvm.polyglot) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) + + testApi(projects.packages.base) + testApi(projects.packages.graalvm) + testAnnotationProcessor(mn.micronaut.inject.java) +} + +val testInternals by configurations.registering { + extendsFrom(configurations.testImplementation.get()) + isCanBeConsumed = true + isCanBeResolved = false +} + +val testJar by tasks.registering(Jar::class) { + archiveBaseName.set("engine-test") + from(sourceSets.test.get().output) +} + +artifacts { + add(testInternals.name, testJar) } diff --git a/packages/engine/detekt-baseline.xml b/packages/engine/detekt-baseline.xml index b05d4d228c..e894e4278d 100644 --- a/packages/engine/detekt-baseline.xml +++ b/packages/engine/detekt-baseline.xml @@ -3,6 +3,7 @@ ForbiddenComment:AbstractLanguageConfig.kt$AbstractLanguageConfig$// @TODO: don't unconditionally mount all members + MatchingDeclarationName:GuestTestExecutor.kt$GuestTestExecutorFactory : GuestExecutorProvider ReturnCount:Version.kt$Version$public override operator fun compareTo(other: Version): Int TooGenericExceptionThrown:AbstractLanguagePlugin.kt$AbstractLanguagePlugin$throw Exception("Failed to resolve language resources with key $manifestKey for language $languageId", cause) diff --git a/packages/engine/src/test/kotlin/elide/runtime/gvm/test/GuestTestExecutor.kt b/packages/engine/src/test/kotlin/elide/runtime/gvm/test/GuestTestExecutor.kt new file mode 100644 index 0000000000..6cc405afcb --- /dev/null +++ b/packages/engine/src/test/kotlin/elide/runtime/gvm/test/GuestTestExecutor.kt @@ -0,0 +1,19 @@ +package elide.runtime.gvm.test + +import io.micronaut.context.annotation.Requires +import elide.annotations.Eager +import elide.annotations.Factory +import elide.annotations.Singleton +import elide.runtime.gvm.GuestExecution +import elide.runtime.gvm.GuestExecutor +import elide.runtime.gvm.GuestExecutorProvider + +/** + * Guest Test Executor Factory + * + * Initializes a direct executor for use during test execution. + */ +@Requires(env = ["test"]) +@Eager @Factory internal class GuestTestExecutorFactory : GuestExecutorProvider { + @Singleton override fun executor(): GuestExecutor = GuestExecution.direct() +} diff --git a/packages/graalvm-py/build.gradle.kts b/packages/graalvm-py/build.gradle.kts index e804d1da8b..7d810b0e2d 100644 --- a/packages/graalvm-py/build.gradle.kts +++ b/packages/graalvm-py/build.gradle.kts @@ -143,6 +143,7 @@ dependencies { kaptTest(mn.micronaut.inject.java) testImplementation(projects.packages.test) testImplementation(projects.packages.graalvm) + testApi(project(":packages:engine", configuration = "testInternals")) testImplementation(project(":packages:graalvm", configuration = "testBase")) } diff --git a/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/PythonTest.kt b/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/PythonTest.kt index 47f58ac84a..953bd13fb7 100644 --- a/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/PythonTest.kt +++ b/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/PythonTest.kt @@ -16,6 +16,7 @@ package elide.runtime.gvm import org.graalvm.polyglot.Value import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.test.runTest import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext import elide.runtime.core.PolyglotEngineConfiguration @@ -32,7 +33,7 @@ abstract class PythonTest : AbstractDualTest() { } @Suppress("SameParameterValue") - private fun executeGuestInternal(ctx: PolyglotContext, bindUtils: Boolean, op: Python,): Value { + private fun executeGuestInternal(ctx: PolyglotContext, bindUtils: Boolean, op: Python): Value { // resolve the script val script = op.invoke(ctx) @@ -71,8 +72,8 @@ abstract class PythonTest : AbstractDualTest() { } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { op.invoke() } return object : DualTestExecutionProxy() { override fun guest(guestOperation: Python) = GuestTestExecution(::withContext) { executeGuestInternal( diff --git a/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/internals/AbstractPythonIntrinsicTest.kt b/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/internals/AbstractPythonIntrinsicTest.kt index 4f0a3556e5..9869ac4b2e 100644 --- a/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/internals/AbstractPythonIntrinsicTest.kt +++ b/packages/graalvm-py/src/test/kotlin/elide/runtime/gvm/internals/AbstractPythonIntrinsicTest.kt @@ -17,6 +17,7 @@ import org.graalvm.polyglot.Value import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import java.util.function.Function +import kotlinx.coroutines.test.runTest import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext import elide.runtime.core.PolyglotEngineConfiguration @@ -79,8 +80,8 @@ abstract class AbstractPythonIntrinsicTest : AbstractIntrins } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { op.invoke() } return object : DualTestExecutionProxy() { override fun guest(guestOperation: Python) = GuestTestExecution(::withContext) { executeGuestInternal( diff --git a/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/RubyTest.kt b/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/RubyTest.kt index fb297fcba0..fbbe43164b 100644 --- a/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/RubyTest.kt +++ b/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/RubyTest.kt @@ -16,6 +16,7 @@ package elide.runtime.gvm import org.graalvm.polyglot.Value import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.test.runTest import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext import elide.runtime.core.PolyglotEngineConfiguration @@ -75,8 +76,8 @@ ctx: PolyglotContext, } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { op.invoke() } return object : DualTestExecutionProxy() { override fun guest(guestOperation: Ruby) = GuestTestExecution(::withContext) { executeGuestInternal( diff --git a/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/internals/ruby/AbstractRubyIntrinsicTest.kt b/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/internals/ruby/AbstractRubyIntrinsicTest.kt index e055d901d7..7822ccdf58 100644 --- a/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/internals/ruby/AbstractRubyIntrinsicTest.kt +++ b/packages/graalvm-rb/src/test/kotlin/elide/runtime/gvm/internals/ruby/AbstractRubyIntrinsicTest.kt @@ -17,6 +17,7 @@ import org.graalvm.polyglot.Value import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import java.util.function.Function +import kotlinx.coroutines.test.runTest import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext import elide.runtime.core.PolyglotEngineConfiguration @@ -85,8 +86,8 @@ ctx: PolyglotContext, } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { op.invoke() } return object : DualTestExecutionProxy() { override fun guest(guestOperation: Ruby) = GuestTestExecution(::withContext) { executeGuestInternal( diff --git a/packages/graalvm/README.md b/packages/graalvm/README.md new file mode 100644 index 0000000000..f68f944134 --- /dev/null +++ b/packages/graalvm/README.md @@ -0,0 +1,5 @@ +# `graalvm` + +Main logic module for Elide's integration points with GraalVM. + + diff --git a/packages/graalvm/api/graalvm.api b/packages/graalvm/api/graalvm.api index ddc006c8ef..6e1fb3c899 100644 --- a/packages/graalvm/api/graalvm.api +++ b/packages/graalvm/api/graalvm.api @@ -142,6 +142,24 @@ public final class elide/runtime/feature/engine/NativeSQLiteFeature : elide/runt public fun unpackNatives (Lorg/graalvm/nativeimage/hosted/Feature$BeforeAnalysisAccess;)Ljava/util/List; } +public synthetic class elide/runtime/gvm/$GuestExecutorFactory$Definition : io/micronaut/context/AbstractInitializableBeanDefinitionAndReference { + public static final field $ANNOTATION_METADATA Lio/micronaut/core/annotation/AnnotationMetadata; + public fun ()V + protected fun (Ljava/lang/Class;Lio/micronaut/context/AbstractInitializableBeanDefinition$MethodOrFieldReference;)V + public fun inject (Lio/micronaut/context/BeanResolutionContext;Lio/micronaut/context/BeanContext;Ljava/lang/Object;)Ljava/lang/Object; + public fun instantiate (Lio/micronaut/context/BeanResolutionContext;Lio/micronaut/context/BeanContext;)Ljava/lang/Object; + public fun load ()Lio/micronaut/inject/BeanDefinition; +} + +public synthetic class elide/runtime/gvm/$GuestExecutorFactory$Executor0$Definition : io/micronaut/context/AbstractInitializableBeanDefinitionAndReference { + public static final field $ANNOTATION_METADATA Lio/micronaut/core/annotation/AnnotationMetadata; + public fun ()V + protected fun (Ljava/lang/Class;Lio/micronaut/context/AbstractInitializableBeanDefinition$MethodOrFieldReference;)V + public fun inject (Lio/micronaut/context/BeanResolutionContext;Lio/micronaut/context/BeanContext;Ljava/lang/Object;)Ljava/lang/Object; + public fun instantiate (Lio/micronaut/context/BeanResolutionContext;Lio/micronaut/context/BeanContext;)Ljava/lang/Object; + public fun load ()Lio/micronaut/inject/BeanDefinition; +} + public abstract class elide/runtime/gvm/DeveloperScript : elide/runtime/gvm/ExecutableScript { public fun ()V } @@ -223,6 +241,27 @@ public final class elide/runtime/gvm/ExecutionInputs$Empty : elide/runtime/gvm/E public fun allInputs ()[Ljava/lang/Object; } +public abstract interface class elide/runtime/gvm/GuestError { +} + +public final class elide/runtime/gvm/GuestExecution { + public static final field INSTANCE Lelide/runtime/gvm/GuestExecution; + public final fun direct ()Lelide/runtime/gvm/GuestExecutor; + public final fun workStealing ()Lelide/runtime/gvm/GuestExecutor; +} + +public abstract interface class elide/runtime/gvm/GuestExecutor : com/google/common/util/concurrent/ListeningExecutorService, kotlin/coroutines/CoroutineContext { + public abstract fun getDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; +} + +public final class elide/runtime/gvm/GuestExecutor$DefaultImpls { + public static fun plus (Lelide/runtime/gvm/GuestExecutor;Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; +} + +public abstract interface class elide/runtime/gvm/GuestExecutorProvider { + public abstract fun executor ()Lelide/runtime/gvm/GuestExecutor; +} + public abstract interface class elide/runtime/gvm/GuestLanguage { public static final field Companion Lelide/runtime/gvm/GuestLanguage$Companion; public abstract fun getEngine ()Ljava/lang/String; @@ -1038,8 +1077,6 @@ public final class elide/runtime/gvm/internals/node/NodeStdlib { public final fun getDnsPromises ()Lelide/runtime/intrinsics/js/node/DNSPromisesAPI; public final fun getDomain ()Lelide/runtime/intrinsics/js/node/DomainAPI; public final fun getEvents ()Lelide/runtime/intrinsics/js/node/EventsAPI; - public final fun getFs ()Lelide/runtime/intrinsics/js/node/FilesystemAPI; - public final fun getFsPromises ()Lelide/runtime/intrinsics/js/node/FilesystemPromiseAPI; public final fun getHttp ()Lelide/runtime/intrinsics/js/node/HTTPAPI; public final fun getHttp2 ()Lelide/runtime/intrinsics/js/node/HTTP2API; public final fun getHttps ()Lelide/runtime/intrinsics/js/node/HTTPSAPI; @@ -2606,7 +2643,7 @@ public final class elide/runtime/intrinsics/js/JsIterator$JsIteratorResult : org public fun removeMember (Ljava/lang/String;)Z } -public abstract interface class elide/runtime/intrinsics/js/JsPromise : org/graalvm/polyglot/proxy/ProxyObject { +public abstract interface class elide/runtime/intrinsics/js/JsPromise : com/google/common/util/concurrent/ListenableFuture, org/graalvm/polyglot/proxy/ProxyObject { public static final field Companion Lelide/runtime/intrinsics/js/JsPromise$Companion; public abstract fun catch (Lkotlin/jvm/functions/Function1;)Lelide/runtime/intrinsics/js/JsPromise; public abstract fun catch (Lorg/graalvm/polyglot/Value;)Lelide/runtime/intrinsics/js/JsPromise; @@ -2614,9 +2651,9 @@ public abstract interface class elide/runtime/intrinsics/js/JsPromise : org/graa public synthetic fun getMemberKeys ()Ljava/lang/Object; public fun getMemberKeys ()[Ljava/lang/String; public fun hasMember (Ljava/lang/String;)Z - public static fun of (Ljava/util/concurrent/CountDownLatch;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; - public static fun of (Ljava/util/concurrent/Future;)Lelide/runtime/intrinsics/js/JsPromise; - public static fun of (Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public static fun of (Lelide/runtime/gvm/GuestExecutor;Ljava/util/concurrent/CountDownLatch;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public static fun of (Lelide/runtime/gvm/GuestExecutor;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public static fun of (Lelide/runtime/gvm/GuestExecutor;Lkotlin/jvm/functions/Function0;)Lelide/runtime/intrinsics/js/JsPromise; public fun putMember (Ljava/lang/String;Lorg/graalvm/polyglot/Value;)V public static fun rejected (Ljava/lang/Throwable;)Lelide/runtime/intrinsics/js/JsPromise; public static fun resolved (Ljava/lang/Object;)Lelide/runtime/intrinsics/js/JsPromise; @@ -2624,14 +2661,16 @@ public abstract interface class elide/runtime/intrinsics/js/JsPromise : org/graa public abstract fun then (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)Lelide/runtime/intrinsics/js/JsPromise; public static synthetic fun then$default (Lelide/runtime/intrinsics/js/JsPromise;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lelide/runtime/intrinsics/js/JsPromise; public static synthetic fun then$default (Lelide/runtime/intrinsics/js/JsPromise;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;ILjava/lang/Object;)Lelide/runtime/intrinsics/js/JsPromise; + public static fun wrap (Lcom/google/common/util/concurrent/ListenableFuture;)Lelide/runtime/intrinsics/js/JsPromise; } public final class elide/runtime/intrinsics/js/JsPromise$Companion { - public final fun of (Ljava/util/concurrent/CountDownLatch;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; - public final fun of (Ljava/util/concurrent/Future;)Lelide/runtime/intrinsics/js/JsPromise; - public final fun of (Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public final fun of (Lelide/runtime/gvm/GuestExecutor;Ljava/util/concurrent/CountDownLatch;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public final fun of (Lelide/runtime/gvm/GuestExecutor;Ljava/util/function/Supplier;)Lelide/runtime/intrinsics/js/JsPromise; + public final fun of (Lelide/runtime/gvm/GuestExecutor;Lkotlin/jvm/functions/Function0;)Lelide/runtime/intrinsics/js/JsPromise; public final fun rejected (Ljava/lang/Throwable;)Lelide/runtime/intrinsics/js/JsPromise; public final fun resolved (Ljava/lang/Object;)Lelide/runtime/intrinsics/js/JsPromise; + public final fun wrap (Lcom/google/common/util/concurrent/ListenableFuture;)Lelide/runtime/intrinsics/js/JsPromise; } public abstract interface class elide/runtime/intrinsics/js/JsSymbol { @@ -2912,16 +2951,23 @@ public abstract interface class elide/runtime/intrinsics/js/err/AbstractJsExcept public static synthetic fun create$default (Lelide/runtime/intrinsics/js/err/AbstractJsException$ErrorFactory;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lelide/runtime/intrinsics/js/err/AbstractJsException; } -public abstract class elide/runtime/intrinsics/js/err/Error : java/lang/RuntimeException, elide/runtime/intrinsics/js/err/AbstractJsException { +public abstract class elide/runtime/intrinsics/js/err/Error : java/lang/RuntimeException, elide/runtime/gvm/GuestError, elide/runtime/intrinsics/js/err/AbstractJsException, org/graalvm/polyglot/proxy/ProxyObject { public fun ()V + public fun ([Lkotlin/Pair;)V public fun getCause ()Lelide/runtime/intrinsics/js/err/Error; public synthetic fun getCause ()Ljava/lang/Throwable; public fun getColumnNumber ()Ljava/lang/Integer; + public fun getErrno ()Ljava/lang/Integer; public fun getFileName ()Ljava/lang/String; public fun getLineNumber ()Ljava/lang/Integer; + public fun getMember (Ljava/lang/String;)Ljava/lang/Object; + public synthetic fun getMemberKeys ()Ljava/lang/Object; + public fun getMemberKeys ()[Ljava/lang/String; public abstract fun getMessage ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; public final fun getStackTrace ()Lelide/runtime/intrinsics/js/err/Stacktrace; + public fun hasMember (Ljava/lang/String;)Z + public fun putMember (Ljava/lang/String;Lorg/graalvm/polyglot/Value;)V } public abstract interface class elide/runtime/intrinsics/js/err/JsError { @@ -3179,9 +3225,9 @@ public abstract interface class elide/runtime/intrinsics/js/node/FilesystemAPI { public abstract fun existsSync (Lelide/runtime/intrinsics/js/node/path/Path;)Z public abstract fun existsSync (Lorg/graalvm/polyglot/Value;)Z public abstract fun readFile (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/ReadFileOptions;Lkotlin/jvm/functions/Function2;)V - public abstract fun readFile (Lorg/graalvm/polyglot/Value;Lkotlin/jvm/functions/Function2;)V public abstract fun readFile (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lkotlin/jvm/functions/Function2;)V public static synthetic fun readFile$default (Lelide/runtime/intrinsics/js/node/FilesystemAPI;Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/ReadFileOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun readFile$default (Lelide/runtime/intrinsics/js/node/FilesystemAPI;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public abstract fun readFileSync (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/ReadFileOptions;)Ljava/lang/Object; public abstract fun readFileSync (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)Ljava/lang/Object; public static synthetic fun readFileSync$default (Lelide/runtime/intrinsics/js/node/FilesystemAPI;Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/ReadFileOptions;ILjava/lang/Object;)Ljava/lang/Object; @@ -3379,6 +3425,14 @@ public abstract interface class elide/runtime/intrinsics/js/node/WorkerAPI : eli } public abstract interface class elide/runtime/intrinsics/js/node/WritableFilesystemAPI : elide/runtime/intrinsics/js/node/NodeAPI { + public abstract fun copyFile (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;ILkotlin/jvm/functions/Function1;)V + public abstract fun copyFile (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V + public abstract fun copyFile (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V + public static synthetic fun copyFile$default (Lelide/runtime/intrinsics/js/node/WritableFilesystemAPI;Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public abstract fun copyFileSync (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;I)V + public abstract fun copyFileSync (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V + public abstract fun copyFileSync (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V + public static synthetic fun copyFileSync$default (Lelide/runtime/intrinsics/js/node/WritableFilesystemAPI;Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;IILjava/lang/Object;)V public abstract fun mkdir (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/MkdirOptions;Lkotlin/jvm/functions/Function1;)V public abstract fun mkdir (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V public abstract fun mkdir (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)V @@ -3402,6 +3456,10 @@ public abstract interface class elide/runtime/intrinsics/js/node/WritableFilesys } public abstract interface class elide/runtime/intrinsics/js/node/WritableFilesystemPromiseAPI { + public abstract fun copyFile (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;Ljava/lang/Integer;)Lelide/runtime/intrinsics/js/JsPromise; + public abstract fun copyFile (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)Lelide/runtime/intrinsics/js/JsPromise; + public abstract fun copyFile (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;I)Lelide/runtime/intrinsics/js/JsPromise; + public static synthetic fun copyFile$default (Lelide/runtime/intrinsics/js/node/WritableFilesystemPromiseAPI;Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/path/Path;Ljava/lang/Integer;ILjava/lang/Object;)Lelide/runtime/intrinsics/js/JsPromise; public abstract fun mkdir (Lelide/runtime/intrinsics/js/node/path/Path;Lelide/runtime/intrinsics/js/node/fs/MkdirOptions;)Lelide/runtime/intrinsics/js/JsPromise; public abstract fun mkdir (Lorg/graalvm/polyglot/Value;)Lelide/runtime/intrinsics/js/JsPromise; public abstract fun mkdir (Lorg/graalvm/polyglot/Value;Lorg/graalvm/polyglot/Value;)Lelide/runtime/intrinsics/js/JsPromise; diff --git a/packages/graalvm/build.gradle.kts b/packages/graalvm/build.gradle.kts index 07f181ddc4..178d9cbd5d 100644 --- a/packages/graalvm/build.gradle.kts +++ b/packages/graalvm/build.gradle.kts @@ -437,8 +437,10 @@ dependencies { implementation(kotlin("stdlib")) implementation(kotlin("reflect")) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.jdk8) implementation(libs.kotlinx.coroutines.jdk9) implementation(libs.kotlinx.coroutines.core.jvm) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.serialization.core.jvm) implementation(libs.kotlinx.serialization.json.jvm) implementation(libs.kotlinx.collections.immutable) @@ -506,6 +508,7 @@ dependencies { } // Testing + testApi(project(":packages:engine", configuration = "testInternals")) testImplementation(projects.packages.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.junit.jupiter.api) diff --git a/packages/graalvm/detekt-baseline.xml b/packages/graalvm/detekt-baseline.xml index 89c0454792..02670a0cc7 100644 --- a/packages/graalvm/detekt-baseline.xml +++ b/packages/graalvm/detekt-baseline.xml @@ -95,6 +95,7 @@ SpreadOperator:NativeTransportFeature.kt$NativeTransportFeature$("netty_transport_native_epoll", *epollImpls) SpreadOperator:NativeTransportFeature.kt$NativeTransportFeature$("netty_transport_native_kqueue", *kqueueImpls) SpreadOperator:NodeEvents.kt$BoundEventListener$(*args) + SpreadOperator:NodeFilesystem.kt$(src.toJavaPath(), dest.toJavaPath(), *copyOpts(mode)) SpreadOperator:NodePaths.kt$NodePaths.BasePaths$(args[0].asString(), *args.drop(1).map { it.asString() }.toTypedArray()) SpreadOperator:NodePaths.kt$PathBuf$(args[0].asString(), *args.drop(1).map { it.asString() }.toTypedArray()) SpreadOperator:SQLiteTransactor.kt$SQLiteTransactor$(*args) diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestError.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestError.kt new file mode 100644 index 0000000000..e5495e1d97 --- /dev/null +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestError.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package elide.runtime.gvm + +import elide.annotations.API + +/** + * # Guest Error + * + * Marker interface for guest-side errors, which are typically wrapped by a guest-side type. + */ +@API public interface GuestError diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecution.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecution.kt new file mode 100644 index 0000000000..e8642d5083 --- /dev/null +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecution.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package elide.runtime.gvm + +import com.google.common.util.concurrent.ListeningExecutorService +import com.google.common.util.concurrent.MoreExecutors +import io.micronaut.context.annotation.Requires +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlin.coroutines.CoroutineContext +import elide.annotations.Eager +import elide.annotations.Factory +import elide.annotations.Singleton + +/** + * # Guest Executor Provider + * + * Factory interface for creating a [GuestExecutor] early in the runtime boot process; see [GuestExecutorFactory], and + * note that this interface is overridden for testing purposes. + */ +@FunctionalInterface public fun interface GuestExecutorProvider { + public fun executor(): GuestExecutor +} + +/** + * Guest Executor Factory + * + * Initializes the [GuestExecution.workStealing] executor and mounts it within the injection context; the guest executor + * is initialized eagerly at runtime boot. + */ +@Requires(notEnv = ["test"]) +@Eager @Factory internal class GuestExecutorFactory : GuestExecutorProvider { + @Singleton override fun executor(): GuestExecutor = GuestExecution.workStealing() +} + +/** + * # Guest Execution Utilities + * + * Provides pre-made implementations of [GuestExecutor] classes, which are used at guest VM run-time to provide managed + * and background-enabled execution services, which are aware of lock-state with regard to guest context. + * + * @see [GuestExecutor] main `GuestExecutor` interface. + */ +public object GuestExecution { + // Direct executor instance, which invokes the target operation within the calling thread, blocking until completion. + private val directExecutor: GuestExecutor by lazy { + val exec = MoreExecutors.newDirectExecutorService() + val dispatcher = exec.asCoroutineDispatcher() + object: GuestExecutor, ListeningExecutorService by exec, CoroutineContext by dispatcher { + override val dispatcher: CoroutineDispatcher get() = dispatcher + } + } + + // Work-stealing executor instance with context-aware scheduling. + private val workStealingExecutor: GuestExecutor by lazy { + val exec = MoreExecutors.listeningDecorator( + Executors.newWorkStealingPool() + ) + val dispatcher = exec.asCoroutineDispatcher() + object: GuestExecutor, ListeningExecutorService by exec, CoroutineContext by dispatcher { + override val dispatcher: CoroutineDispatcher get() = dispatcher + } + } + + /** @return Direct executor implementation; meant for use in testing or single-threaded modes only. */ + public fun direct(): GuestExecutor = directExecutor + + /** @return Work-stealing executor implementation, with parallelism set to the active number of CPUs. */ + public fun workStealing(): GuestExecutor = workStealingExecutor +} diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecutor.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecutor.kt new file mode 100644 index 0000000000..64bcbc337d --- /dev/null +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/GuestExecutor.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package elide.runtime.gvm + +import com.google.common.util.concurrent.ListeningExecutorService +import java.util.concurrent.Executor +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext + +/** + * # Guest Executor + * + * Defines the API surface made available, and expected, for guest execution management; a guest executor is provided + * for host-side use, in the execution of guest code, which should be background-invoked by a guest runtime. + * + * For example, when using async methods via Node APIs (like the `fs`) module, execution of method logic takes place on + * a [GuestExecutor] instance, which may manage context switching for the polyglot runtime as needed. + * + * APIs that originate promise objects work this way too, executing on the [GuestExecutor] when their value is awaited. + * + *   + * + * ## Executor Services + * + * Guest executors are, firstly, [Executor] instances; depending on the implementation assigned, execution may take + * place in a background thread or in the calling thread (for instance, during testing). + * + * In "production," the executor service is typically multithreaded, and equipped with context lock management. + * + *   + * + * ## Coroutine Scope and Context + * + * In addition to the [Executor] API, the [GuestExecutor] interface extends [CoroutineScope], providing a bridge to the + * Kotlin Coroutines API. + * + * Co-routine scope may be enclosed to provide context for host-side execution. + */ +public interface GuestExecutor : ListeningExecutorService, CoroutineContext { + /** @return The co-routine dispatcher scope/context to use. */ + public val dispatcher: CoroutineDispatcher +} diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsError.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsError.kt index eb1ecfe022..7b3ddaa0b1 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsError.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsError.kt @@ -12,6 +12,7 @@ */ package elide.runtime.gvm.internals.intrinsics.js +import org.graalvm.polyglot.Value import kotlin.reflect.KClass import kotlin.reflect.full.companionObjectInstance import elide.runtime.intrinsics.js.err.* @@ -30,18 +31,69 @@ import elide.runtime.intrinsics.js.err.* return (type.companionObjectInstance as AbstractJsException.ErrorFactory).create(message, cause) } + /** + * Manufacture a generic JavaScript error with the provided [msg] and optional [cause]. + * + * @param msg Message to enclose for the error. + * @param errno Optional error number to include. + * @param extraProps Extra properties to mount on the error. + * @return Constructed JS exception type. + */ + fun of( + msg: String, + errno: Int? = null, + vararg extraProps: Pair, + ): Error = object : Error(extraProps.map { it.first to Value.asValue(it.second) }.toTypedArray()) { + override val message: String get() = msg + override val errno: Int? get() = errno + override val name: String get() = cause?.javaClass?.simpleName ?: "Error" + } + + /** + * Manufacture a generic JavaScript error with the provided [msg] and optional [cause]. + * + * @param msg Message to enclose for the error. + * @param cause Caught error to wrap, if any. + * @param errno Optional error number to include. + * @param extraProps Extra properties to mount on the error. + * @return Constructed JS exception type. + */ + fun of( + msg: String, + cause: Throwable?, + errno: Int? = null, + vararg extraProps: Pair, + ): Error = object : Error(extraProps.map { it.first to Value.asValue(it.second) }.toTypedArray()) { + override val message: String get() = msg + override val errno: Int? get() = errno + override val name: String get() = cause?.javaClass?.simpleName ?: "Error" + } + + /** + * Manufacture a generic JavaScript error with the provided [msg] and optional [cause]. + * + * @param msg Message to enclose for the error. + * @param cause Caught error to wrap, if any. + * @return Constructed JS exception type. + */ + @Suppress("NOTHING_TO_INLINE") + inline fun error( + msg: String, + cause: Throwable? = null, + errno: Int? = null, + vararg extraProps: Pair, + ): Nothing = throw of(msg, cause, errno = errno, *extraProps) + /** * Wrap a caught [throwable] in a JavaScript error; if no error type is specified, a [ValueError] will be raised. * * @param throwable Caught error to wrap. * @return Constructed JS exception type. */ - fun wrap(throwable: Throwable, type: KClass? = null): Error { - return if (type == null) { - wrapped(throwable, TypeError::class) - } else { - wrapped(throwable, type) - } + fun wrap(throwable: Throwable, type: KClass? = null): Error = if (type == null) { + wrapped(throwable, TypeError::class) + } else { + wrapped(throwable, type) } /** diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsPromiseImpl.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsPromiseImpl.kt index 794559bd5d..4d65308705 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsPromiseImpl.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/intrinsics/js/JsPromiseImpl.kt @@ -14,12 +14,16 @@ package elide.runtime.gvm.internals.intrinsics.js +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import org.graalvm.polyglot.Value import java.util.concurrent.CountDownLatch import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import java.util.function.Supplier +import kotlinx.coroutines.runBlocking +import elide.runtime.gvm.GuestExecutor import elide.runtime.intrinsics.js.JsPromise import elide.vm.annotations.Polyglot @@ -30,9 +34,10 @@ internal class JsPromiseImpl private constructor ( private val ready: AtomicBoolean = AtomicBoolean(false), private val latch: CountDownLatch? = null, private val producer: () -> T, + private val future: ListenableFuture, private val value: AtomicReference = AtomicReference(), private val err: AtomicReference = AtomicReference(), -) : JsPromise { +) : ListenableFuture by future, JsPromise { // Whether the promise has started executing. private val started: AtomicBoolean = AtomicBoolean(false) @@ -82,25 +87,45 @@ internal class JsPromiseImpl private constructor ( } companion object { - @JvmStatic fun of(producer: Supplier): JsPromise = JsPromiseImpl(producer = { producer.get() }) - @JvmStatic fun of(promise: Future): JsPromise = JsPromiseImpl(producer = { promise.get() }) + @JvmStatic fun wrap(promise: ListenableFuture): JsPromise = JsPromiseImpl( + producer = { promise.get() }, + future = promise, + ) - @JvmStatic fun of(latch: CountDownLatch, producer: Supplier): JsPromise = - JsPromiseImpl(latch = latch, producer = { producer.get() }) + @JvmStatic fun GuestExecutor.spawn(promise: () -> T): JsPromise = JsPromiseImpl( + producer = promise, + future = submit(promise), + ) - @JvmStatic inline fun of(crossinline callable: () -> T): JsPromise = - JsPromiseImpl(producer = { callable.invoke() }) + @JvmStatic fun GuestExecutor.latched(latch: CountDownLatch, promise: () -> T): JsPromise = JsPromiseImpl( + producer = promise, + latch = latch, + future = submit(promise), + ) + + @JvmStatic fun GuestExecutor.spawnSuspending(promise: suspend () -> T): JsPromise = JsPromiseImpl( + producer = { + runBlocking { promise.invoke() } + }, + future = submit { + runBlocking(this) { + promise.invoke() + } + }, + ) @JvmStatic fun resolved(value: T): JsPromise = JsPromiseImpl( ready = AtomicBoolean(true), value = AtomicReference(value), producer = { value }, + future = Futures.immediateFuture(value), ) @JvmStatic fun rejected(err: Throwable): JsPromise = JsPromiseImpl( ready = AtomicBoolean(true), err = AtomicReference(err), producer = { throw err }, + future = Futures.immediateFailedFuture(err), ) } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/NodeStdlib.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/NodeStdlib.kt index 251a9f511e..8220707abd 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/NodeStdlib.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/NodeStdlib.kt @@ -154,21 +154,6 @@ public object NodeStdlib { */ public val events: EventsAPI by lazy { NodeEventsModuleFacade.obtain() } - /** - * ## `fs` - * - * Provides access to a compliant implementation of the Node File System API, at the built-in module name `fs`. - */ - public val fs: FilesystemAPI by lazy { NodeFilesystem.createStd() } - - /** - * ## `fs/promises` - * - * Provides access to a compliant implementation of the Node File System Promises API, at the built-in module name - * `fs/promises`. - */ - public val fsPromises: FilesystemPromiseAPI by lazy { NodeFilesystem.createPromises() } - /** * ## `http` * diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/fs/NodeFilesystem.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/fs/NodeFilesystem.kt index 39ef7c8fa7..6c6e815660 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/fs/NodeFilesystem.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/internals/node/fs/NodeFilesystem.kt @@ -14,7 +14,6 @@ package elide.runtime.gvm.internals.node.fs -import com.google.common.util.concurrent.MoreExecutors import org.graalvm.polyglot.Value import org.graalvm.polyglot.proxy.ProxyExecutable import java.io.BufferedReader @@ -26,27 +25,31 @@ import java.nio.channels.WritableByteChannel import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.nio.file.AccessMode +import java.nio.file.AccessMode.* +import java.nio.file.CopyOption import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption import java.util.* -import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicReference import java.util.function.Supplier import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.asCoroutineDispatcher -import kotlin.coroutines.CoroutineContext import elide.annotations.Eager import elide.annotations.Factory import elide.annotations.Singleton +import elide.runtime.gvm.GuestExecutor +import elide.runtime.gvm.GuestExecutorProvider import elide.runtime.gvm.internals.intrinsics.Intrinsic import elide.runtime.gvm.internals.intrinsics.js.AbstractNodeBuiltinModule import elide.runtime.gvm.internals.intrinsics.js.JsError +import elide.runtime.gvm.internals.intrinsics.js.JsPromiseImpl.Companion.spawn import elide.runtime.gvm.internals.intrinsics.js.JsSymbol.JsSymbols.asJsSymbol import elide.runtime.gvm.internals.node.NodeStdlib -import elide.runtime.gvm.vfs.HostVFS import elide.runtime.intrinsics.GuestIntrinsic.MutableIntrinsicBindings import elide.runtime.intrinsics.js.JsPromise +import elide.runtime.intrinsics.js.err.AbstractJsException +import elide.runtime.intrinsics.js.err.Error import elide.runtime.intrinsics.js.err.TypeError import elide.runtime.intrinsics.js.node.FilesystemAPI import elide.runtime.intrinsics.js.node.FilesystemPromiseAPI @@ -59,6 +62,9 @@ import elide.runtime.plugins.vfs.VfsListener import elide.runtime.vfs.GuestVFS import elide.vm.annotations.Polyglot +// Default copy mode value. +private const val DEFAULT_COPY_MODE: Int = 0 + // Listener bean for VFS init. @Singleton @Eager public class VfsInitializerListener : VfsListener, Supplier { private val filesystem: AtomicReference = AtomicReference() @@ -67,21 +73,21 @@ import elide.vm.annotations.Polyglot filesystem.set(fileSystem) } - override fun get(): GuestVFS { - return filesystem.get() ?: error("VFS not yet initialized") - } + override fun get(): GuestVFS = filesystem.get().also { assert(it != null) { "VFS is not initialized" } } } // Installs the Node `fs` and `fs/promises` modules into the intrinsic bindings. @Intrinsic @Factory internal class NodeFilesystemModule ( private val vfs: VfsInitializerListener, + private val executorProvider: GuestExecutorProvider, ) : AbstractNodeBuiltinModule() { - private val executor: ExecutorService by lazy { - MoreExecutors.newDirectExecutorService() + private val std: FilesystemAPI by lazy { + NodeFilesystem.createStd(executorProvider.executor(), vfs.get()) } - private val std: FilesystemAPI by lazy { NodeFilesystem.createStd(executor, vfs.get()) } - private val promises: FilesystemPromiseAPI by lazy { NodeFilesystem.createPromises(executor, vfs.get()) } + private val promises: FilesystemPromiseAPI by lazy { + NodeFilesystem.createPromises(executorProvider.executor(), vfs.get()) + } @Singleton fun provideStd(): FilesystemAPI = std @Singleton fun providePromises(): FilesystemPromiseAPI = promises @@ -96,23 +102,16 @@ import elide.vm.annotations.Polyglot internal object NodeFilesystem { internal const val SYMBOL_STD: String = "node_fs" internal const val SYMBOL_PROMISES: String = "node_fs_promises" - private val directExecutor by lazy { MoreExecutors.newDirectExecutorService() } - - /** @return Host-only implementation of the `fs` module. */ - fun createStd(): FilesystemAPI = NodeFilesystemProxy(directExecutor, HostVFS.acquireWritable()) - - /** @return Host-only implementation of the `fs/promises` module. */ - fun createPromises(): FilesystemPromiseAPI = NodeFilesystemPromiseProxy(directExecutor, HostVFS.acquireWritable()) /** @return Instance of the `fs` module. */ fun createStd( - exec: ExecutorService, + exec: GuestExecutor, filesystem: GuestVFS ): FilesystemAPI = NodeFilesystemProxy(exec, filesystem) /** @return Instance of the `fs/promises` module. */ fun createPromises( - exec: ExecutorService, + exec: GuestExecutor, filesystem: GuestVFS ): FilesystemPromiseAPI = NodeFilesystemPromiseProxy( exec, @@ -126,6 +125,9 @@ internal object FilesystemConstants { const val R_OK: Int = 4 const val W_OK: Int = 2 const val X_OK: Int = 1 + const val COPYFILE_EXCL: Int = 1 + const val COPYFILE_FICLONE: Int = 2 + const val COPYFILE_FICLONE_FORCE: Int = 4 } // Context for a file write operation with managed state. @@ -166,13 +168,13 @@ internal interface FileWriterContext { is ByteArray -> writeBytes(data) is String -> writeString(data) is Buffer -> writeBuffer(data) - else -> error("Unknown type passed to `fs` writeFile: $data") + else -> JsError.error("Unknown type passed to `fs` writeFile: $data") } } } // Convert a file data input value to a raw byte array suitable for reading or writing. -private fun guestToStringOrBuffer(value: Any?, encoding: Charset = StandardCharsets.UTF_8): ByteArray = when (value) { +internal fun guestToStringOrBuffer(value: Any?, encoding: Charset = StandardCharsets.UTF_8): ByteArray = when (value) { null -> throw JsError.valueError("Cannot read or write `null` as file data") is ByteArray -> value is String -> value.toByteArray(encoding) @@ -187,32 +189,83 @@ private fun guestToStringOrBuffer(value: Any?, encoding: Charset = StandardChars else -> throw JsError.valueError("Unknown host type passed as file data: $value") } +// From Node constants, determine the set of `CopyOption` values to use. +private fun copyOpts(mode: Int = DEFAULT_COPY_MODE): Array = when (mode) { + FilesystemConstants.COPYFILE_EXCL -> arrayOf() + FilesystemConstants.COPYFILE_FICLONE -> arrayOf(StandardCopyOption.COPY_ATTRIBUTES) + FilesystemConstants.COPYFILE_FICLONE_FORCE -> arrayOf( + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING, + ) + + else -> arrayOf(StandardCopyOption.REPLACE_EXISTING) +} + +// From Node constants, determine the set of `CopyOption` values to use, as a guest value. +private fun copyOpts(mode: Value?): Int = when (mode) { + null -> DEFAULT_COPY_MODE // default mode value + else -> when { + mode.isNull -> DEFAULT_COPY_MODE + mode.isNumber -> { + assert(mode.fitsInInt()) { "Mode for `copyFile` must be no bigger than an integer" } + mode.asInt() + } + + else -> throw JsError.typeError("Unknown type passed to `fs` copyFile: ${mode.asString()}") + } +} + +// Perform a file copy operation. +private fun doCopyFile(src: Path, dest: Path, mode: Int = 0, callback: ((Throwable?) -> Unit)? = null): Throwable? { + var exc: Throwable? = null + try { + Files.copy(src.toJavaPath(), dest.toJavaPath(), *copyOpts(mode)) + } catch (ioe: IOException) { + exc = ioe + } + callback?.invoke(exc) + return exc +} + +// Perform a file copy operation. +private fun doCopyFileGuest(src: Path, dest: Path, mode: Int, callback: Value?): Throwable? { + return doCopyFile(src, dest, mode) { err -> + if (callback != null && callback.canExecute()) { + if (err != null) callback.executeVoid(err) + else callback.executeVoid() + } + } +} + // Implements common baseline functionality for the Node filesystem modules. internal abstract class FilesystemBase ( - protected val exec: ExecutorService, + protected val exec: GuestExecutor, protected val fs: GuestVFS, - protected val dispatcher: CoroutineDispatcher = exec.asCoroutineDispatcher(), - protected val fsContext: CoroutineContext = dispatcher + CoroutineName("node-fs"), + protected val dispatcher: CoroutineDispatcher = exec.dispatcher, ) { protected fun resolvePath(operation: String, path: Value): Path = when { path.isString -> NodeStdlib.path.parse(path.asString()) - else -> error("Unknown type passed to `fs` $operation: ${path.asString()}") + else -> JsError.error("Unknown type passed to `fs` $operation: ${path.asString()}") } + // Obtain current context, if any, and then execute in the background; once execution completes, the context is again + // entered, and execution continues. + protected inline fun withExec(noinline op: () -> T): JsPromise = exec.spawn { op() } + private fun constantToAccessMode(constant: Int = FilesystemConstants.F_OK): AccessMode { return when (constant) { FilesystemConstants.F_OK, - FilesystemConstants.R_OK -> AccessMode.READ - FilesystemConstants.W_OK -> AccessMode.WRITE - FilesystemConstants.X_OK -> AccessMode.EXECUTE - else -> error("Unknown constant passed to `fs` access: $constant") + FilesystemConstants.R_OK -> READ + FilesystemConstants.W_OK -> WRITE + FilesystemConstants.X_OK -> EXECUTE + else -> JsError.error("Unknown constant passed to `fs` access: $constant") } } protected fun translateMode(options: Value?): AccessMode = when { options == null || options.isNull -> constantToAccessMode() options.isNumber -> constantToAccessMode(options.asInt()) - else -> error("Unknown type passed as access mode: ${options.asString()}") + else -> JsError.error("Unknown type passed as access mode: ${options.asString()}") } protected fun readFileOptions(options: Value?): ReadFileOptions { @@ -226,7 +279,7 @@ internal abstract class FilesystemBase ( if (encoding == null) ReadFileOptions.DEFAULTS else ReadFileOptions.fromGuest(options) } - else -> error("Unknown options passed to `fs` readFile: $options") + else -> JsError.error("Unknown options passed to `fs` readFile: $options") } } @@ -240,80 +293,91 @@ internal abstract class FilesystemBase ( options.hasMembers() -> { val encoding = options.getMember("encoding") - if (encoding == null) { - WriteFileOptions.DEFAULTS - } else WriteFileOptions( + if (encoding == null) WriteFileOptions.DEFAULTS else WriteFileOptions( encoding = when { encoding.isNull -> null encoding.isString -> encoding.asString() - else -> error("Unknown encoding type passed to `fs` writeFile: ${encoding.asString()}") + else -> JsError.error("Unknown encoding type passed to `fs` writeFile: ${encoding.asString()}") }, ) } - else -> error("Unknown options passed to `fs` writeFile: ${options.asString()}") + else -> JsError.error("Unknown options passed to `fs` writeFile: ${options.asString()}") } } protected fun resolveEncoding(encoding: StringOrBuffer?): Charset? { - return if (encoding == null) null else when (encoding) { - is String -> resolveEncodingString(encoding) - is Buffer -> TODO("`Buffer` is not supported yet for `fs` operations") - else -> error("Unknown encoding passed to `fs` readFile: $encoding") - } + return if (encoding == null) null else resolveEncodingString( + encoding as? String ?: JsError.error("Unknown encoding passed to `fs` readFile: $encoding") + ) } private fun resolveEncodingString(encoding: String): Charset = when (encoding.trim().lowercase()) { - "utf8", "utf-8" -> StandardCharsets.UTF_8 - "utf16", "utf-16" -> StandardCharsets.UTF_16 - "utf32", "utf-32" -> TODO("UTF-32 is not implemented yet") - "ascii" -> StandardCharsets.US_ASCII - "latin1", "binary" -> StandardCharsets.ISO_8859_1 - else -> error("Unknown encoding passed to `fs` readFile: $encoding") + "utf8", "utf-8" -> Charsets.UTF_8 + "utf16", "utf-16" -> Charsets.UTF_16 + "utf32", "utf-32" -> Charsets.UTF_32 + "ascii" -> Charsets.US_ASCII + "latin1", "binary" -> Charsets.ISO_8859_1 + else -> JsError.error("Unknown encoding passed to `fs` readFile: $encoding") } private fun checkFileExists(path: java.nio.file.Path) { when { - !fs.existsAny(path) -> error("ENOENT: no such file or directory, open '${path}'") - !Files.isRegularFile(path) -> error("EISDIR: illegal operation on a directory, open '${path}'") + !fs.existsAny(path) -> JsError.error("ENOENT: no such file or directory, open '${path}'") + !Files.isRegularFile(path) -> JsError.error("EISDIR: illegal operation on a directory, open '${path}'") } } - protected fun checkFile(path: java.nio.file.Path, mode: AccessMode = AccessMode.READ) { + protected fun checkFile(path: java.nio.file.Path, mode: AccessMode = READ) { checkFileExists(path) when (mode) { - AccessMode.READ -> checkFileForRead(path) - AccessMode.WRITE -> checkFileForWrite(path) - AccessMode.EXECUTE -> error("EACCES: permission denied for execute, open '${path}'") + READ -> checkFileForRead(path) + WRITE -> checkFileForWrite(path) + EXECUTE -> checkFileForExec(path) } } protected fun checkFileForRead(path: java.nio.file.Path) { checkFileExists(path) when { - !Files.isReadable(path) -> error("EACCES: permission denied for read, open '${path}'") + !Files.isReadable(path) -> throw JsError.of( + "EPERM: operation not permitted, access '$path'", + errno = -1, + "code" to "EPERM", + "syscall" to "access", + "path" to path.toString(), + ) } } protected fun checkFileForWrite(path: java.nio.file.Path, expectExists: Boolean = false) { if (expectExists) checkFileExists(path) when { - !Files.isWritable(path.parent) -> error("EACCES: permission denied for write, open '${path}'") + !Files.isWritable(path.parent) -> JsError.error("EACCES: permission denied for write, open '${path}'") } } - protected inline fun openAndRead(path: java.nio.file.Path, op: SeekableByteChannel.() -> T): T { - checkFileForRead(path) - - return (try { - Files.newByteChannel(path, StandardOpenOption.READ) - } catch (e: IOException) { - error("EACCES: failed to open stream to '${path}': ${e.message}") - }).use { - op(it) + protected fun checkFileForExec(path: java.nio.file.Path) { + when { + !Files.isExecutable(path.parent) -> JsError.error("EACCES: permission denied for exec, open '${path}'") } } + // Open and read a file, closing it when finished. + protected inline fun openAndRead(path: java.nio.file.Path, op: SeekableByteChannel.() -> T): T = + try { + Files.newByteChannel(path, StandardOpenOption.READ).use { op(it) } + } catch (ioe: NoSuchFileException) { + JsError.error( + "ENOENT: no such file or directory at '$path'", + cause = ioe, + errno = -2, + "syscall" to "open", + ) + } catch (ioe: IOException) { + JsError.error("I/O error during file read", cause = ioe) + } + protected fun SeekableByteChannel.readFileData(path: java.nio.file.Path, encoding: Charset?): StringOrBuffer { return try { when (encoding) { @@ -321,31 +385,36 @@ internal abstract class FilesystemBase ( TODO("Binary read not implemented: fs.readFileSync(Value, ...) (Node API)") } - StandardCharsets.US_ASCII, - StandardCharsets.UTF_8, - StandardCharsets.UTF_16, - StandardCharsets.UTF_16LE, - StandardCharsets.UTF_16BE -> Channels.newReader(this, encoding).let { reader -> + Charsets.US_ASCII, + Charsets.UTF_8, + Charsets.UTF_16, + Charsets.UTF_32 -> Channels.newReader(this, encoding).let { reader -> BufferedReader(reader).use { it.readText() } } - else -> error("Unsupported encoding for `fs` readFileSync: ${encoding.name()}") + else -> throw JsError.valueError("Unsupported encoding for `fs` readFileSync: ${encoding.name()}") } + } catch (ioe: NoSuchFileException) { + JsError.error("ENOENT: no such file or directory at '$path'", cause = ioe) } catch (ioe: IOException) { - error("EIO: failed to read file '${path}': ${ioe.message}") + JsError.error("I/O error during buffered file read", cause = ioe) } } protected inline fun checkForDirectoryCreate(path: java.nio.file.Path, op: () -> T): T { when { - Files.exists(path) -> error("EEXIST: file already exists, mkdir '${path}'") - !Files.isWritable(path.parent) -> error("EACCES: permission denied, mkdir '${path}'") + Files.exists(path) -> JsError.error("EEXIST: file already exists, mkdir '${path}'") + !Files.isWritable(path.parent) -> JsError.error("EACCES: permission denied, mkdir '${path}'") } return op() } protected fun createDirectory(path: java.nio.file.Path, recursive: Boolean = false, op: ((Path) -> Unit)? = null) { - if (recursive) Files.createDirectories(path) else Files.createDirectory(path) + try { + if (recursive) Files.createDirectories(path) else Files.createDirectory(path) + } catch (ioe: IOException) { + throw JsError.wrap(ioe) + } op?.invoke(Path.from(path)) } @@ -373,25 +442,53 @@ internal abstract class FilesystemBase ( } // Implements the Node `fs` module. -internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeFilesystemAPI, FilesystemBase(exec, fs) { - @Polyglot override fun access(path: Value, callback: Value) = access( - resolvePath("access", path), - AccessMode.READ - ) { - if (callback.canExecute()) callback.executeVoid(it) +internal class NodeFilesystemProxy (exec: GuestExecutor, fs: GuestVFS) : NodeFilesystemAPI, FilesystemBase(exec, fs) { + @Polyglot override fun access(path: Value, callback: Value) { + if ( + !callback.canExecute() && + !callback.isHostObject + ) throw JsError.typeError("Callback to `fs.access` should be executable") + + access( + resolvePath("access", path), + READ + ) { + callback.executeVoid(it) + } } - @Polyglot override fun access(path: Value, mode: Value, callback: Value) = access( - resolvePath("access", path), - translateMode(mode), - ) { - if (callback.canExecute()) callback.executeVoid(it) + @Polyglot override fun access(path: Value, mode: Value, callback: Value) { + if ( + !callback.canExecute() && + !callback.isHostObject + ) throw JsError.typeError("Callback to `fs.access` should be executable") + + access( + resolvePath("access", path), + translateMode(mode), + ) { + callback.executeVoid(it) + } } - @Polyglot override fun access(path: Path, mode: AccessMode, callback: AccessCallback) = try { - checkFileForRead(path.toJavaPath()) - } catch (err: Throwable) { - callback(JsError.wrap(err)) + @Polyglot override fun access(path: Path, mode: AccessMode, callback: AccessCallback) { + withExec { + try { + val nioPath = path.toJavaPath() + when (mode) { + READ -> checkFileForRead(nioPath) + WRITE -> checkFileForWrite(nioPath) + EXECUTE -> checkFileForExec(nioPath) + } + null + } catch (typeError: TypeError) { + throw typeError // immediately throw type errors, so they surface at callsites + } catch (ioe: Error) { + ioe + }.let { + callback.invoke(it) + } + } } @Polyglot override fun accessSync(path: Value) = @@ -401,11 +498,7 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF accessSync(resolvePath("accessSync", path), translateMode(mode)) @Polyglot override fun accessSync(path: Path, mode: AccessMode) { - try { - checkFileForRead(path.toJavaPath()) - } catch (err: Throwable) { - throw JsError.wrap(err) - } + checkFileForRead(path.toJavaPath()) } @Polyglot override fun exists(path: Value, callback: Value) = exists(resolvePath("exists", path)) { @@ -413,50 +506,47 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF } @Polyglot override fun exists(path: Path, callback: (Boolean) -> Unit) { - callback(Files.exists(path.toJavaPath())) + withExec { + callback(Files.exists(path.toJavaPath())) + } } @Polyglot override fun existsSync(path: Value): Boolean = existsSync(resolvePath("existsSync", path)) @Polyglot override fun existsSync(path: Path): Boolean = Files.exists(path.toJavaPath()) - @Polyglot override fun readFile(path: Value, options: Value, callback: ReadFileCallback) { - val resolved = resolvePath("readFile", path) + @Polyglot override fun readFile(path: Value, options: Value?, callback: ReadFileCallback?) { val opts = readFileOptions(options) - val nioPath = resolved.toJavaPath() - val encoding = resolveEncoding(opts.encoding) + val optionsIsCallback = callback == null && options != null && options.canExecute() + val encoding = resolveEncoding(opts.encoding) ?: when { + optionsIsCallback -> StandardCharsets.UTF_8 + else -> null + } - exec.submit { - openAndRead(nioPath) { - callback(null, try { - readFileData(nioPath, encoding) - } catch (err: Throwable) { - callback(TypeError.create(err), null) - }) + val cbk: (AbstractJsException?, StringOrBuffer?) -> Unit = { err, data -> + if (callback != null) { + if (err != null) callback(err, null) + else callback(null, data) + } else if (optionsIsCallback) { + if (err != null) options!!.executeVoid(err, null) + else options!!.executeVoid(null, data) + } else { + throw JsError.typeError("Callback for `readFile` must be a function") } } - } - - @Polyglot override fun readFile(path: Value, callback: ReadFileCallback) { - val resolved = resolvePath("readFile", path) - val nioPath = resolved.toJavaPath() - val encoding = resolveEncoding(ReadFileOptions.DEFAULTS.encoding) - exec.submit { + withExec { + val nioPath = resolvePath("readFile", path).toJavaPath() openAndRead(nioPath) { - callback(null, try { - readFileData(nioPath, encoding) - } catch (err: Throwable) { - callback(TypeError.create(err), null) - }) + cbk(null, readFileData(nioPath, encoding)) } } } @Polyglot override fun readFile(path: Path, options: ReadFileOptions, callback: ReadFileCallback) { val encoding = resolveEncoding(options.encoding) - val nioPath = path.toJavaPath() - exec.submit { + withExec { + val nioPath = path.toJavaPath() openAndRead(nioPath) { callback(null, try { readFileData(nioPath, encoding) @@ -471,8 +561,10 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF val resolved = resolvePath("readFileSync", path) val opts = readFileOptions(options) val nioPath = resolved.toJavaPath() - val encoding = resolveEncoding(opts.encoding) - + val encoding = resolveEncoding(opts.encoding) ?: when { + options == null || options.isNull -> StandardCharsets.UTF_8 + else -> null + } return openAndRead(nioPath) { readFileData(nioPath, encoding) } @@ -481,7 +573,6 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF @Polyglot override fun readFileSync(path: Path, options: ReadFileOptions): StringOrBuffer { val nioPath = path.toJavaPath() val encoding = resolveEncoding(options.encoding) - return openAndRead(nioPath) { readFileData(nioPath, encoding) } @@ -489,12 +580,12 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF @Polyglot override fun writeFile(path: Value, data: Value, callback: Value) = writeFile(resolvePath("writeFile", path), guestToStringOrBuffer(data), WriteFileOptions.DEFAULTS) { - if (callback.canExecute()) callback.executeVoid(it) + callback.executeVoid(it) } @Polyglot override fun writeFile(path: Value, data: Value, options: Value?, callback: Value) = writeFile(resolvePath("writeFile", path), guestToStringOrBuffer(data), writeFileOptions(options)) { - if (callback.canExecute()) callback.executeVoid(it) + callback.executeVoid(it) } override fun writeFile(path: Path, data: String, options: WriteFileOptions, callback: WriteFileCallback) = @@ -506,18 +597,13 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF ) override fun writeFile(path: Path, data: ByteArray, options: WriteFileOptions, callback: WriteFileCallback) { - val nioPath = path.toJavaPath() val encoding = resolveEncoding(options.encoding) - - exec.submit { - try { - openForWrite(nioPath) { - writeFileData(nioPath, encoding) { - writeStringOrBuffer(data) - } + withExec { + val nioPath = path.toJavaPath() + openForWrite(nioPath) { + writeFileData(nioPath, encoding) { + writeStringOrBuffer(data) } - } catch (err: Throwable) { - callback(TypeError.create(err)) } } } @@ -550,43 +636,35 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF val nioPath = path.toJavaPath() val encoding = resolveEncoding(options.encoding) - try { - openForWrite(nioPath) { - writeFileData(nioPath, encoding) { - writeStringOrBuffer(data) - } + openForWrite(nioPath) { + writeFileData(nioPath, encoding) { + writeStringOrBuffer(data) } - } catch (err: Throwable) { - throw TypeError.create(err) } } @Polyglot override fun mkdir(path: Value, callback: Value?) = mkdir(path, null, callback) @Polyglot override fun mkdir(path: Value, options: Value?, callback: Value?) { - require(callback != null && !callback.isNull) { + assert(callback != null && !callback.isNull) { "Callback for `mkdir` cannot be `null` or missing" } - exec.submit { + withExec { mkdir( resolvePath("mkdir", path), MkdirOptions.fromGuest(options), ) { - if (callback.canExecute()) callback.executeVoid(it) + callback?.executeVoid(it) } } } @Polyglot override fun mkdir(path: Path, options: MkdirOptions, callback: MkdirCallback) { - val nioPath = path.toJavaPath() - exec.submit { + withExec { + val nioPath = path.toJavaPath() checkForDirectoryCreate(nioPath) { - try { - createDirectory(nioPath, options.recursive) { - callback.invoke(null) - } - } catch (ioe: IOException) { - callback.invoke(TypeError.create(ioe)) + createDirectory(nioPath, options.recursive) { + callback.invoke(null) } } } @@ -606,10 +684,61 @@ internal class NodeFilesystemProxy (exec: ExecutorService, fs: GuestVFS) : NodeF nioPath }.toString() } + + override fun copyFile(src: Path, dest: Path, mode: Int, callback: ((Throwable?) -> Unit)?) { + doCopyFile(src, dest, mode, callback) + } + + @Polyglot override fun copyFile(src: Value, dest: Value, callback: Value) { + copyFile(src, dest, mode = null, callback) + } + + @Polyglot override fun copyFile(src: Value, dest: Value, mode: Value?, callback: Value) { + val modeValue = if (mode?.isNumber != true) 0 else { + assert(mode.fitsInInt()) { "Mode for `copyFile` must be no bigger than an integer" } + mode.asInt() + } + val cbk = when { + // if `callback` is `null`-like, and `mode` is executable, the `mode` should default, and the callback should be + // used via `mode`. + callback.isNull && mode != null && mode.canExecute() -> mode + + // otherwise, use the provided callback + else -> callback + } + + withExec { + val srcPath = resolvePath("copyFile", src) + val destPath = resolvePath("copyFile", dest) + + doCopyFileGuest(srcPath, destPath, modeValue, cbk) + } + } + + override fun copyFileSync(src: Path, dest: Path, mode: Int) { + when (val err = doCopyFile(src, dest, mode)) { + null -> {} + else -> throw err + } + } + + @Polyglot override fun copyFileSync(src: Value, dest: Value) { + copyFileSync(src, dest, mode = null) + } + + @Polyglot override fun copyFileSync(src: Value, dest: Value, mode: Value?) { + doCopyFile( + resolvePath("copyFileSync", src), + resolvePath("copyFileSync", dest), + copyOpts(mode), + ) { + if (it != null) throw it + } + } } // Implements the Node `fs/promises` module. -private class NodeFilesystemPromiseProxy (executor: ExecutorService, fs: GuestVFS) +private class NodeFilesystemPromiseProxy (executor: GuestExecutor, fs: GuestVFS) : NodeFilesystemPromiseAPI, FilesystemBase(executor, fs) { @Polyglot override fun readFile(path: Value): JsPromise = readFile(resolvePath("readFile", path), ReadFileOptions.DEFAULTS) @@ -617,24 +746,24 @@ private class NodeFilesystemPromiseProxy (executor: ExecutorService, fs: GuestVF @Polyglot override fun readFile(path: Value, options: Value?): JsPromise = readFile(resolvePath("readFile", path), readFileOptions(options)) - override fun readFile(path: Path, options: ReadFileOptions?) = JsPromise.of(exec.submit { + override fun readFile(path: Path, options: ReadFileOptions?) = withExec { val encoding = resolveEncoding(options?.encoding) - val nioPath = path.toJavaPath() - - openAndRead(nioPath) { - readFileData(nioPath, encoding) + path.toJavaPath().let { nioPath -> + openAndRead(nioPath) { + readFileData(nioPath, encoding) + } } - }) + } @Polyglot override fun access(path: Value): JsPromise = - access(resolvePath("access", path), AccessMode.READ) + access(resolvePath("access", path), READ) @Polyglot override fun access(path: Value, mode: Value?): JsPromise = access(resolvePath("access", path), translateMode(mode)) - override fun access(path: Path, mode: AccessMode?): JsPromise = JsPromise.of(exec.submit { - checkFile(path.toJavaPath(), mode ?: AccessMode.READ) - }) + override fun access(path: Path, mode: AccessMode?): JsPromise = withExec { + checkFile(path.toJavaPath(), mode ?: READ) + } @Polyglot override fun writeFile(path: Value, data: Value): JsPromise = writeFile(resolvePath("writeFile", path), data, null) @@ -643,7 +772,7 @@ private class NodeFilesystemPromiseProxy (executor: ExecutorService, fs: GuestVF writeFile(resolvePath("writeFile", path), data, writeFileOptions(options)) override fun writeFile(path: Path, data: StringOrBuffer, options: WriteFileOptions?): JsPromise { - return JsPromise.of(exec.submit { + return withExec { val encoding = resolveEncoding(options?.encoding) val nioPath = path.toJavaPath() @@ -652,7 +781,7 @@ private class NodeFilesystemPromiseProxy (executor: ExecutorService, fs: GuestVF writeStringOrBuffer(data) } } - }) + } } @Polyglot override fun mkdir(path: Value): JsPromise = @@ -661,14 +790,33 @@ private class NodeFilesystemPromiseProxy (executor: ExecutorService, fs: GuestVF @Polyglot override fun mkdir(path: Value, options: Value?): JsPromise = mkdir(resolvePath("mkdir", path), MkdirOptions.fromGuest(options)) - override fun mkdir(path: Path, options: MkdirOptions?): JsPromise { - return JsPromise.of(exec.submit { - path.toJavaPath().let { nioPath -> - checkForDirectoryCreate(nioPath) { - createDirectory(nioPath, options?.recursive ?: false) - } - nioPath - }.toString() - }) + override fun copyFile(src: Path, dest: Path, mode: Int?): JsPromise = withExec { + doCopyFile(src, dest, mode = mode ?: DEFAULT_COPY_MODE) { + if (it != null) throw it + } + Value.asValue(null) + } + + @Polyglot override fun copyFile(src: Value, dest: Value): JsPromise = + copyFile(resolvePath("copyFile", src), resolvePath("copyFile", dest)) + + @Polyglot override fun copyFile(src: Value, dest: Value, mode: Int): JsPromise { + return withExec { + val srcPath = resolvePath("copyFile", src) + val destPath = resolvePath("copyFile", dest) + when (val err = doCopyFile(srcPath, destPath)) { + null -> Value.asValue(null) + else -> throw err + } + } + } + + override fun mkdir(path: Path, options: MkdirOptions?): JsPromise = withExec { + path.toJavaPath().let { nioPath -> + checkForDirectoryCreate(nioPath) { + createDirectory(nioPath, options?.recursive ?: false) + } + nioPath + }.toString() } } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/js/JavaScript.kt b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/js/JavaScript.kt index f65d2867b4..63320ad699 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/gvm/js/JavaScript.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/gvm/js/JavaScript.kt @@ -87,7 +87,11 @@ public object JavaScript { @JvmStatic public val EMPTY: ExecutionInputs = ExecutionInputs.EMPTY /** - * TBD. + * Build HTTP request state and render property inputs for an embedded SSR render cycle. + * + * @param state Request state. + * @param props Render properties. + * @return Request execution inputs. */ @JvmStatic public fun requestState( state: RequestState, diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/JsPromise.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/JsPromise.kt index 650f35d09a..3c5e2c7bb9 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/JsPromise.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/JsPromise.kt @@ -12,13 +12,18 @@ */ package elide.runtime.intrinsics.js +import com.google.common.util.concurrent.ListenableFuture import org.graalvm.polyglot.Value import org.graalvm.polyglot.proxy.ProxyExecutable import org.graalvm.polyglot.proxy.ProxyObject import java.util.concurrent.CountDownLatch -import java.util.concurrent.Future import java.util.function.Supplier +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.guava.asDeferred +import elide.runtime.gvm.GuestExecutor import elide.runtime.gvm.internals.intrinsics.js.JsPromiseImpl +import elide.runtime.gvm.internals.intrinsics.js.JsPromiseImpl.Companion.spawn +import elide.runtime.gvm.internals.intrinsics.js.JsPromiseImpl.Companion.latched import elide.vm.annotations.Polyglot private const val THEN_SYMBOL = "then" @@ -30,10 +35,15 @@ private val jsPromiseKeys = arrayOf( CATCH_SYMBOL, ) +/** + * @return [Deferred] version of this future. + */ +public inline fun JsPromise.deferred(): Deferred = asDeferred() + /** * TBD. */ -public interface JsPromise : ProxyObject { +public interface JsPromise : ListenableFuture, ProxyObject { /** * TBD. */ @@ -71,15 +81,19 @@ public interface JsPromise : ProxyObject { } public companion object { - @JvmStatic public fun of(latch: CountDownLatch, producer: Supplier): JsPromise = - JsPromiseImpl.of(latch, producer) - - @JvmStatic public fun of(producer: Supplier): JsPromise = JsPromiseImpl.of(producer) - - @JvmStatic public fun of(promise: Future): JsPromise = JsPromiseImpl.of(promise) + @JvmStatic public fun GuestExecutor.of(latch: CountDownLatch, producer: Supplier): JsPromise = + latched(latch) { producer.get() } @JvmStatic public fun resolved(value: T): JsPromise = JsPromiseImpl.resolved(value) @JvmStatic public fun rejected(err: Throwable): JsPromise = JsPromiseImpl.rejected(err) + + @JvmStatic public fun wrap(promise: ListenableFuture): JsPromise = JsPromiseImpl.wrap( + promise, + ) + + @JvmStatic public fun GuestExecutor.of(fn: () -> T): JsPromise = spawn { fn() } + + @JvmStatic public fun GuestExecutor.of(supplier: Supplier): JsPromise = spawn { supplier.get() } } } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/err/Error.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/err/Error.kt index 912123bd3f..27513d5baa 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/err/Error.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/err/Error.kt @@ -12,44 +12,96 @@ */ package elide.runtime.intrinsics.js.err +import org.graalvm.polyglot.Value +import org.graalvm.polyglot.proxy.ProxyObject +import elide.runtime.gvm.GuestError import elide.vm.annotations.Polyglot -/** TBD. */ -public abstract class Error : AbstractJsException, RuntimeException() { +// Base properties and methods exposed for guest code, from a JavaScript `Error` instance. +private val JS_ERROR_PROPS_AND_METHODS = arrayOf( + "message", + "name", + "errno", + "cause", + "fileName", + "lineNumber", + "columnNumber", + "stackTrace", +) + +/** + * # JavaScript Error + * + * Describes the generic interface defined by JavaScript guest errors; properties on instances of this class are made + * available to guest code. + */ +public abstract class Error public constructor(extraProps: Array>) : + AbstractJsException, GuestError, RuntimeException(), ProxyObject { + // Extra properties which should be exposed from `Error` types. + private val extraProps: Map = if (extraProps.isNotEmpty()) extraProps.toMap() else emptyMap() + + /** Empty constructor with no extra properties. */ + public constructor(): this(emptyArray()) + /** - * TBD. + * String message associated with this error, associated at construction time. */ @get:Polyglot public abstract override val message: String /** - * TBD. + * Name of the cause or type of this error, if any. */ @get:Polyglot public abstract val name: String /** - * TBD. + * Error number assigned to this error (also known as the error code). + */ + @get:Polyglot public open val errno: Int? get() = null + + /** + * Causing [Error] which this error wraps, if any. */ @get:Polyglot public override val cause: Error? get() = null /** - * TBD. + * File name where the error was thrown, if any or if known. */ @get:Polyglot public open val fileName: String? get() = null /** - * TBD. + * Line number where the error was thrown, if any or if known. */ @get:Polyglot public open val lineNumber: Int? get() = null /** - * TBD. + * Column number where the error was thrown, if any or if known. */ @get:Polyglot public open val columnNumber: Int? get() = null /** - * TBD. + * Stack trace for this error. */ @get:Polyglot public val stackTrace: Stacktrace get() { TODO("not yet implemented") } + + override fun getMember(key: String?): Any? = when (key) { + "message" -> message + "name" -> name + "errno" -> errno + "cause" -> cause + "fileName" -> fileName + "lineNumber" -> lineNumber + "columnNumber" -> columnNumber + "stackTrace" -> stackTrace + in extraProps -> extraProps[key] + else -> null + } + + override fun getMemberKeys(): Array = JS_ERROR_PROPS_AND_METHODS + override fun hasMember(key: String?): Boolean = key != null && key in JS_ERROR_PROPS_AND_METHODS + + override fun putMember(key: String?, value: Value?) { + // no-op + } } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemAPI.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemAPI.kt index c3a38aca78..d503f9edb6 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemAPI.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemAPI.kt @@ -194,22 +194,14 @@ import elide.vm.annotations.Polyglot * Reads the contents of a file at the specified path; provides the results or an error to the callback. This variant * accepts a polyglot [Value]. * - * @param path The path to the file to read. - * @param options The options to use for the file read operation. - * @param callback The callback to provide the results or an error. - */ - @Polyglot public fun readFile(path: Value, options: Value, callback: ReadFileCallback) - - /** - * ## Method: `fs.readFile` - * - * Reads the contents of a file at the specified path; provides the results or an error to the callback. This variant - * accepts a polyglot [Value]. + * Note: If the final [cbk] parameter is `null`, [options] will be inferred as the callback, and so must be executable + * in this case. * * @param path The path to the file to read. + * @param options The options to use for the file read operation. * @param callback The callback to provide the results or an error. */ - @Polyglot public fun readFile(path: Value, callback: ReadFileCallback) + @Polyglot public fun readFile(path: Value, options: Value?, callback: ReadFileCallback? = null) /** * ## Method: `fs.readFile` @@ -441,6 +433,89 @@ import elide.vm.annotations.Polyglot path: Path, options: MkdirOptions = MkdirOptions.DEFAULTS, ): String? + + /** + * ## Method: `fs.copyFile` + * + * Copies the contents at the provided [src] path to the provided [dest] path, using the given [mode] (if specified) + * as modifiers for the copy operation; default value for [mode] is `0`. + * + * [callback] is dispatched once the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode The mode to use for the copy operation. + * @param callback The callback to provide the results or an error. + */ + public fun copyFile(src: Path, dest: Path, mode: Int = 0, callback: ((Throwable?) -> Unit)? = null) + + /** + * ## Method: `fs.copyFile` + * + * Copies the contents at the provided [src] path to the provided [dest] path, using the given [mode] (if specified) + * as modifiers for the copy operation; default value for [mode] is `0`. + * + * [callback] is dispatched once the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode The mode to use for the copy operation. + * @param callback The callback to provide the results or an error. + */ + @Polyglot public fun copyFile(src: Value, dest: Value, mode: Value?, callback: Value) + + /** + * ## Method: `fs.copyFile` + * + * Copies the contents at the provided [src] path to the provided [dest] path. + * [callback] is dispatched once the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param callback The callback to provide the results or an error. + */ + @Polyglot public fun copyFile(src: Value, dest: Value, callback: Value) + + /** + * ## Method: `fs.copyFileSync` + * + * Copies the contents at the provided [src] path to the provided [dest] path, using the given [mode] (if specified) + * as modifiers for the copy operation; default value for [mode] is `0`. + * + * [callback] is dispatched once the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode The mode to use for the copy operation. + * @param callback The callback to provide the results or an error. + */ + public fun copyFileSync(src: Path, dest: Path, mode: Int = 0) + + /** + * ## Method: `fs.copyFileSync` + * + * Copies the contents at the provided [src] path to the provided [dest] path, using the given [mode] (if specified) + * as modifiers for the copy operation; default value for [mode] is `0`. + * + * This method operates synchronously. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode The mode to use for the copy operation. + */ + @Polyglot public fun copyFileSync(src: Value, dest: Value, mode: Value?) + + /** + * ## Method: `fs.copyFileSync` + * + * Copies the contents at the provided [src] path to the provided [dest] path. + * + * This method operates synchronously. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + */ + @Polyglot public fun copyFileSync(src: Value, dest: Value) } /** diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemPromiseAPI.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemPromiseAPI.kt index 5fda46ed5b..16970cf9bc 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemPromiseAPI.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/js/node/FilesystemPromiseAPI.kt @@ -249,8 +249,7 @@ import elide.vm.annotations.Polyglot * @param options The options to use for the file write operation. * @return A promise which resolves with no value upon success. */ - @Polyglot - public fun writeFile(path: Path, data: StringOrBuffer, options: WriteFileOptions? = null): JsPromise + @Polyglot public fun writeFile(path: Path, data: StringOrBuffer, options: WriteFileOptions? = null): JsPromise /** * ## Method: `fs.mkdir` @@ -298,6 +297,50 @@ import elide.vm.annotations.Polyglot * `recursive` is `true`. */ @Polyglot public fun mkdir(path: Path, options: MkdirOptions? = null): JsPromise + + /** + * ## Method: `fs.copyFile` + * + * Asynchronously copies a file using host-side types. + * + * Copies the contents at the provided [src] path to the provided [dest] path; the returned promise is resolved once + * the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode Copy mode constant to apply. + * @return Upon success, fulfills with `undefined`; otherwise, rejects with an error. + */ + public fun copyFile(src: Path, dest: Path, mode: Int? = null): JsPromise + + /** + * ## Method: `fs.copyFile` + * + * Asynchronously copies a file. + * + * Copies the contents at the provided [src] path to the provided [dest] path; the returned promise is resolved once + * the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @return Upon success, fulfills with `undefined`; otherwise, rejects with an error. + */ + @Polyglot public fun copyFile(src: Value, dest: Value): JsPromise + + /** + * ## Method: `fs.copyFile` + * + * Asynchronously copies a file. + * + * Copies the contents at the provided [src] path to the provided [dest] path; the returned promise is resolved once + * the copy operation completes. + * + * @param src The source path to copy from. + * @param dest The destination path to copy to. + * @param mode The mode to use for the copy operation. + * @return Upon success, fulfills with `undefined`; otherwise, rejects with an error. + */ + @Polyglot public fun copyFile(src: Value, dest: Value, mode: Int): JsPromise } /** diff --git a/packages/graalvm/src/main/resources/META-INF/elide/embedded/runtime/js/js.modules.tar b/packages/graalvm/src/main/resources/META-INF/elide/embedded/runtime/js/js.modules.tar index 373525072ab1e4d183d5551876eb98cc0f0d63d5..b41a47f7414fa02ed3b1065388c79bbbd5695f09 100755 GIT binary patch delta 1218 zcma)*O>fgc5Qe)+-~g=<7d}En-3X#JtRlB*sYvQ_sG19{QgQ(VQe^y*n))MJ+oi~{ zS_uTVRwzkBUL7OHB6rdvO)A;mOM9ILA(SzbC8QC-pEX1PowMIHhn``Je4Z$Lem z!no=MHGQ4SAQw68xQ(vu#sJ6f*;|@PxwS=#$$7jXJ5HW3=krTb-L3@o*d|3D2Yvi^ ztjHY05J3(Y80_gLx>Dkot2RlBR;{RJyINrBWqqgPQs0wzaI@cflKqf*C)_%Gj$UUM z7Z<*~ZAPe`cf^l8&R8J$QBwg_t$0Cj^Nq`K%^H5Ciy^W@Tpg-giYexK_7UV#t9d4q_ zyX}AMQ-p(-+NU72V#x`AKK(S6AT%-X=kWo;`VgTy*rH^OJi_)ZWVinSgfEY# delta 1062 zcmZvaL2J}N6vsPF=|Kdkv?^_FvjLY04%=*ZYa!byrQmufWmmzYEHRmEL$i~1lGH*r z+M>{-N}zM{=E-Y?UKHxZvmZe(Ui|_^oMbne#Xw~wVtR+*s))}ZtfKN=0&vW@Vcm!I>&g8w)A$IV(2OXre zTEyS>tk5M2iVh4nBzi%MRYb6eGBr~rXrXvluIDF_?J;;@Fqz5@m6)Z?8B7Ova41DTMi z-L9QHKAzcd;#yj3OL*gT5u&0er&x4KZ4wCYZWmI#2@hc;nr5i1qAcjAL7_#I|NJH` z-)iD0uGL`^MTTh--`AnAG5CQEJ}ow|3Due%QZsNoD*I547zsi~`T8$`~=My5o|*kk9Ra*`lx% zeP5NmU`{}Cornp`Hg7xxx0h0)Y((4HwH%fgc9D{OD{6-DDgU(tW~w2|>*^L7c~WQb+UaZk;6TY1wP(_&9gtp`{2|CZ={t<`RqiGK{Daa&r>WQJA$Mwo(eTO!zFU~ z8U>I~f0yp_w-l@`nmD*hnG7kLF@>Z@+4~RWFs*^TQOY7cr<`^m9Kw|HooCJfM=Htx zG;33c45Z{o0?J_IRvJhCNa}!0KPIxRjQXo~=9W(=Ez_gkZ=^j+y@T_W3z6wjKQL(E z>)vn(#+4yh{UNmDT91Ad%7!oZ_6fDq_YR-f2ba8g8+<%{38s>pU!I(p42XOD++@;+ O@ciNuY=A`Gz4!+uqF#9b diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/AbstractDualTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/AbstractDualTest.kt index 4b4fece73f..2125f7bd50 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/AbstractDualTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/AbstractDualTest.kt @@ -455,9 +455,9 @@ abstract class AbstractDualTest { } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - protected fun dual(op: () -> Unit): DualTestExecutionProxy = + protected fun dual(op: suspend () -> Unit): DualTestExecutionProxy = dual(true, op) // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - protected abstract fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy + protected abstract fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy } diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/AbstractJsIntrinsicTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/AbstractJsIntrinsicTest.kt index 9d8d8f56ec..8c426923ab 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/AbstractJsIntrinsicTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/AbstractJsIntrinsicTest.kt @@ -19,9 +19,11 @@ import org.graalvm.polyglot.proxy.ProxyHashMap import org.graalvm.polyglot.proxy.ProxyObject import org.junit.jupiter.api.DynamicTest import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.assertDoesNotThrow import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import java.util.function.Function +import kotlinx.coroutines.test.runTest import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext import elide.runtime.core.PolyglotEngineConfiguration @@ -210,8 +212,12 @@ internal abstract class AbstractJsIntrinsicTest( } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { + assertDoesNotThrow { + op.invoke() + } + } return object : DualTestExecutionProxy() { override fun guest(guestOperation: JavaScript) = GuestTestExecution(::withContext) { executeGuestInternal( diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/AbstractNodeFsTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/AbstractNodeFsTest.kt new file mode 100644 index 0000000000..8451ce6691 --- /dev/null +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/AbstractNodeFsTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package elide.runtime.gvm.internals.js.node + +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.test.runTest +import elide.runtime.gvm.js.node.NodeModuleConformanceTest +import elide.runtime.intrinsics.GuestIntrinsic + +internal abstract class AbstractNodeFsTest : NodeModuleConformanceTest() where T: GuestIntrinsic { + protected inline fun withTemp(crossinline op: suspend (Path) -> Unit) = runTest { + val temp = Files.createTempDirectory( + Path.of(System.getProperty("java.io.tmpdir")), + "elide-test-" + ) + val fileOf = temp.toFile() + var didError = false + try { + op(temp) + } catch (ioe: Throwable) { + didError = true + throw ioe + } finally { + if (!didError) fileOf.deleteRecursively() + } + } +} diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsPromisesTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsPromisesTest.kt index a47262c3c7..ca627479be 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsPromisesTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsPromisesTest.kt @@ -10,21 +10,40 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under the License. */ +@file:Suppress("JSUnresolvedReference", "JSDeprecatedSymbols", "JSCheckFunctionSignatures") + package elide.runtime.gvm.internals.js.node -import kotlin.test.Test -import kotlin.test.assertNotNull +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import kotlinx.coroutines.guava.asDeferred +import kotlin.test.* import elide.annotations.Inject +import elide.runtime.gvm.GuestExecutorProvider import elide.runtime.gvm.internals.node.fs.NodeFilesystemModule -import elide.runtime.gvm.js.node.NodeModuleConformanceTest +import elide.runtime.gvm.internals.node.fs.VfsInitializerListener +import elide.runtime.gvm.vfs.EmbeddedGuestVFS +import elide.runtime.intrinsics.js.deferred +import elide.runtime.intrinsics.js.node.WritableFilesystemPromiseAPI +import elide.runtime.intrinsics.js.node.fs.ReadFileOptions +import elide.runtime.intrinsics.js.node.path.Path import elide.testing.annotations.TestCase /** Tests for Elide's implementation of the Node `fs/promises` built-in module. */ -@TestCase internal class NodeFsPromisesTest : NodeModuleConformanceTest() { - @Inject lateinit var filesystem: NodeFilesystemModule +@TestCase internal class NodeFsPromisesTest : AbstractNodeFsTest() { + @Inject private lateinit var execProvider: GuestExecutorProvider + + private val filesystem: NodeFilesystemModule by lazy { + provide() + } override val moduleName: String get() = "fs/promises" - override fun provide(): NodeFilesystemModule = filesystem + override fun provide(): NodeFilesystemModule = NodeFilesystemModule( + VfsInitializerListener().also { + it.onVfsCreated(EmbeddedGuestVFS.empty()) + }, + execProvider, + ) // @TODO(sgammon): Not yet fully supported override fun expectCompliance(): Boolean = false @@ -59,4 +78,151 @@ import elide.testing.annotations.TestCase @Test override fun testInjectable() { assertNotNull(filesystem) } + + @Test fun `access() with text file`() = withTemp { tmp -> + filesystem.providePromises().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + assertNotNull(fs.access(Path.from(samplePath)).asDeferred().await()) + }.guest { + // language=javascript + """ + const { access } = require("node:fs/promises"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath").then(() => { + callbackDispatched = true; + }, (err) => { + callbackDispatched = true; + fileErr = err || null; + }); + test(callbackDispatched).shouldBeTrue(); + test(fileErr).isNull(); + """ + } + } + } + + @Test fun `access() with text file and mode`() = withTemp { tmp -> + filesystem.providePromises().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + assertNotNull(fs.access(Path.from(samplePath)).asDeferred().await()) + + executeGuest { + // language=javascript + """ + const { access, constants } = require("node:fs/promises"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", constants.R_OK).then(() => { + callbackDispatched = true; + }, (err) => { + callbackDispatched = true; + fileErr = err || null; + }); + test(callbackDispatched).shouldBeTrue(); + test(fileErr).isNull(); + """ + } + + executeGuest { + // language=javascript + """ + const { access, constants } = require("node:fs/promises"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", constants.R_OK).then(() => { + callbackDispatched = true; + }, (err) => { + callbackDispatched = true; + fileErr = err || null; + }); + test(callbackDispatched).shouldBeTrue(); + test(fileErr).isNull(); + """ + } + } + } + + @Test fun `readFile() with text file`() = withTemp { tmp -> + filesystem.providePromises().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + val data = assertNotNull(fs.readFile(Path.from(samplePath), ReadFileOptions(encoding = "utf8")) + .deferred() + .await()) + assertIs(data) + assertEquals("Hello, world!", data) + }.guest { + // language=javascript + """ + const { readFile } = require("node:fs/promises"); + test(readFile).isNotNull(); + let fileData = null; + const promise = readFile("$samplePath", { encoding: 'utf-8' }).then((data) => { + fileData = data; + }); + test(promise).isNotNull(); + test(fileData).isNotNull(); + test(typeof fileData === 'string').shouldBeTrue(); + test(fileData).isEqualTo("Hello, world!"); + """ + } + } + } + + @Test fun `copyFile() with valid file`() = withTemp { tmp -> + filesystem.providePromises().let { fs -> + val srcPath = tmp.resolve("some-file.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + Files.writeString(srcPath, "Hello, world!", StandardCharsets.UTF_8) + + assertTrue(Files.exists(srcPath), "src file should exist before creation") + assertFalse(Files.exists(destPath), "dest file should not exist before creation") + assertIs(fs) + fs.copyFile(Path.from(srcPath), Path.from(destPath)).deferred().await() + assertTrue(Files.exists(destPath), "file should exist after creation") + assertEquals("Hello, world!", Files.readString(destPath)) + + Files.delete(destPath) + assertFalse(Files.exists(destPath), "file should not exist before guest test") + + executeGuest { + // language=javascript + """ + const { copyFile } = require("node:fs/promises"); + test(copyFile).isNotNull("expected `copyFile` symbol"); + let copyErr = null; + let didExecute = false; + copyFile("$srcPath", "$destPath").then((err) => { + copyErr = err; + didExecute = true; + }); + test(didExecute).shouldBeTrue(); + test(copyErr).isNull(); + """ + }.doesNotFail() + + assertTrue(Files.exists(destPath)) + assertTrue(Files.isRegularFile(destPath)) + assertEquals("Hello, world!", Files.readString(destPath)) + } + } } diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsTest.kt index 53616898f9..2b01e470a2 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/internals/js/node/NodeFsTest.kt @@ -10,23 +10,29 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under the License. */ -@file:OptIn(DelicateElideApi::class) -@file:Suppress("JSCheckFunctionSignatures", "JSUnresolvedReference") +@file:Suppress("JSCheckFunctionSignatures", "JSUnresolvedReference", "JSDeprecatedSymbols", "LargeClass") package elide.runtime.gvm.internals.js.node +import org.graalvm.polyglot.Value import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows import java.nio.charset.StandardCharsets +import java.nio.file.AccessMode.* import java.nio.file.Files -import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission.* import java.util.concurrent.atomic.AtomicReference +import java.util.function.Function import kotlin.test.* import elide.annotations.Inject -import elide.runtime.core.DelicateElideApi +import elide.runtime.gvm.GuestExecutorProvider +import elide.runtime.gvm.internals.node.fs.FilesystemConstants import elide.runtime.gvm.internals.node.fs.NodeFilesystemModule import elide.runtime.gvm.internals.node.fs.VfsInitializerListener -import elide.runtime.gvm.js.node.NodeModuleConformanceTest import elide.runtime.gvm.vfs.EmbeddedGuestVFS +import elide.runtime.intrinsics.js.err.AbstractJsException +import elide.runtime.intrinsics.js.err.Error +import elide.runtime.intrinsics.js.err.TypeError import elide.runtime.intrinsics.js.node.WritableFilesystemAPI import elide.runtime.intrinsics.js.node.fs.ReadFileOptions import elide.runtime.intrinsics.js.node.fs.WriteFileOptions @@ -34,8 +40,10 @@ import elide.testing.annotations.TestCase import elide.runtime.intrinsics.js.node.path.Path as NodePath /** Tests for Elide's implementation of the Node `fs` built-in module. */ -@TestCase internal class NodeFsTest : NodeModuleConformanceTest() { - val filesystem: NodeFilesystemModule by lazy { +@TestCase internal class NodeFsTest : AbstractNodeFsTest() { + @Inject private lateinit var execProvider: GuestExecutorProvider + + private val filesystem: NodeFilesystemModule by lazy { provide() } @@ -43,26 +51,10 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath override fun provide(): NodeFilesystemModule = NodeFilesystemModule( VfsInitializerListener().also { it.onVfsCreated(EmbeddedGuestVFS.empty()) - } + }, + execProvider, ) - private fun withTemp(op: (Path) -> Unit) { - val temp = Files.createTempDirectory( - Path.of(System.getProperty("java.io.tmpdir")), - "elide-test-" - ) - val fileOf = temp.toFile() - var didError = false - try { - op(temp) - } catch (ioe: Throwable) { - didError = true - throw ioe - } finally { - if (!didError) fileOf.deleteRecursively() - } - } - // @TODO(sgammon): Not yet fully supported override fun expectCompliance(): Boolean = false @@ -293,6 +285,86 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath } @Test fun `access() with text file`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + fs.access(NodePath.from(samplePath)) { err -> + assertNull(err) + } + fs.access( + Value.asValue(NodePath.from(samplePath).toString()), + Value.asValue(FilesystemConstants.R_OK), + Value.asValue(Function { err: AbstractJsException? -> assertNull(err) })) + + assertThrows { + fs.access( + Value.asValue(NodePath.from(samplePath).toString()), + Value.asValue(FilesystemConstants.R_OK), + Value.asValue(true)) + } + + executeGuest { + // language=javascript + """ + const { access } = require("node:fs"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", (err) => { + callbackDispatched = true; + fileErr = err || null; + }); + test(callbackDispatched).shouldBeTrue(); + test(fileErr).isNull(); + """ + } + executeGuest { + // language=javascript + """ + const { access, constants } = require("node:fs"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", constants.R_OK, (err) => { + callbackDispatched = true; + fileErr = err || null; + }); + test(callbackDispatched).shouldBeTrue(); + test(fileErr).isNull(); + """ + } + } + } + + @Test fun `access() should fail with invalid callback type`() = withTemp { tmp -> + filesystem.provideStd().let { + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + executeGuest { + // language=javascript + """ + const { access } = require("node:fs"); + test(access).isNotNull(); + test(() => access("$samplePath", true)).fails(); + """ + } + executeGuest { + // language=javascript + """ + const { access, constants } = require("node:fs"); + test(access).isNotNull(); + test(() => access("$samplePath", constants.R_OK, true)).fails(); + """ + } + } + } + + @Test fun `access() with text file and mode as READ`() = withTemp { tmp -> filesystem.provideStd().let { fs -> // write a file val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() @@ -301,17 +373,21 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath assertEquals("Hello, world!", Files.readString(samplePath)) dual { - fs.access(NodePath.from(samplePath)) { err -> + fs.access(NodePath.from(samplePath), mode = READ) { err -> assertNull(err) } + fs.access( + Value.asValue(samplePath.toString()), + Value.asValue(FilesystemConstants.R_OK), + Value.asValue({ err: Any? -> assertNull(err) })) }.guest { // language=javascript """ - const { access } = require("node:fs"); + const { access, constants } = require("node:fs"); test(access).isNotNull(); let callbackDispatched = false; let fileErr = null; - access("$samplePath", (err) => { + access("$samplePath", constants.R_OK, (err) => { fileErr = err || null; }); test(callbackDispatched).shouldBeFalse(); @@ -321,6 +397,103 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath } } + @Test fun `access() with text file and mode as WRITE`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + fs.access(NodePath.from(samplePath), mode = WRITE) { err -> + assertNull(err) + } + }.guest { + // language=javascript + """ + const { access, constants } = require("node:fs"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", constants.W_OK, (err) => { + fileErr = err || null; + }); + test(callbackDispatched).shouldBeFalse(); + test(fileErr).isNull(); + """ + } + } + } + + @Test fun `access() with text file and mode as EXECUTE`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + assertFalse(Files.isExecutable(samplePath), "sample path should not be executable at first") + + // should not be executable at first + fs.access(NodePath.from(samplePath), mode = EXECUTE) { err -> + assertNotNull(err) + } + + // make it executable + Files.getPosixFilePermissions(samplePath).let { perms -> + Files.setPosixFilePermissions(samplePath, perms + OWNER_EXECUTE) + } + assertTrue(Files.isExecutable(samplePath), "path should now be executable") + + // now it should be executable + fs.access(NodePath.from(samplePath), mode = EXECUTE) { err -> + assertNull(err) + } + } + } + + @Test fun `access() with text file and mode failure`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + // remove read access from self + val perms = Files.getPosixFilePermissions(samplePath) + Files.setPosixFilePermissions(samplePath, setOf( + OWNER_WRITE, + OWNER_EXECUTE, + )) + + dual { + fs.access(NodePath.from(samplePath), mode = READ) { err -> + assertNotNull(err) + } + }.guest { + // language=javascript + """ + const { access, constants } = require("node:fs"); + test(access).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + access("$samplePath", constants.R_OK, (err) => { + fileErr = err || null; + }); + test(callbackDispatched).shouldBeFalse(); + test(fileErr).isNotNull(); + """ + } + + // restore perms and delete + Files.setPosixFilePermissions(samplePath, perms) + Files.delete(samplePath) + } + } + @Test fun `accessSync() with text file`() = withTemp { tmp -> filesystem.provideStd().let { fs -> // write a file @@ -331,6 +504,7 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath dual { assertDoesNotThrow { fs.accessSync(NodePath.from(samplePath)) } + assertDoesNotThrow { fs.accessSync(Value.asValue(NodePath.from(samplePath).toString())) } }.guest { // language=javascript """ @@ -342,6 +516,91 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath } } + @Test fun `accessSync() with text file and mode as READ`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + assertDoesNotThrow { fs.accessSync(NodePath.from(samplePath), READ) } + assertDoesNotThrow { + fs.accessSync( + Value.asValue(NodePath.from(samplePath).toString()), + Value.asValue(FilesystemConstants.R_OK), + ) + } + }.guest { + // language=javascript + """ + const { accessSync, constants } = require("node:fs"); + test(accessSync).isNotNull(); + test(() => accessSync("$samplePath", constants.R_OK)).doesNotFail(); + """ + } + } + } + + @Test fun `accessSync() with text file and mode as WRITE`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + assertDoesNotThrow { fs.accessSync(NodePath.from(samplePath), WRITE) } + assertDoesNotThrow { + fs.accessSync( + Value.asValue(NodePath.from(samplePath).toString()), + Value.asValue(FilesystemConstants.W_OK), + ) + } + }.guest { + // language=javascript + """ + const { accessSync, constants } = require("node:fs"); + test(accessSync).isNotNull(); + test(() => accessSync("$samplePath", constants.W_OK)).doesNotFail(); + """ + } + + // remove read abilities by owner + val perms = Files.getPosixFilePermissions(samplePath) + Files.setPosixFilePermissions(samplePath, setOf( + OWNER_WRITE, + OWNER_EXECUTE, + )) + + assertFalse(Files.isReadable(samplePath), "should not be able to read file after read perms revoked") + + assertThrows { + fs.accessSync(NodePath.from(samplePath), READ) + } + assertThrows { + fs.accessSync( + Value.asValue(NodePath.from(samplePath).toString()), + Value.asValue(FilesystemConstants.R_OK), + ) + } + + executeGuest { + // language=javascript + """ + const { accessSync, constants } = require("node:fs"); + test(accessSync).isNotNull(); + test(() => accessSync("$samplePath", constants.R_OK)).fails(); + """ + } + + // response perms + Files.setPosixFilePermissions(samplePath, perms) + } + } + @Test fun `readFile() with text file`() = withTemp { tmp -> filesystem.provideStd().let { fs -> // write a file @@ -357,6 +616,13 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath assertIs(data) assertEquals("Hello, world!", data) } + + fs.readFile(Value.asValue(samplePath.toString()), Value.asValue(null)) { err, data -> + assertNull(err) + assertNotNull(data) + assertIs(data) + assertEquals("Hello, world!", data) + } }.guest { // language=javascript """ @@ -380,6 +646,47 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath } } + @Test fun `readFile() with text file with default encoding`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + fs.readFile(NodePath.from(samplePath)) { err, data -> + assertNull(err) + assertNotNull(data) + assertIs(data) + assertEquals("Hello, world!", data) + } + }.guest { + // language=javascript + """ + const { readFile } = require("node:fs"); + test(readFile).isNotNull(); + let callbackDispatched = false; + let fileErr = null; + let fileData = null; + + readFile("$samplePath", (err, data) => { + callbackDispatched = true; + fileErr = null; + fileData = data; + }); + test(callbackDispatched).shouldBeTrue("callback must be dispatched"); + test(fileErr).isNull(); + const fileDataType = typeof fileData; + test(typeof fileData === 'string').shouldBeTrue( + 'returned file data should be of type `string` but was `' + fileDataType + '`' + ); + test(fileData).isEqualTo("Hello, world!"); + """ + } + } + } + @Test fun `readFileSync() with text file`() = withTemp { tmp -> filesystem.provideStd().let { fs -> // write a file @@ -405,6 +712,31 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath } } + @Test fun `readFileSync() with text file and default encoding`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + // write a file + val samplePath = tmp.resolve("some-file.txt").toAbsolutePath() + Files.write(samplePath, "Hello, world!".toByteArray()) + assertTrue(Files.exists(samplePath), "should have written file") + assertEquals("Hello, world!", Files.readString(samplePath)) + + dual { + assertEquals( + "Hello, world!", + assertDoesNotThrow { fs.readFileSync(NodePath.from(samplePath), ReadFileOptions(encoding = "utf8")) } + ) + }.guest { + // language=javascript + """ + const { readFileSync } = require("node:fs"); + test(readFileSync).isNotNull(); + test(readFileSync("$samplePath")).isNotNull(); + test(readFileSync("$samplePath")).isEqualTo("Hello, world!"); + """ + } + } + } + @Test fun `mkdir() with valid directory name`() = withTemp { tmp -> filesystem.provideStd().let { fs -> // write a file @@ -571,6 +903,15 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath assertTrue(Files.exists(samplePath), "file should exist after creation") assertEquals("Hello, world!", Files.readString(samplePath)) + Files.delete(samplePath) + + assertDoesNotThrow { + fs.writeFileSync(Value.asValue(NodePath.from(samplePath).toString()), Value.asValue("Hello, world!")) + } + + assertTrue(Files.exists(samplePath), "file should exist after creation") + assertEquals("Hello, world!", Files.readString(samplePath)) + Files.delete(samplePath) executeGuest { // language=javascript @@ -626,4 +967,204 @@ import elide.runtime.intrinsics.js.node.path.Path as NodePath assertEquals("Hello, world!", Files.readString(samplePath2)) } } + + @Test fun `copyFile() with valid file`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + Files.writeString(srcPath, "Hello, world!", StandardCharsets.UTF_8) + + assertTrue(Files.exists(srcPath), "src file should not exist before creation") + assertFalse(Files.exists(destPath), "dest file should not exist before creation") + assertIs(fs) + fs.copyFile(NodePath.from(srcPath), NodePath.from(destPath)) { + assertNull(it, "copy file operation should not fail") + } + assertTrue(Files.exists(destPath), "file should exist after creation") + assertEquals("Hello, world!", Files.readString(destPath)) + + Files.delete(destPath) + assertFalse(Files.exists(destPath), "file should not exist before guest test") + + executeGuest { + // language=javascript + """ + const { copyFile } = require("node:fs"); + test(copyFile).isNotNull(); + let fsErr = null; + let didDispatch = false; + copyFile("$srcPath", "$destPath", (err) => { + fsErr = err || null; + didDispatch = true; + }); + test(fsErr).isNull(); + test(didDispatch).shouldBeTrue(); + """ + }.doesNotFail() + + assertTrue(Files.exists(destPath)) + assertTrue(Files.isRegularFile(destPath)) + assertEquals("Hello, world!", Files.readString(destPath)) + } + } + + @Test fun `copyFileSync() with valid file`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + Files.writeString(srcPath, "Hello, world!", StandardCharsets.UTF_8) + + assertTrue(Files.exists(srcPath), "src file should exist before creation") + assertFalse(Files.exists(destPath), "dest file should not exist before creation") + assertIs(fs) + fs.copyFileSync(NodePath.from(srcPath), NodePath.from(destPath)) + assertTrue(Files.exists(destPath), "file should exist after creation") + assertEquals("Hello, world!", Files.readString(destPath)) + + Files.delete(destPath) + assertFalse(Files.exists(destPath), "file should not exist before guest test") + + fs.copyFileSync( + Value.asValue(NodePath.from(srcPath).toString()), + Value.asValue(NodePath.from(destPath).toString())) + + assertTrue(Files.exists(destPath), "dest file should exist after another copy") + assertEquals("Hello, world!", Files.readString(destPath)) + + Files.delete(destPath) + assertFalse(Files.exists(destPath), "file should not exist before guest test") + + executeGuest { + // language=javascript + """ + const { copyFileSync } = require("node:fs"); + test(copyFileSync).isNotNull(); + copyFileSync("$srcPath", "$destPath"); + """ + }.doesNotFail() + + assertTrue(Files.exists(destPath)) + assertTrue(Files.isRegularFile(destPath)) + assertEquals("Hello, world!", Files.readString(destPath)) + } + } + + @Test fun `copyFile() with valid file and non-overwrite`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + Files.writeString(srcPath, "Hello, world!", StandardCharsets.UTF_8) + Files.writeString(destPath, "Should not overwrite", StandardCharsets.UTF_8) + + assertTrue(Files.exists(srcPath), "src file should exist before creation") + assertTrue(Files.exists(destPath), "dest file should exist before creation") + assertIs(fs) + fs.copyFile(NodePath.from(srcPath), NodePath.from(destPath), mode = FilesystemConstants.COPYFILE_EXCL) { + assertNotNull(it, "copy operation should fail due to non-overwrite") + } + assertTrue(Files.exists(destPath), "file should exist after creation") + assertEquals("Should not overwrite", Files.readString(destPath)) + + executeGuest { + // language=javascript + """ + const { copyFile, constants } = require("node:fs"); + test(copyFile).isNotNull(); + let fsErr = null; + let didDispatch = false; + copyFile("$srcPath", "$destPath", constants.COPYFILE_EXCL, (err) => { + fsErr = err || null; + didDispatch = true; + }); + test(fsErr).isNotNull(); + test(didDispatch).shouldBeTrue(); + """ + }.doesNotFail() + + assertTrue(Files.exists(destPath)) + assertTrue(Files.isRegularFile(destPath)) + assertEquals("Should not overwrite", Files.readString(destPath)) + } + } + + @Test fun `copyFileSync() with valid file and non-overwrite`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + Files.writeString(srcPath, "Hello, world!", StandardCharsets.UTF_8) + Files.writeString(destPath, "Should not overwrite", StandardCharsets.UTF_8) + + assertTrue(Files.exists(srcPath), "src file should exist before creation") + assertTrue(Files.exists(destPath), "dest file should exist before creation") + assertIs(fs) + assertThrows { + fs.copyFileSync(NodePath.from(srcPath), NodePath.from(destPath), mode = FilesystemConstants.COPYFILE_EXCL) + } + assertTrue(Files.exists(destPath), "file should exist after creation") + assertEquals("Should not overwrite", Files.readString(destPath)) + + executeGuest { + // language=javascript + """ + const { copyFileSync, constants } = require("node:fs"); + test(copyFileSync).isNotNull(); + test(() => { copyFileSync("$srcPath", "$destPath", constants.COPYFILE_EXCL) }).fails(); + """ + }.doesNotFail() + + assertTrue(Files.exists(destPath)) + assertTrue(Files.isRegularFile(destPath)) + assertEquals("Should not overwrite", Files.readString(destPath)) + } + } + + @Test fun `copyFile() with missing file`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file-missing.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + + assertFalse(Files.exists(srcPath), "src file should not exist before creation") + assertFalse(Files.exists(destPath), "dest file should not exist before creation") + assertIs(fs) + fs.copyFile(NodePath.from(srcPath), NodePath.from(destPath)) { + assertNotNull(it, "copy file operation should fail") + } + assertFalse(Files.exists(destPath), "dest file should exist after error") + + executeGuest { + // language=javascript + """ + const { copyFile } = require("node:fs"); + test(copyFile).isNotNull(); + copyFile("$srcPath", "$destPath", (err) => { + test(err).isNotNull() + }) + """ + }.doesNotFail() + } + } + + @Test fun `copyFileSync() with missing file`() = withTemp { tmp -> + filesystem.provideStd().let { fs -> + val srcPath = tmp.resolve("some-file-missing.txt").toAbsolutePath() + val destPath = tmp.resolve("some-file-2.txt").toAbsolutePath() + + assertFalse(Files.exists(srcPath), "src file should not exist before creation") + assertFalse(Files.exists(destPath), "dest file should not exist before creation") + assertIs(fs) + assertThrows { + fs.copyFileSync(NodePath.from(srcPath), NodePath.from(destPath)) + } + assertFalse(Files.exists(destPath), "dest file should exist after error") + + executeGuest { + // language=javascript + """ + const { copyFileSync } = require("node:fs"); + test(copyFileSync).isNotNull(); + test(() => { copyFileSync("$srcPath", "$destPath") }).fails(); + """ + }.doesNotFail() + } + } } diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/AbstractJsTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/AbstractJsTest.kt index 775b246c54..f879b0d95e 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/AbstractJsTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/AbstractJsTest.kt @@ -22,6 +22,7 @@ import org.intellij.lang.annotations.Language import java.nio.charset.StandardCharsets import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.test.runTest import elide.annotations.Inject import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotContext @@ -301,8 +302,8 @@ internal abstract class AbstractJsTest : AbstractDualTest() { } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - override fun dual(bind: Boolean, op: () -> Unit): DualTestExecutionProxy { - op.invoke() + override fun dual(bind: Boolean, op: suspend () -> Unit): DualTestExecutionProxy { + runTest { op.invoke() } return object : JsDualTestExecutionProxy() { override fun guest(esm: Boolean, guestOperation: JavaScript) = diff --git a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/node/NodeModuleConformanceTest.kt b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/node/NodeModuleConformanceTest.kt index 7db972744e..8847b998ec 100644 --- a/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/node/NodeModuleConformanceTest.kt +++ b/packages/graalvm/src/test/kotlin/elide/runtime/gvm/js/node/NodeModuleConformanceTest.kt @@ -201,10 +201,10 @@ internal abstract class NodeModuleConformanceTest : GenericJs } // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - protected fun conforms(op: () -> Unit): ConformanceTestExecutionProxy = conforms(true, op) + protected fun conforms(op: suspend () -> Unit): ConformanceTestExecutionProxy = conforms(true, op) // Run the provided `op` on the host, and the provided `guest` via `executeGuest`. - protected fun conforms(bind: Boolean, op: () -> Unit): ConformanceTestExecutionProxy { + protected fun conforms(bind: Boolean, op: suspend () -> Unit): ConformanceTestExecutionProxy { val dual = dual(bind, op) return object : ConformanceTestExecutionProxy() { diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 0000000000..411963eaa6 --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,4 @@ +# `runtime` + +Main runtime entrypoint module. + diff --git a/packages/runtime/src/main/kotlin/elide/tool/cli/cmd/repl/ToolShellCommand.kt b/packages/runtime/src/main/kotlin/elide/tool/cli/cmd/repl/ToolShellCommand.kt index b497dbee7d..c5e038ab48 100644 --- a/packages/runtime/src/main/kotlin/elide/tool/cli/cmd/repl/ToolShellCommand.kt +++ b/packages/runtime/src/main/kotlin/elide/tool/cli/cmd/repl/ToolShellCommand.kt @@ -70,6 +70,7 @@ import elide.runtime.core.PolyglotEngine import elide.runtime.core.PolyglotEngineConfiguration import elide.runtime.core.PolyglotEngineConfiguration.HostAccess import elide.runtime.core.extensions.attach +import elide.runtime.gvm.GuestError import elide.runtime.gvm.internals.GraalVMGuest import elide.runtime.gvm.internals.IntrinsicsManager import elide.runtime.intrinsics.server.http.HttpServerAgent @@ -1294,13 +1295,24 @@ import elide.tool.project.ProjectManager "${language.label} syntax is incomplete", ) - exc.isHostException || exc.message?.contains("HostException: ") == true -> displayFormattedError( - exc, - exc.message ?: "A runtime error was thrown", - advice = "This is an error in Elide. Please report this to the Elide Team with `elide bug`", - stacktrace = true, - internal = true, - ) + exc.isHostException || exc.message?.contains("HostException: ") == true -> { + when (exc.asHostException()) { + // guest error thrown from host-side logic + is GuestError -> displayFormattedError( + exc, + exc.message ?: "An error was thrown", + stacktrace = !interactive.get(), + ) + + else -> displayFormattedError( + exc, + exc.message ?: "A runtime error was thrown", + advice = "This is an error in Elide. Please report this to the Elide Team with `elide bug`", + stacktrace = true, + internal = true, + ) + } + } exc.isGuestException -> displayFormattedError( exc, diff --git a/packages/server/build.gradle.kts b/packages/server/build.gradle.kts index 214a47434c..a086faf6f1 100644 --- a/packages/server/build.gradle.kts +++ b/packages/server/build.gradle.kts @@ -163,6 +163,7 @@ dependencies { testImplementation(mn.micronaut.test.core) testImplementation(kotlin("test")) testImplementation(kotlin("test-junit5")) + testApi(project(":packages:engine", configuration = "testInternals")) } tasks { diff --git a/runtime b/runtime index 8b040071c7..dd5a3ad5dd 160000 --- a/runtime +++ b/runtime @@ -1 +1 @@ -Subproject commit 8b040071c75eb2bf34ba0065f3ae1340ef81ec8a +Subproject commit dd5a3ad5ddd887cf24cd77ff86f8d58fa91c50c0