diff --git a/README.md b/README.md index 371d707..9b91f57 100644 --- a/README.md +++ b/README.md @@ -93,42 +93,49 @@ import net.sergeych.lyng.* // we need a coroutine to start, as Lyng // is a coroutine based language, async topdown runBlocking { - assert(5 == eval(""" 3*3 - 4 """).toInt()) - eval(""" println("Hello, Lyng!") """) + val session = EvalSession() + assert(5 == session.eval(""" 3*3 - 4 """).toInt()) + session.eval(""" println("Hello, Lyng!") """) } ``` ### Exchanging information -Script is executed over some `Scope`. Create instance, -add your specific vars and functions to it, and call: +The preferred host runtime is `EvalSession`. It owns the script scope and any coroutines +started with `launch { ... }`. Create a session, grab its scope when you need low-level +binding APIs, then execute scripts through the session: ```kotlin import net.sergeych.lyng.* -// simple function -val scope = Script.newScope().apply { - addFn("sumOf") { - var sum = 0.0 - for (a in args) sum += a.toDouble() - ObjReal(sum) - } - addConst("LIGHT_SPEED", ObjReal(299_792_458.0)) +runBlocking { + val session = EvalSession() + val scope = session.getScope().apply { + // simple function + addFn("sumOf") { + var sum = 0.0 + for (a in args) sum += a.toDouble() + ObjReal(sum) + } + addConst("LIGHT_SPEED", ObjReal(299_792_458.0)) - // callback back to kotlin to some suspend fn, for example:: - // suspend fun doSomeWork(text: String): Int - addFn("doSomeWork") { - // this _is_ a suspend lambda, we can call suspend function, - // and it won't consume the thread. - // note that in kotlin handler, `args` is a list of `Obj` arguments - // and return value from this lambda should be Obj too: - doSomeWork(args[0]).toObj() + // callback back to kotlin to some suspend fn, for example:: + // suspend fun doSomeWork(text: String): Int + addFn("doSomeWork") { + // this _is_ a suspend lambda, we can call suspend function, + // and it won't consume the thread. + // note that in kotlin handler, `args` is a list of `Obj` arguments + // and return value from this lambda should be Obj too: + doSomeWork(args[0]).toObj() + } } + + // execute through the session: + session.eval("sumOf(1,2,3)") // <- 6 } -// adding constant: -scope.eval("sumOf(1,2,3)") // <- 6 ``` -Note that the scope stores all changes in it so you can make calls on a single scope to preserve state between calls. +Note that the session reuses one scope, so state persists across `session.eval(...)` calls. +Use raw `Scope.eval(...)` only when you intentionally want low-level control without session-owned coroutine lifecycle. ## IntelliJ IDEA plugin: Lightweight autocompletion (experimental) diff --git a/docs/Decimal.md b/docs/Decimal.md index f34feef..4b38b80 100644 --- a/docs/Decimal.md +++ b/docs/Decimal.md @@ -80,11 +80,11 @@ If you already have an ionspin `BigDecimal` on the host side, the simplest suppo ```kotlin import com.ionspin.kotlin.bignum.decimal.BigDecimal -import net.sergeych.lyng.Script +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.asFacade import net.sergeych.lyng.newDecimal -val scope = Script.newScope() +val scope = EvalSession().getScope() val decimal = scope.asFacade().newDecimal(BigDecimal.parseStringWithMode("12.34")) ``` diff --git a/docs/embedding.md b/docs/embedding.md index f319d89..9d3c6fb 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -36,21 +36,60 @@ dependencies { If you use Kotlin Multiplatform, add the dependency in the `commonMain` source set (and platform‑specific sets if you need platform APIs). -### 2) Create a runtime (Scope) and execute scripts +### 2) Preferred runtime: `EvalSession` -The easiest way to get a ready‑to‑use scope with standard packages is via `Script.newScope()`. +For host applications, prefer `EvalSession` as the main way to run scripts. +It owns one reusable Lyng scope, serializes `eval(...)` calls, and governs coroutines started from Lyng `launch { ... }`. + +Main entrypoints: + +- `session.eval(code)` / `session.eval(source)` +- `session.getScope()` when you need low-level binding APIs +- `session.cancel()` to cancel active session-owned coroutines +- `session.join()` to wait for active session-owned coroutines + +```kotlin +fun main() = kotlinx.coroutines.runBlocking { + val session = EvalSession() + + // Evaluate a one‑liner + val result = session.eval("1 + 2 * 3") + println("Lyng result: $result") // ObjReal/ObjInt etc. + + // Optional lifecycle management + session.join() +} +``` + +The session creates its underlying scope lazily. If you need raw low-level APIs, get the scope explicitly: + +```kotlin +val session = EvalSession() +val scope = session.getScope() +``` + +Use `cancel()` / `join()` to govern async work started by scripts: + +```kotlin +val session = EvalSession() +session.eval("""launch { delay(1000); println("done") }""") +session.cancel() +session.join() +``` + +### 2.1) Low-level runtime: `Scope` + +Use `Scope` directly when you intentionally want lower-level control. ```kotlin fun main() = kotlinx.coroutines.runBlocking { val scope = Script.newScope() // suspends on first init - - // Evaluate a one‑liner val result = scope.eval("1 + 2 * 3") - println("Lyng result: $result") // ObjReal/ObjInt etc. + println("Lyng result: $result") } ``` -You can also pre‑compile a script and execute it multiple times: +You can also pre‑compile a script and execute it multiple times on the same scope: ```kotlin val script = Compiler.compile(""" @@ -63,7 +102,8 @@ val run1 = script.execute(scope) val run2 = script.execute(scope) ``` -`Scope.eval("...")` is a shortcut that compiles and executes on the given scope. +`Scope.eval("...")` is the low-level shortcut that compiles and executes on the given scope. +For most embedding use cases, prefer `session.eval("...")`. ### 3) Preferred: bind extern globals from Kotlin @@ -85,6 +125,8 @@ import net.sergeych.lyng.bridge.* import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjString +val session = EvalSession() +val scope = session.getScope() val im = Script.defaultImportManager.copy() im.addPackage("my.api") { module -> module.eval(""" @@ -149,6 +191,9 @@ binder.bindGlobalFunRaw("echoRaw") { _, args -> Use this when you intentionally want raw `Scope` APIs. For most module APIs, prefer section 3. ```kotlin +val session = EvalSession() +val scope = session.getScope() + // A function returning value scope.addFn("inc") { val x = args.firstAndOnly() as ObjInt @@ -167,7 +212,7 @@ scope.addVoidFn("log") { // } // Call them from Lyng -scope.eval("val y = inc(41); log('Answer:', y)") +session.eval("val y = inc(41); log('Answer:', y)") ``` You can register multiple names (aliases) at once: `addFn("inc", "increment") { ... }`. @@ -254,6 +299,8 @@ If you want multi-axis slicing semantics, decode that list yourself in `getAt`. If you need a simple field (with a value) instead of a computed property, use `createField`. This adds a field to the class that will be present in all its instances. ```kotlin +val session = EvalSession() +val scope = session.getScope() val myClass = ObjClass("MyClass") // Add a read-only field (constant) @@ -281,6 +328,8 @@ println(instance.count) // -> 5 Properties in Lyng are pure accessors (getters and setters) and do not have automatic backing fields. You can add them to a class using `addProperty`. ```kotlin +val session = EvalSession() +val scope = session.getScope() val myClass = ObjClass("MyClass") var internalValue: Long = 10 @@ -447,8 +496,9 @@ For Kotlin code that needs dynamic access to Lyng variables, functions, or membe It provides explicit, cached handles and predictable lookup rules. ```kotlin -val scope = Script.newScope() -scope.eval(""" +val session = EvalSession() +val scope = session.getScope() +session.eval(""" val x = 40 fun add(a, b) = a + b class Box { var value = 1 } @@ -463,7 +513,7 @@ val x = resolver.resolveVal("x").get(scope) val sum = (resolver as BridgeCallByName).callByName(scope, "add", Arguments(ObjInt(1), ObjInt(2))) // Member access -val box = scope.eval("Box()") +val box = session.eval("Box()") val valueHandle = resolver.resolveMemberVar(box, "value") valueHandle.set(scope, ObjInt(10)) val value = valueHandle.get(scope) @@ -474,12 +524,14 @@ val value = valueHandle.get(scope) The simplest approach: evaluate an expression that yields the value and convert it. ```kotlin -val kotlinAnswer = scope.eval("(1 + 2) * 3").toKotlin(scope) // -> 9 (Int) +val session = EvalSession() +val scope = session.getScope() +val kotlinAnswer = session.eval("(1 + 2) * 3").toKotlin(scope) // -> 9 (Int) // After scripts manipulate your vars: scope.addOrUpdateItem("name", ObjString("Lyng")) -scope.eval("name = name + ' rocks!'") -val kotlinName = scope.eval("name").toKotlin(scope) // -> "Lyng rocks!" +session.eval("name = name + ' rocks!'") +val kotlinName = session.eval("name").toKotlin(scope) // -> "Lyng rocks!" ``` Advanced: you can also grab a variable record directly via `scope.get(name)` and work with its `Obj` value, but evaluating `"name"` is often clearer and enforces Lyng semantics consistently. @@ -492,16 +544,20 @@ There are two convenient patterns. ```kotlin // Suppose Lyng defines: fun add(a, b) = a + b -scope.eval("fun add(a, b) = a + b") +val session = EvalSession() +val scope = session.getScope() +session.eval("fun add(a, b) = a + b") -val sum = scope.eval("add(20, 22)").toKotlin(scope) // -> 42 +val sum = session.eval("add(20, 22)").toKotlin(scope) // -> 42 ``` 2) Call a Lyng function by name via a prepared call scope: ```kotlin // Ensure the function exists in the scope -scope.eval("fun add(a, b) = a + b") +val session = EvalSession() +val scope = session.getScope() +session.eval("fun add(a, b) = a + b") // Look up the function object val addFn = scope.get("add")!!.value as Statement @@ -532,7 +588,8 @@ Register a Kotlin‑built package: import net.sergeych.lyng.bridge.* import net.sergeych.lyng.obj.ObjInt -val scope = Script.newScope() +val session = EvalSession() +val scope = session.getScope() // Access the import manager behind this scope val im: ImportManager = scope.importManager @@ -563,12 +620,12 @@ im.addPackage("my.tools") { module: ModuleScope -> } // Use it from Lyng -scope.eval(""" +session.eval(""" import my.tools.* val v = triple(14) status = "busy" """) -val v = scope.eval("v").toKotlin(scope) // -> 42 +val v = session.eval("v").toKotlin(scope) // -> 42 ``` Register a package from Lyng source text: @@ -582,24 +639,27 @@ val pkgText = """ scope.importManager.addTextPackages(pkgText) -scope.eval(""" +session.eval(""" import math.extra.* val s = sqr(12) """) -val s = scope.eval("s").toKotlin(scope) // -> 144 +val s = session.eval("s").toKotlin(scope) // -> 144 ``` You can also register from parsed `Source` instances via `addSourcePackages(source)`. ### 10) Executing from files, security, and isolation -- To run code from a file, read it and pass to `scope.eval(text)` or compile with `Compiler.compile(Source(fileName, text))`. +- To run code from a file, read it and pass to `session.eval(text)` or compile with `Compiler.compile(Source(fileName, text))`. - `ImportManager` takes an optional `SecurityManager` if you need to restrict what packages or operations are available. By default, `Script.defaultImportManager` allows everything suitable for embedded use; clamp it down in sandboxed environments. -- For isolation, create fresh modules/scopes via `Scope.new()` or `Script.newScope()` when you need a clean environment per request. +- For isolation, prefer a fresh `EvalSession()` per request. Use `Scope.new()` / `Script.newScope()` when you specifically need low-level raw scopes or modules. ```kotlin -// Fresh module based on the default manager, without the standard prelude -val isolated = net.sergeych.lyng.Scope.new() +// Preferred per-request runtime: +val isolatedSession = EvalSession() + +// Low-level fresh module based on the default manager, without the standard prelude: +val isolatedScope = net.sergeych.lyng.Scope.new() ``` ### 11) Tips and troubleshooting @@ -634,8 +694,11 @@ To simplify handling these objects from Kotlin, several extension methods are pr You can serialize Lyng exception objects using `Lynon` to transmit them across boundaries and then rethrow them. ```kotlin +val session = EvalSession() +val scope = session.getScope() + try { - scope.eval("throw MyUserException(404, \"Not Found\")") + session.eval("throw MyUserException(404, \"Not Found\")") } catch (e: ExecutionError) { // 1. Serialize the Lyng exception object val encoded: UByteArray = lynonEncodeAny(scope, e.errorObject) diff --git a/docs/json_and_kotlin_serialization.md b/docs/json_and_kotlin_serialization.md index a34b3eb..99df608 100644 --- a/docs/json_and_kotlin_serialization.md +++ b/docs/json_and_kotlin_serialization.md @@ -122,7 +122,8 @@ data class TestJson2( @Test fun deserializeMapWithJsonTest() = runTest { - val x = eval(""" + val session = EvalSession() + val x = session.eval(""" import lyng.serialization { value: 1, inner: { "foo": 1, "bar": 2 }} """.trimIndent()).decodeSerializable() @@ -143,7 +144,8 @@ data class TestJson3( ) @Test fun deserializeAnyMapWithJsonTest() = runTest { - val x = eval(""" + val session = EvalSession() + val x = session.eval(""" import lyng.serialization { value: 12, inner: { "foo": 1, "bar": "two" }} """.trimIndent()).decodeSerializable() @@ -175,4 +177,3 @@ on [Instant](time.md), see `Instant.truncateTo...` functions. (3) : Map keys must be strings, map values may be any objects serializable to Json. - diff --git a/docs/lyng.io.console.md b/docs/lyng.io.console.md index 86ed045..637bc83 100644 --- a/docs/lyng.io.console.md +++ b/docs/lyng.io.console.md @@ -9,12 +9,13 @@ #### Install in host ```kotlin -import net.sergeych.lyng.Script +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.io.console.createConsoleModule import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy suspend fun initScope() { - val scope = Script.newScope() + val session = EvalSession() + val scope = session.getScope() createConsoleModule(PermitAllConsoleAccessPolicy, scope) } ``` diff --git a/docs/lyng.io.fs.md b/docs/lyng.io.fs.md index aa92891..2a930b1 100644 --- a/docs/lyng.io.fs.md +++ b/docs/lyng.io.fs.md @@ -39,23 +39,27 @@ This brings in: --- -#### Install the module into a Lyng Scope +#### Install the module into a Lyng session -The filesystem module is not installed automatically. You must explicitly register it in the scope’s `ImportManager` using the installer. You can customize access control via `FsAccessPolicy`. +The filesystem module is not installed automatically. The preferred host runtime is `EvalSession`: create the session, get its underlying scope, install the module there, and execute scripts through the session. You can customize access control via `FsAccessPolicy`. Kotlin (host) bootstrap example: ```kotlin +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.Scope import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy -val scope: Scope = Scope.new() -val installed: Boolean = createFs(PermitAllAccessPolicy, scope) -// installed == true on first registration in this ImportManager, false on repeats +suspend fun bootstrapFs() { + val session = EvalSession() + val scope: Scope = session.getScope() + val installed: Boolean = createFs(PermitAllAccessPolicy, scope) + // installed == true on first registration in this ImportManager, false on repeats -// In scripts (or via scope.eval), import the module to use its symbols: -scope.eval("import lyng.io.fs") + // In scripts (or via session.eval), import the module to use its symbols: + session.eval("import lyng.io.fs") +} ``` You can install with a custom policy too (see Access policy below). @@ -185,7 +189,7 @@ val denyWrites = object : FsAccessPolicy { } createFs(denyWrites, scope) -scope.eval("import lyng.io.fs") +session.eval("import lyng.io.fs") ``` Composite operations like `copy` and `move` are checked as a set of primitives (e.g., `OpenRead(src)` + `Delete(dst)` if overwriting + `CreateFile(dst)` + `OpenWrite(dst)`). diff --git a/docs/lyng.io.process.md b/docs/lyng.io.process.md index 24e395f..dd38f5c 100644 --- a/docs/lyng.io.process.md +++ b/docs/lyng.io.process.md @@ -20,24 +20,26 @@ For external projects, ensure you have the appropriate Maven repository configur --- -#### Install the module into a Lyng Scope +#### Install the module into a Lyng session -The process module is not installed automatically. You must explicitly register it in the scope’s `ImportManager` using `createProcessModule`. You can customize access control via `ProcessAccessPolicy`. +The process module is not installed automatically. The preferred host runtime is `EvalSession`: create the session, get its underlying scope, install the module there, and execute scripts through the session. You can customize access control via `ProcessAccessPolicy`. Kotlin (host) bootstrap example: ```kotlin import net.sergeych.lyng.Scope -import net.sergeych.lyng.Script +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy -// ... inside a suspend function or runBlocking -val scope: Scope = Script.newScope() -createProcessModule(PermitAllProcessAccessPolicy, scope) +suspend fun bootstrapProcess() { + val session = EvalSession() + val scope: Scope = session.getScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) -// In scripts (or via scope.eval), import the module: -scope.eval("import lyng.io.process") + // In scripts (or via session.eval), import the module: + session.eval("import lyng.io.process") +} ``` --- diff --git a/docs/lyngio.md b/docs/lyngio.md index 08e8fa1..d1f5917 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -37,7 +37,7 @@ dependencies { To use `lyngio` modules in your scripts, you must install them into your Lyng scope and provide a security policy. ```kotlin -import net.sergeych.lyng.Script +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.console.createConsoleModule @@ -46,7 +46,8 @@ import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy suspend fun runMyScript() { - val scope = Script.newScope() + val session = EvalSession() + val scope = session.getScope() // Install modules with policies createFs(PermitAllAccessPolicy, scope) @@ -54,7 +55,7 @@ suspend fun runMyScript() { createConsoleModule(PermitAllConsoleAccessPolicy, scope) // Now scripts can import them - scope.eval(""" + session.eval(""" import lyng.io.fs import lyng.io.process import lyng.io.console diff --git a/docs/perf_guide.md b/docs/perf_guide.md index ff98fee..3133f60 100644 --- a/docs/perf_guide.md +++ b/docs/perf_guide.md @@ -114,10 +114,12 @@ When running end‑to‑end “book” workloads or heavier benches, you can ena Flags are mutable at runtime, e.g.: ```kotlin -PerfFlags.ARG_BUILDER = false -val r1 = (Scope().eval(script) as ObjInt).value -PerfFlags.ARG_BUILDER = true -val r2 = (Scope().eval(script) as ObjInt).value +runTest { + PerfFlags.ARG_BUILDER = false + val r1 = (EvalSession(Scope()).eval(script) as ObjInt).value + PerfFlags.ARG_BUILDER = true + val r2 = (EvalSession(Scope()).eval(script) as ObjInt).value +} ``` Reset flags at the end of a test to avoid impacting other tests. @@ -619,4 +621,3 @@ Reproduce Notes - Negative caches are installed only after a real miss throws (cache‑after‑miss), preserving error semantics and invalidation on `layoutVersion` changes. - IndexRef PIC augments the existing direct path and uses move‑to‑front promotion; it is keyed on `(classId, layoutVersion)` like other PICs. - diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/EvalSession.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/EvalSession.kt new file mode 100644 index 0000000..a5b7cda --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/EvalSession.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 net.sergeych.lyng + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.sergeych.lyng.obj.Obj +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Host-managed lifetime owner for coroutines started from Lyng scripts. + * + * The session reuses a single [Scope] across eval calls and tracks async work launched by + * `launch { ... }` and active flow producers created from these evals. + */ +class EvalSession(initialScope: Scope? = null) { + private val ownerJob = SupervisorJob() + private val evalMutex = Mutex() + private val scopeMutex = Mutex() + private val activeJobs = MutableStateFlow(0) + + private var _scope: Scope? = initialScope + val scope: Scope? get() = _scope + + val isActive: Boolean get() = ownerJob.isActive + val isCancelled: Boolean get() = ownerJob.isCancelled + + suspend fun getScope(): Scope = ensureScope() + + suspend fun eval(code: String): Obj = eval(code.toSource()) + + suspend fun eval(source: Source): Obj = evalMutex.withLock { + throwIfCancelled() + val scope = ensureScope() + withEvalSession(this@EvalSession) { + scope.eval(source) + } + } + + fun cancel(cause: String? = null) { + ownerJob.cancel(ScriptSessionCancelled(cause ?: "EvalSession cancelled")) + } + + suspend fun join() { + activeJobs.filter { it == 0 }.first() + } + + suspend fun cancelAndJoin() { + cancel() + join() + } + + internal suspend fun launchTrackedDeferred(block: suspend CoroutineScope.() -> T): Deferred { + throwIfCancelled() + val deferred = CoroutineScope(currentTrackedCoroutineContext()).async(block = block) + track(deferred) + return deferred + } + + internal suspend fun launchTrackedJob(block: suspend CoroutineScope.() -> Unit): Job { + throwIfCancelled() + val job = CoroutineScope(currentTrackedCoroutineContext()).launch(block = block) + track(job) + return job + } + + private suspend fun currentTrackedCoroutineContext(): CoroutineContext { + val base = currentCoroutineContext() + return base.minusKey(Job) + ownerJob + EvalSessionElement(this) + } + + private suspend fun ensureScope(): Scope { + _scope?.let { return it } + return scopeMutex.withLock { + _scope ?: Script.newScope().also { _scope = it } + } + } + + private fun track(job: Job) { + activeJobs.update { it + 1 } + job.invokeOnCompletion { + activeJobs.update { count -> if (count > 0) count - 1 else 0 } + } + } + + internal fun throwIfCancelled() { + if (ownerJob.isCancelled) { + throw ScriptSessionCancelled("EvalSession cancelled") + } + } + + companion object { + suspend fun currentOrNull(): EvalSession? = currentCoroutineContext()[EvalSessionElement]?.session + } +} + +internal class EvalSessionElement(val session: EvalSession) : + AbstractCoroutineContextElement(EvalSessionElement) { + companion object Key : CoroutineContext.Key +} + +internal suspend fun withEvalSession(session: EvalSession, block: suspend () -> T): T { + return kotlinx.coroutines.withContext(EvalSessionElement(session)) { + block() + } +} + +class ScriptSessionCancelled(message: String) : CancellationException(message) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index a666b2f..54d8d25 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -636,9 +636,17 @@ class Script( addFn("launch") { val callable = requireOnlyArg() val captured = this - ObjDeferred(globalDefer { - captured.call(callable) - }) + val session = EvalSession.currentOrNull() + val deferred = if (session != null) { + session.launchTrackedDeferred { + captured.call(callable) + } + } else { + globalDefer { + captured.call(callable) + } + } + ObjDeferred(deferred) } addFn("yield") { @@ -649,7 +657,7 @@ class Script( addFn("flow", callSignature = CallSignature(tailBlockReceiverType = "FlowBuilder")) { // important is: current context contains closure often used in call; // we'll need it for the producer - ObjFlow(requireOnlyArg(), requireScope()) + ObjFlow(requireOnlyArg(), requireScope(), EvalSession.currentOrNull()) } val pi = ObjReal(PI) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt index fc8ebd5..cd71211 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt @@ -17,6 +17,7 @@ package net.sergeych.lyng.obj +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ChannelResult @@ -24,6 +25,7 @@ import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import net.sergeych.lyng.EvalSession import net.sergeych.lyng.Scope import net.sergeych.lyng.ScriptFlowIsNoMoreCollected import net.sergeych.lyng.miniast.ParamDoc @@ -72,25 +74,34 @@ class ObjFlowBuilder(val output: SendChannel) : Obj() { } } -private fun createLyngFlowInput(scope: Scope, producer: Obj): ReceiveChannel { +private suspend fun createLyngFlowInput(scope: Scope, producer: Obj, ownerSession: EvalSession?): ReceiveChannel { val channel = Channel(Channel.RENDEZVOUS) val builder = ObjFlowBuilder(channel) val builderScope = scope.createChildScope(newThisObj = builder) - globalLaunch { + val runProducer: suspend CoroutineScope.() -> Unit = { + var failure: Throwable? = null try { producer.callOn(builderScope) } catch (x: ScriptFlowIsNoMoreCollected) { // premature flow closing, OK } catch (x: Throwable) { - channel.close(x) - return@globalLaunch + failure = x } - channel.close() + if (failure != null) { + channel.close(failure) + } else { + channel.close() + } + } + if (ownerSession != null) { + ownerSession.launchTrackedJob(runProducer) + } else { + globalLaunch(block = runProducer) } return channel } -class ObjFlow(val producer: Obj, val scope: Scope) : Obj() { +class ObjFlow(val producer: Obj, val scope: Scope, val ownerSession: EvalSession? = null) : Obj() { override val objClass get() = type @@ -109,14 +120,14 @@ class ObjFlow(val producer: Obj, val scope: Scope) : Obj() { val objFlow = thisAs() ObjFlowIterator(ObjExternCallable.fromBridge { call(objFlow.producer) - }) + }, objFlow.ownerSession) } } } } -class ObjFlowIterator(val producer: Obj) : Obj() { +class ObjFlowIterator(val producer: Obj, val ownerSession: EvalSession? = null) : Obj() { override val objClass: ObjClass get() = type @@ -134,7 +145,7 @@ class ObjFlowIterator(val producer: Obj) : Obj() { suspend fun hasNext(scope: Scope): ObjBool { checkNotCancelled(scope) // cold start: - if (channel == null) channel = createLyngFlowInput(scope, producer) + if (channel == null) channel = createLyngFlowInput(scope, producer, ownerSession) if (nextItem == null) nextItem = channel!!.receiveCatching() nextItem?.exceptionOrNull()?.let { throw it } return ObjBool(nextItem!!.isSuccess) diff --git a/lynglib/src/commonTest/kotlin/EvalSessionTest.kt b/lynglib/src/commonTest/kotlin/EvalSessionTest.kt new file mode 100644 index 0000000..0139265 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/EvalSessionTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + * + */ + +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.EvalSession +import net.sergeych.lyng.Scope +import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjFlow +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjList +import kotlin.test.* + +class EvalSessionTest { + @Test + fun sessionCreatesAndReusesScope() = runTest { + val session = EvalSession() + + assertEquals(null, session.scope) + assertEquals(10L, (session.eval("var x = 10; x") as ObjInt).value) + assertNotNull(session.scope) + assertEquals(session.scope, session.getScope()) + assertEquals(15L, (session.eval("x += 5; x") as ObjInt).value) + } + + @Test + fun sessionCancelStopsLaunchedCoroutines() = runTest { + val scope = Scope() + val session = EvalSession(scope) + + session.eval( + """ + var touched = false + launch { + delay(100) + touched = true + } + """ + .trimIndent() + ) + + session.cancel() + session.join() + advanceTimeBy(150) + + assertFalse((scope.eval("touched") as ObjBool).value) + } + + @Test + fun joinObservesWorkStartedByLaterEval() = runTest { + val session = EvalSession(Scope()) + + session.eval("launch { delay(100) }") + + var joined = false + val waiter = launch { + session.join() + joined = true + } + + advanceTimeBy(50) + assertFalse(joined) + + session.eval("launch { delay(100) }") + + advanceTimeBy(60) + assertFalse(joined) + + advanceTimeBy(50) + waiter.join() + assertTrue(joined) + } + + @Test + fun concurrentEvalCallsAreSerialized() = runTest { + val session = EvalSession(Scope()) + + session.eval("var counter = 0") + + val first = async { + session.eval( + """ + delay(100) + counter += 1 + counter + """ + .trimIndent() + ) as ObjInt + } + val second = async { + session.eval( + """ + counter += 10 + counter + """ + .trimIndent() + ) as ObjInt + } + + advanceTimeBy(150) + + assertEquals(1L, first.await().value) + assertEquals(11L, second.await().value) + assertEquals(11L, (session.eval("counter") as ObjInt).value) + } + + @Test + fun joinWaitsForActiveFlowProducer() = runTest { + val scope = Scope() + val session = EvalSession(scope) + val flow = session.eval( + """ + flow { + delay(100) + emit(1) + delay(100) + emit(2) + } + """ + .trimIndent() + ) as ObjFlow + + var joined = false + var collected: ObjList? = null + + val collector = launch { + collected = flow.callMethod(scope, "toList") + } + val waiter = launch { + session.join() + joined = true + } + + advanceTimeBy(150) + assertFalse(joined) + + advanceTimeBy(100) + collector.join() + waiter.join() + + assertTrue(joined) + assertEquals(listOf(1L, 2L), collected!!.list.map { (it as ObjInt).value }) + } +}