Added EvalSession way of controlling all coroutines started from a script

This commit is contained in:
Sergey Chernov 2026-04-02 02:05:04 +03:00
parent 446c8d9a6e
commit fc01016a74
13 changed files with 482 additions and 94 deletions

View File

@ -93,42 +93,49 @@ import net.sergeych.lyng.*
// we need a coroutine to start, as Lyng // we need a coroutine to start, as Lyng
// is a coroutine based language, async topdown // is a coroutine based language, async topdown
runBlocking { runBlocking {
assert(5 == eval(""" 3*3 - 4 """).toInt()) val session = EvalSession()
eval(""" println("Hello, Lyng!") """) assert(5 == session.eval(""" 3*3 - 4 """).toInt())
session.eval(""" println("Hello, Lyng!") """)
} }
``` ```
### Exchanging information ### Exchanging information
Script is executed over some `Scope`. Create instance, The preferred host runtime is `EvalSession`. It owns the script scope and any coroutines
add your specific vars and functions to it, and call: started with `launch { ... }`. Create a session, grab its scope when you need low-level
binding APIs, then execute scripts through the session:
```kotlin ```kotlin
import net.sergeych.lyng.* import net.sergeych.lyng.*
// simple function runBlocking {
val scope = Script.newScope().apply { val session = EvalSession()
addFn("sumOf") { val scope = session.getScope().apply {
var sum = 0.0 // simple function
for (a in args) sum += a.toDouble() addFn("sumOf") {
ObjReal(sum) var sum = 0.0
} for (a in args) sum += a.toDouble()
addConst("LIGHT_SPEED", ObjReal(299_792_458.0)) ObjReal(sum)
}
addConst("LIGHT_SPEED", ObjReal(299_792_458.0))
// callback back to kotlin to some suspend fn, for example:: // callback back to kotlin to some suspend fn, for example::
// suspend fun doSomeWork(text: String): Int // suspend fun doSomeWork(text: String): Int
addFn("doSomeWork") { addFn("doSomeWork") {
// this _is_ a suspend lambda, we can call suspend function, // this _is_ a suspend lambda, we can call suspend function,
// and it won't consume the thread. // and it won't consume the thread.
// note that in kotlin handler, `args` is a list of `Obj` arguments // note that in kotlin handler, `args` is a list of `Obj` arguments
// and return value from this lambda should be Obj too: // and return value from this lambda should be Obj too:
doSomeWork(args[0]).toObj() 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) ## IntelliJ IDEA plugin: Lightweight autocompletion (experimental)

View File

@ -80,11 +80,11 @@ If you already have an ionspin `BigDecimal` on the host side, the simplest suppo
```kotlin ```kotlin
import com.ionspin.kotlin.bignum.decimal.BigDecimal 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.asFacade
import net.sergeych.lyng.newDecimal import net.sergeych.lyng.newDecimal
val scope = Script.newScope() val scope = EvalSession().getScope()
val decimal = scope.asFacade().newDecimal(BigDecimal.parseStringWithMode("12.34")) val decimal = scope.asFacade().newDecimal(BigDecimal.parseStringWithMode("12.34"))
``` ```

View File

@ -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). 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 ```kotlin
fun main() = kotlinx.coroutines.runBlocking { fun main() = kotlinx.coroutines.runBlocking {
val scope = Script.newScope() // suspends on first init val scope = Script.newScope() // suspends on first init
// Evaluate a one‑liner
val result = scope.eval("1 + 2 * 3") 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 ```kotlin
val script = Compiler.compile(""" val script = Compiler.compile("""
@ -63,7 +102,8 @@ val run1 = script.execute(scope)
val run2 = 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 ### 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.ObjInt
import net.sergeych.lyng.obj.ObjString import net.sergeych.lyng.obj.ObjString
val session = EvalSession()
val scope = session.getScope()
val im = Script.defaultImportManager.copy() val im = Script.defaultImportManager.copy()
im.addPackage("my.api") { module -> im.addPackage("my.api") { module ->
module.eval(""" 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. Use this when you intentionally want raw `Scope` APIs. For most module APIs, prefer section 3.
```kotlin ```kotlin
val session = EvalSession()
val scope = session.getScope()
// A function returning value // A function returning value
scope.addFn<ObjInt>("inc") { scope.addFn<ObjInt>("inc") {
val x = args.firstAndOnly() as ObjInt val x = args.firstAndOnly() as ObjInt
@ -167,7 +212,7 @@ scope.addVoidFn("log") {
// } // }
// Call them from Lyng // 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<ObjInt>("inc", "increment") { ... }`. You can register multiple names (aliases) at once: `addFn<ObjInt>("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. 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 ```kotlin
val session = EvalSession()
val scope = session.getScope()
val myClass = ObjClass("MyClass") val myClass = ObjClass("MyClass")
// Add a read-only field (constant) // 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`. 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 ```kotlin
val session = EvalSession()
val scope = session.getScope()
val myClass = ObjClass("MyClass") val myClass = ObjClass("MyClass")
var internalValue: Long = 10 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. It provides explicit, cached handles and predictable lookup rules.
```kotlin ```kotlin
val scope = Script.newScope() val session = EvalSession()
scope.eval(""" val scope = session.getScope()
session.eval("""
val x = 40 val x = 40
fun add(a, b) = a + b fun add(a, b) = a + b
class Box { var value = 1 } 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))) val sum = (resolver as BridgeCallByName).callByName(scope, "add", Arguments(ObjInt(1), ObjInt(2)))
// Member access // Member access
val box = scope.eval("Box()") val box = session.eval("Box()")
val valueHandle = resolver.resolveMemberVar(box, "value") val valueHandle = resolver.resolveMemberVar(box, "value")
valueHandle.set(scope, ObjInt(10)) valueHandle.set(scope, ObjInt(10))
val value = valueHandle.get(scope) 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. The simplest approach: evaluate an expression that yields the value and convert it.
```kotlin ```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: // After scripts manipulate your vars:
scope.addOrUpdateItem("name", ObjString("Lyng")) scope.addOrUpdateItem("name", ObjString("Lyng"))
scope.eval("name = name + ' rocks!'") session.eval("name = name + ' rocks!'")
val kotlinName = scope.eval("name").toKotlin(scope) // -> "Lyng 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. 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 ```kotlin
// Suppose Lyng defines: fun add(a, b) = a + b // 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: 2) Call a Lyng function by name via a prepared call scope:
```kotlin ```kotlin
// Ensure the function exists in the scope // 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 // Look up the function object
val addFn = scope.get("add")!!.value as Statement 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.bridge.*
import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjInt
val scope = Script.newScope() val session = EvalSession()
val scope = session.getScope()
// Access the import manager behind this scope // Access the import manager behind this scope
val im: ImportManager = scope.importManager val im: ImportManager = scope.importManager
@ -563,12 +620,12 @@ im.addPackage("my.tools") { module: ModuleScope ->
} }
// Use it from Lyng // Use it from Lyng
scope.eval(""" session.eval("""
import my.tools.* import my.tools.*
val v = triple(14) val v = triple(14)
status = "busy" 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: Register a package from Lyng source text:
@ -582,24 +639,27 @@ val pkgText = """
scope.importManager.addTextPackages(pkgText) scope.importManager.addTextPackages(pkgText)
scope.eval(""" session.eval("""
import math.extra.* import math.extra.*
val s = sqr(12) 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)`. You can also register from parsed `Source` instances via `addSourcePackages(source)`.
### 10) Executing from files, security, and isolation ### 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. - `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 ```kotlin
// Fresh module based on the default manager, without the standard prelude // Preferred per-request runtime:
val isolated = net.sergeych.lyng.Scope.new() 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 ### 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. You can serialize Lyng exception objects using `Lynon` to transmit them across boundaries and then rethrow them.
```kotlin ```kotlin
val session = EvalSession()
val scope = session.getScope()
try { try {
scope.eval("throw MyUserException(404, \"Not Found\")") session.eval("throw MyUserException(404, \"Not Found\")")
} catch (e: ExecutionError) { } catch (e: ExecutionError) {
// 1. Serialize the Lyng exception object // 1. Serialize the Lyng exception object
val encoded: UByteArray = lynonEncodeAny(scope, e.errorObject) val encoded: UByteArray = lynonEncodeAny(scope, e.errorObject)

View File

@ -122,7 +122,8 @@ data class TestJson2(
@Test @Test
fun deserializeMapWithJsonTest() = runTest { fun deserializeMapWithJsonTest() = runTest {
val x = eval(""" val session = EvalSession()
val x = session.eval("""
import lyng.serialization import lyng.serialization
{ value: 1, inner: { "foo": 1, "bar": 2 }} { value: 1, inner: { "foo": 1, "bar": 2 }}
""".trimIndent()).decodeSerializable<TestJson2>() """.trimIndent()).decodeSerializable<TestJson2>()
@ -143,7 +144,8 @@ data class TestJson3(
) )
@Test @Test
fun deserializeAnyMapWithJsonTest() = runTest { fun deserializeAnyMapWithJsonTest() = runTest {
val x = eval(""" val session = EvalSession()
val x = session.eval("""
import lyng.serialization import lyng.serialization
{ value: 12, inner: { "foo": 1, "bar": "two" }} { value: 12, inner: { "foo": 1, "bar": "two" }}
""".trimIndent()).decodeSerializable<TestJson3>() """.trimIndent()).decodeSerializable<TestJson3>()
@ -175,4 +177,3 @@ on [Instant](time.md), see `Instant.truncateTo...` functions.
(3) (3)
: Map keys must be strings, map values may be any objects serializable to Json. : Map keys must be strings, map values may be any objects serializable to Json.

View File

@ -9,12 +9,13 @@
#### Install in host #### Install in host
```kotlin ```kotlin
import net.sergeych.lyng.Script import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.io.console.createConsoleModule import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
suspend fun initScope() { suspend fun initScope() {
val scope = Script.newScope() val session = EvalSession()
val scope = session.getScope()
createConsoleModule(PermitAllConsoleAccessPolicy, scope) createConsoleModule(PermitAllConsoleAccessPolicy, scope)
} }
``` ```

View File

@ -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 (host) bootstrap example:
```kotlin ```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
val scope: Scope = Scope.new() suspend fun bootstrapFs() {
val installed: Boolean = createFs(PermitAllAccessPolicy, scope) val session = EvalSession()
// installed == true on first registration in this ImportManager, false on repeats 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: // In scripts (or via session.eval), import the module to use its symbols:
scope.eval("import lyng.io.fs") session.eval("import lyng.io.fs")
}
``` ```
You can install with a custom policy too (see Access policy below). You can install with a custom policy too (see Access policy below).
@ -185,7 +189,7 @@ val denyWrites = object : FsAccessPolicy {
} }
createFs(denyWrites, scope) 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)`). 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)`).

View File

@ -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 (host) bootstrap example:
```kotlin ```kotlin
import net.sergeych.lyng.Scope 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.lyng.io.process.createProcessModule
import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy
// ... inside a suspend function or runBlocking suspend fun bootstrapProcess() {
val scope: Scope = Script.newScope() val session = EvalSession()
createProcessModule(PermitAllProcessAccessPolicy, scope) val scope: Scope = session.getScope()
createProcessModule(PermitAllProcessAccessPolicy, scope)
// In scripts (or via scope.eval), import the module: // In scripts (or via session.eval), import the module:
scope.eval("import lyng.io.process") session.eval("import lyng.io.process")
}
``` ```
--- ---

View File

@ -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. To use `lyngio` modules in your scripts, you must install them into your Lyng scope and provide a security policy.
```kotlin ```kotlin
import net.sergeych.lyng.Script import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.process.createProcessModule
import net.sergeych.lyng.io.console.createConsoleModule 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 import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
suspend fun runMyScript() { suspend fun runMyScript() {
val scope = Script.newScope() val session = EvalSession()
val scope = session.getScope()
// Install modules with policies // Install modules with policies
createFs(PermitAllAccessPolicy, scope) createFs(PermitAllAccessPolicy, scope)
@ -54,7 +55,7 @@ suspend fun runMyScript() {
createConsoleModule(PermitAllConsoleAccessPolicy, scope) createConsoleModule(PermitAllConsoleAccessPolicy, scope)
// Now scripts can import them // Now scripts can import them
scope.eval(""" session.eval("""
import lyng.io.fs import lyng.io.fs
import lyng.io.process import lyng.io.process
import lyng.io.console import lyng.io.console

View File

@ -114,10 +114,12 @@ When running end‑to‑end “book” workloads or heavier benches, you can ena
Flags are mutable at runtime, e.g.: Flags are mutable at runtime, e.g.:
```kotlin ```kotlin
PerfFlags.ARG_BUILDER = false runTest {
val r1 = (Scope().eval(script) as ObjInt).value PerfFlags.ARG_BUILDER = false
PerfFlags.ARG_BUILDER = true val r1 = (EvalSession(Scope()).eval(script) as ObjInt).value
val r2 = (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. Reset flags at the end of a test to avoid impacting other tests.
@ -619,4 +621,3 @@ Reproduce
Notes Notes
- Negative caches are installed only after a real miss throws (cache‑after‑miss), preserving error semantics and invalidation on `layoutVersion` changes. - 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. - IndexRef PIC augments the existing direct path and uses move‑to‑front promotion; it is keyed on `(classId, layoutVersion)` like other PICs.

View File

@ -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 <T> launchTrackedDeferred(block: suspend CoroutineScope.() -> T): Deferred<T> {
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<EvalSessionElement>
}
internal suspend fun <T> withEvalSession(session: EvalSession, block: suspend () -> T): T {
return kotlinx.coroutines.withContext(EvalSessionElement(session)) {
block()
}
}
class ScriptSessionCancelled(message: String) : CancellationException(message)

View File

@ -636,9 +636,17 @@ class Script(
addFn("launch") { addFn("launch") {
val callable = requireOnlyArg<Obj>() val callable = requireOnlyArg<Obj>()
val captured = this val captured = this
ObjDeferred(globalDefer { val session = EvalSession.currentOrNull()
captured.call(callable) val deferred = if (session != null) {
}) session.launchTrackedDeferred {
captured.call(callable)
}
} else {
globalDefer {
captured.call(callable)
}
}
ObjDeferred(deferred)
} }
addFn("yield") { addFn("yield") {
@ -649,7 +657,7 @@ class Script(
addFn("flow", callSignature = CallSignature(tailBlockReceiverType = "FlowBuilder")) { addFn("flow", callSignature = CallSignature(tailBlockReceiverType = "FlowBuilder")) {
// important is: current context contains closure often used in call; // important is: current context contains closure often used in call;
// we'll need it for the producer // we'll need it for the producer
ObjFlow(requireOnlyArg<Obj>(), requireScope()) ObjFlow(requireOnlyArg<Obj>(), requireScope(), EvalSession.currentOrNull())
} }
val pi = ObjReal(PI) val pi = ObjReal(PI)

View File

@ -17,6 +17,7 @@
package net.sergeych.lyng.obj package net.sergeych.lyng.obj
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.channels.ChannelResult
@ -24,6 +25,7 @@ import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
import net.sergeych.lyng.miniast.ParamDoc import net.sergeych.lyng.miniast.ParamDoc
@ -72,25 +74,34 @@ class ObjFlowBuilder(val output: SendChannel<Obj>) : Obj() {
} }
} }
private fun createLyngFlowInput(scope: Scope, producer: Obj): ReceiveChannel<Obj> { private suspend fun createLyngFlowInput(scope: Scope, producer: Obj, ownerSession: EvalSession?): ReceiveChannel<Obj> {
val channel = Channel<Obj>(Channel.RENDEZVOUS) val channel = Channel<Obj>(Channel.RENDEZVOUS)
val builder = ObjFlowBuilder(channel) val builder = ObjFlowBuilder(channel)
val builderScope = scope.createChildScope(newThisObj = builder) val builderScope = scope.createChildScope(newThisObj = builder)
globalLaunch { val runProducer: suspend CoroutineScope.() -> Unit = {
var failure: Throwable? = null
try { try {
producer.callOn(builderScope) producer.callOn(builderScope)
} catch (x: ScriptFlowIsNoMoreCollected) { } catch (x: ScriptFlowIsNoMoreCollected) {
// premature flow closing, OK // premature flow closing, OK
} catch (x: Throwable) { } catch (x: Throwable) {
channel.close(x) failure = x
return@globalLaunch
} }
channel.close() if (failure != null) {
channel.close(failure)
} else {
channel.close()
}
}
if (ownerSession != null) {
ownerSession.launchTrackedJob(runProducer)
} else {
globalLaunch(block = runProducer)
} }
return channel 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 override val objClass get() = type
@ -109,14 +120,14 @@ class ObjFlow(val producer: Obj, val scope: Scope) : Obj() {
val objFlow = thisAs<ObjFlow>() val objFlow = thisAs<ObjFlow>()
ObjFlowIterator(ObjExternCallable.fromBridge { ObjFlowIterator(ObjExternCallable.fromBridge {
call(objFlow.producer) 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 override val objClass: ObjClass get() = type
@ -134,7 +145,7 @@ class ObjFlowIterator(val producer: Obj) : Obj() {
suspend fun hasNext(scope: Scope): ObjBool { suspend fun hasNext(scope: Scope): ObjBool {
checkNotCancelled(scope) checkNotCancelled(scope)
// cold start: // cold start:
if (channel == null) channel = createLyngFlowInput(scope, producer) if (channel == null) channel = createLyngFlowInput(scope, producer, ownerSession)
if (nextItem == null) nextItem = channel!!.receiveCatching() if (nextItem == null) nextItem = channel!!.receiveCatching()
nextItem?.exceptionOrNull()?.let { throw it } nextItem?.exceptionOrNull()?.let { throw it }
return ObjBool(nextItem!!.isSuccess) return ObjBool(nextItem!!.isSuccess)

View File

@ -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 })
}
}