Compare commits
No commits in common. "bce88ced431a11871edabb9e53c0ed2cf6ccd1a3" and "b953282251404a5286a8ad186da3bd0a23a03ba6" have entirely different histories.
bce88ced43
...
b953282251
@ -1,34 +0,0 @@
|
|||||||
## Fix: prevent cycles in scope parent chain during pooled frame reuse
|
|
||||||
|
|
||||||
### What changed
|
|
||||||
- Scope.resetForReuse now fully detaches a reused scope from its previous chain/state before re-parenting:
|
|
||||||
- sets `parent = null` and regenerates `frameId`
|
|
||||||
- clears locals/slots/bindings caches
|
|
||||||
- only after that, validates the new parent with `ensureNoCycle` and assigns it
|
|
||||||
- ScopePool.borrow on all targets (JVM, Android, JS, Native, Wasm) now has a defensive fallback:
|
|
||||||
- if `resetForReuse` throws `IllegalStateException` indicating a parent-chain cycle, the pool allocates a fresh `Scope` instead of failing.
|
|
||||||
|
|
||||||
### Why
|
|
||||||
In some nested call patterns (notably instance method calls where an instance is produced by another function and immediately used), the same pooled `Scope` object can be rebound into a chain that already (transitively) contains it. Reassigning `parent` in that case forms a structural cycle, which `ensureNoCycle` correctly detects and throws. This could surface as:
|
|
||||||
|
|
||||||
```
|
|
||||||
IllegalStateException: cycle detected in scope parent chain assignment
|
|
||||||
at net.sergeych.lyng.Scope.ensureNoCycle(...)
|
|
||||||
at net.sergeych.lyng.Scope.resetForReuse(...)
|
|
||||||
at net.sergeych.lyng.ScopePool.borrow(...)
|
|
||||||
... during instance method invocation
|
|
||||||
```
|
|
||||||
|
|
||||||
The fix removes the failure mode by:
|
|
||||||
1) Detaching the reused frame from its prior chain/state before validating and assigning the new parent.
|
|
||||||
2) Falling back to a new frame allocation if a cycle is still detected (extremely rare and cheap vs. a crash).
|
|
||||||
|
|
||||||
### Expected effects
|
|
||||||
- Eliminates sporadic `cycle detected in scope parent chain` crashes during instance method invocation.
|
|
||||||
- No change to public API or normal semantics.
|
|
||||||
- Pooling remains enabled by default; the fallback only triggers on the detected cycle edge case.
|
|
||||||
- Negligible performance impact: fresh allocation is used only when a cycle would have crashed the VM previously.
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- The fix is platform-wide (all ScopePool actuals are covered).
|
|
||||||
- We recommend adding/keeping a regression test that exercises: a class with a method, a function returning an instance, and an exported function calling the instance method. The test should pass without exceptions.
|
|
||||||
@ -39,18 +39,12 @@ actual object ScopePool {
|
|||||||
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
||||||
val pool = pool()
|
val pool = pool()
|
||||||
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
||||||
return try {
|
|
||||||
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
||||||
s.resetForReuse(parent, args, pos, thisObj)
|
s.resetForReuse(parent, args, pos, thisObj)
|
||||||
} else {
|
} else {
|
||||||
s.frameId = nextFrameId()
|
s.frameId = nextFrameId()
|
||||||
}
|
}
|
||||||
s
|
return s
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
if (e.message?.contains("cycle") == true && e.message?.contains("scope parent chain") == true) {
|
|
||||||
Scope(parent, args, pos, thisObj)
|
|
||||||
} else throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun release(scope: Scope) {
|
actual fun release(scope: Scope) {
|
||||||
|
|||||||
@ -302,9 +302,11 @@ open class Scope(
|
|||||||
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
|
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
|
||||||
*/
|
*/
|
||||||
fun resetForReuse(parent: Scope?, args: Arguments, pos: Pos, thisObj: Obj) {
|
fun resetForReuse(parent: Scope?, args: Arguments, pos: Pos, thisObj: Obj) {
|
||||||
// Fully detach from any previous chain/state first to avoid residual ancestry
|
ensureNoCycle(parent)
|
||||||
// that could interact badly with the new parent and produce a cycle.
|
this.parent = parent
|
||||||
this.parent = null
|
this.args = args
|
||||||
|
this.pos = pos
|
||||||
|
this.thisObj = thisObj
|
||||||
this.skipScopeCreation = false
|
this.skipScopeCreation = false
|
||||||
// fresh identity for PIC caches
|
// fresh identity for PIC caches
|
||||||
this.frameId = nextFrameId()
|
this.frameId = nextFrameId()
|
||||||
@ -313,12 +315,6 @@ open class Scope(
|
|||||||
slots.clear()
|
slots.clear()
|
||||||
nameToSlot.clear()
|
nameToSlot.clear()
|
||||||
localBindings.clear()
|
localBindings.clear()
|
||||||
// Now safe to validate and re-parent
|
|
||||||
ensureNoCycle(parent)
|
|
||||||
this.parent = parent
|
|
||||||
this.args = args
|
|
||||||
this.pos = pos
|
|
||||||
this.thisObj = thisObj
|
|
||||||
// Pre-size local slots for upcoming parameter assignment where possible
|
// Pre-size local slots for upcoming parameter assignment where possible
|
||||||
reserveLocalCapacity(args.list.size + 4)
|
reserveLocalCapacity(args.list.size + 4)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
/*
|
|
||||||
* Regression test for scope parent-chain cycle when reusing pooled frames
|
|
||||||
*/
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import net.sergeych.lyng.eval
|
|
||||||
import kotlin.test.Test
|
|
||||||
|
|
||||||
class ScopeCycleRegressionTest {
|
|
||||||
@Test
|
|
||||||
fun instanceMethodCallDoesNotCycle() = runTest {
|
|
||||||
eval(
|
|
||||||
(
|
|
||||||
"""
|
|
||||||
class Whatever {
|
|
||||||
fun something() {
|
|
||||||
println
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ll() { Whatever() }
|
|
||||||
|
|
||||||
fun callTest1() {
|
|
||||||
val l = ll()
|
|
||||||
l.something()
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("ok", callTest1())
|
|
||||||
"""
|
|
||||||
).trimIndent()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4159,86 +4159,4 @@ class ScriptTest {
|
|||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testUsingClassConstructorVars() = runTest {
|
|
||||||
val r = eval("""
|
|
||||||
import lyng.time
|
|
||||||
|
|
||||||
class Request {
|
|
||||||
static val id = "rqid"
|
|
||||||
}
|
|
||||||
enum Action {
|
|
||||||
Test
|
|
||||||
}
|
|
||||||
class LogEntry(vaultId, action, data=null, createdAt=Instant.now().truncateToSecond()) {
|
|
||||||
|
|
||||||
/*
|
|
||||||
Convert to a map object that can be easily decoded outsude the
|
|
||||||
contract execution scope.
|
|
||||||
*/
|
|
||||||
fun toApi() {
|
|
||||||
{ createdAt:, requestId: Request.id, vaultId:, action: action.name, data: Map() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun test() {
|
|
||||||
LogEntry("v1", Action.Test).toApi()
|
|
||||||
}
|
|
||||||
|
|
||||||
test()
|
|
||||||
""".trimIndent()).toJson()
|
|
||||||
println(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testScopeShortCircuit() = runTest() {
|
|
||||||
val baseScope = Script.newScope()
|
|
||||||
|
|
||||||
baseScope.eval("""
|
|
||||||
val exports = Map()
|
|
||||||
fun Export(name,f) {
|
|
||||||
exports[name] = f
|
|
||||||
f
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
|
|
||||||
val exports: MutableMap<Obj, Obj> = (baseScope.eval("exports") as ObjMap).map
|
|
||||||
|
|
||||||
baseScope.eval("""
|
|
||||||
class A(val a) {
|
|
||||||
fun methodA() {
|
|
||||||
a + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val a0 = 100
|
|
||||||
|
|
||||||
fun someFunction(x) {
|
|
||||||
val ia = A(x)
|
|
||||||
ia.methodA()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Export
|
|
||||||
fun exportedFunction(x) {
|
|
||||||
someFunction(x)
|
|
||||||
}
|
|
||||||
""".trimIndent())
|
|
||||||
// Calling from the script is ok:
|
|
||||||
val instanceScope = baseScope.createChildScope()
|
|
||||||
instanceScope.eval("""
|
|
||||||
val a1 = a0 + 1
|
|
||||||
""".trimIndent())
|
|
||||||
assertEquals( ObjInt(2), instanceScope.eval("""
|
|
||||||
exportedFunction(1)
|
|
||||||
"""))
|
|
||||||
assertEquals( ObjInt(103), instanceScope.eval("""
|
|
||||||
exportedFunction(a1 + 1)
|
|
||||||
"""))
|
|
||||||
val dummyThis = Obj()
|
|
||||||
// but we should be able to call it directly
|
|
||||||
val otherScope = baseScope.createChildScope()
|
|
||||||
val r = (exports["exportedFunction".toObj()] as Statement).invoke(otherScope, dummyThis,ObjInt(50))
|
|
||||||
println(r)
|
|
||||||
assertEquals(51, r.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,18 +29,12 @@ actual object ScopePool {
|
|||||||
|
|
||||||
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
||||||
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
||||||
return try {
|
|
||||||
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
||||||
s.resetForReuse(parent, args, pos, thisObj)
|
s.resetForReuse(parent, args, pos, thisObj)
|
||||||
} else {
|
} else {
|
||||||
s.frameId = nextFrameId()
|
s.frameId = nextFrameId()
|
||||||
}
|
}
|
||||||
s
|
return s
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
if (e.message?.contains("cycle") == true && e.message?.contains("scope parent chain") == true) {
|
|
||||||
Scope(parent, args, pos, thisObj)
|
|
||||||
} else throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun release(scope: Scope) {
|
actual fun release(scope: Scope) {
|
||||||
|
|||||||
@ -33,19 +33,9 @@ actual object ScopePool {
|
|||||||
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
||||||
val pool = threadLocalPool.get()
|
val pool = threadLocalPool.get()
|
||||||
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
||||||
return try {
|
|
||||||
// Always reset state on borrow to guarantee fresh-frame semantics
|
// Always reset state on borrow to guarantee fresh-frame semantics
|
||||||
s.resetForReuse(parent, args, pos, thisObj)
|
s.resetForReuse(parent, args, pos, thisObj)
|
||||||
s
|
return s
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
// Defensive fallback: if a cycle in scope parent chain is detected during reuse,
|
|
||||||
// discard pooled instance for this call frame and allocate a fresh scope instead.
|
|
||||||
if (e.message?.contains("cycle") == true && e.message?.contains("scope parent chain") == true) {
|
|
||||||
Scope(parent, args, pos, thisObj)
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun release(scope: Scope) {
|
actual fun release(scope: Scope) {
|
||||||
|
|||||||
@ -29,18 +29,12 @@ actual object ScopePool {
|
|||||||
|
|
||||||
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
||||||
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
||||||
return try {
|
|
||||||
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
||||||
s.resetForReuse(parent, args, pos, thisObj)
|
s.resetForReuse(parent, args, pos, thisObj)
|
||||||
} else {
|
} else {
|
||||||
s.frameId = nextFrameId()
|
s.frameId = nextFrameId()
|
||||||
}
|
}
|
||||||
s
|
return s
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
if (e.message?.contains("cycle") == true && e.message?.contains("scope parent chain") == true) {
|
|
||||||
Scope(parent, args, pos, thisObj)
|
|
||||||
} else throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun release(scope: Scope) {
|
actual fun release(scope: Scope) {
|
||||||
|
|||||||
@ -29,18 +29,12 @@ actual object ScopePool {
|
|||||||
|
|
||||||
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
actual fun borrow(parent: Scope, args: Arguments, pos: Pos, thisObj: Obj): Scope {
|
||||||
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
val s = if (pool.isNotEmpty()) pool.removeLast() else Scope(parent, args, pos, thisObj)
|
||||||
return try {
|
|
||||||
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
if (s.parent !== parent || s.args !== args || s.pos !== pos || s.thisObj !== thisObj) {
|
||||||
s.resetForReuse(parent, args, pos, thisObj)
|
s.resetForReuse(parent, args, pos, thisObj)
|
||||||
} else {
|
} else {
|
||||||
s.frameId = nextFrameId()
|
s.frameId = nextFrameId()
|
||||||
}
|
}
|
||||||
s
|
return s
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
if (e.message?.contains("cycle") == true && e.message?.contains("scope parent chain") == true) {
|
|
||||||
Scope(parent, args, pos, thisObj)
|
|
||||||
} else throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun release(scope: Scope) {
|
actual fun release(scope: Scope) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user