wasm generation bug workaround, docs and debugging tips
This commit is contained in:
parent
7b1ba71ef0
commit
717a79aca2
15
docs/ai_notes_wasm_generation_bug.md
Normal file
15
docs/ai_notes_wasm_generation_bug.md
Normal file
@ -0,0 +1,15 @@
|
||||
# AI notes: avoid Kotlin/Wasm invalid IR with suspend lambdas
|
||||
|
||||
## Do
|
||||
- Prefer explicit `object : Statement()` with `override suspend fun execute(...)` when building compiler statements.
|
||||
- Keep `Statement` objects non-lambda, especially in compiler hot paths like parsing/var declarations.
|
||||
- If you need conditional behavior, return early in `execute` instead of wrapping `parseExpression()` with `statement(...) { ... }`.
|
||||
- When wasmJs tests hang in the browser, first check `wasmJsNodeTest` for a compile error; hangs often mean module instantiation failed.
|
||||
|
||||
## Don't
|
||||
- Do not create suspend lambdas inside `Statement` factories (`statement { ... }`) for wasm targets.
|
||||
- Do not "fix" hangs by increasing browser timeouts; it masks invalid wasm generation.
|
||||
|
||||
## Debugging tips
|
||||
- Look for `$invokeCOROUTINE$` in wasm function names when mapping failures.
|
||||
- If node test logs a wasm compile error, the browser hang is likely the same root cause.
|
||||
27
docs/wasm_generation_bug.md
Normal file
27
docs/wasm_generation_bug.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Wasm generation hang in wasmJs browser tests
|
||||
|
||||
## Summary
|
||||
The wasmJs browser test runner hung after commit 5f819dc. The root cause was invalid WebAssembly generated by the Kotlin/Wasm backend when certain compiler paths emitted suspend lambdas for `Statement` execution. The invalid module failed to instantiate in the browser, and Karma kept the browser connected but never ran tests.
|
||||
|
||||
## Symptoms
|
||||
- `:lynglib:wasmJsBrowserTest` hangs indefinitely in ChromeHeadless.
|
||||
- `:lynglib:wasmJsNodeTest` fails with a WebAssembly compile error similar to:
|
||||
- `struct.set expected type (ref null XXXX), found global.get of type (ref null YYYY)`
|
||||
- The failing function name in the wasm name section looks like:
|
||||
- `net.sergeych.lyng.$invokeCOROUTINE$.doResume`
|
||||
|
||||
## Root cause
|
||||
The delegation/var-declaration changes introduced compiler-generated suspend lambdas inside `Statement` construction (e.g., `statement { ... }` wrappers). Kotlin/Wasm generates extra coroutine state for those suspend lambdas, which in this case produced invalid wasm IR (mismatched GC reference types). The browser loader then waits forever because the module fails to instantiate.
|
||||
|
||||
## Fix
|
||||
Avoid suspend-lambda `Statement` construction in compiler code paths. Replace `statement { ... }` and other anonymous suspend lambdas with explicit `object : Statement()` implementations and move logic into `override suspend fun execute(...)`. This keeps the resulting wasm IR valid while preserving behavior.
|
||||
|
||||
## Where it was fixed
|
||||
- `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt`
|
||||
- `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt`
|
||||
|
||||
## Verification
|
||||
- `./gradlew :lynglib:wasmJsNodeTest --info`
|
||||
- `./gradlew :lynglib:wasmJsBrowserTest --info`
|
||||
|
||||
Both tests finish quickly after the change.
|
||||
File diff suppressed because it is too large
Load Diff
@ -70,8 +70,9 @@ open class Scope(
|
||||
|
||||
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
|
||||
var s: Scope? = this
|
||||
var hops = 0
|
||||
while (s != null && hops++ < 1024) {
|
||||
val visited = HashSet<Long>(4)
|
||||
while (s != null) {
|
||||
if (!visited.add(s.frameId)) break
|
||||
// Proximity rule: check all extensions in the current scope before going to parent.
|
||||
// Priority within scope: more specific class in MRO wins.
|
||||
for (cls in receiverClass.mro) {
|
||||
@ -105,38 +106,28 @@ open class Scope(
|
||||
* intertwined closure frames. They traverse the plain parent chain and consult only locals
|
||||
* and bindings of each frame. Instance/class member fallback must be decided by the caller.
|
||||
*/
|
||||
internal fun tryGetLocalRecord(s: Scope, name: String, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
|
||||
caller?.let { ctx ->
|
||||
s.objects[ctx.mangledName(name)]?.let { rec ->
|
||||
if (rec.visibility == Visibility.Private) return rec
|
||||
}
|
||||
}
|
||||
private fun tryGetLocalRecord(s: Scope, name: String, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
|
||||
s.objects[name]?.let { rec ->
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
|
||||
}
|
||||
caller?.let { ctx ->
|
||||
s.localBindings[ctx.mangledName(name)]?.let { rec ->
|
||||
if (rec.visibility == Visibility.Private) return rec
|
||||
}
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
|
||||
}
|
||||
s.localBindings[name]?.let { rec ->
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
|
||||
}
|
||||
s.getSlotIndexOf(name)?.let { idx ->
|
||||
val rec = s.getSlotRecord(idx)
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun chainLookupIgnoreClosure(name: String, followClosure: Boolean = false, caller: net.sergeych.lyng.obj.ObjClass? = null): ObjRecord? {
|
||||
internal fun chainLookupIgnoreClosure(name: String): ObjRecord? {
|
||||
var s: Scope? = this
|
||||
// use hop counter to detect unexpected structural cycles in the parent chain
|
||||
var hops = 0
|
||||
val effectiveCaller = caller ?: currentClassCtx
|
||||
while (s != null && hops++ < 1024) {
|
||||
tryGetLocalRecord(s, name, effectiveCaller)?.let { return it }
|
||||
s = if (followClosure && s is ClosureScope) s.closureScope else s.parent
|
||||
// use frameId to detect unexpected structural cycles in the parent chain
|
||||
val visited = HashSet<Long>(4)
|
||||
while (s != null) {
|
||||
if (!visited.add(s.frameId)) return null
|
||||
tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
|
||||
s = s.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -153,8 +144,9 @@ open class Scope(
|
||||
tryGetLocalRecord(this, name, currentClassCtx)?.let { return it }
|
||||
// 2) walk parents for plain locals/bindings only
|
||||
var s = parent
|
||||
var hops = 0
|
||||
while (s != null && hops++ < 1024) {
|
||||
val visited = HashSet<Long>(4)
|
||||
while (s != null) {
|
||||
if (!visited.add(s.frameId)) return null
|
||||
tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
|
||||
s = s.parent
|
||||
}
|
||||
@ -163,7 +155,7 @@ open class Scope(
|
||||
this.extensions[cls]?.get(name)?.let { return it }
|
||||
}
|
||||
return thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx, name)) {
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) {
|
||||
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) null
|
||||
else rec
|
||||
} else null
|
||||
@ -177,22 +169,23 @@ open class Scope(
|
||||
* This completely avoids invoking overridden `get` implementations, preventing
|
||||
* ping-pong recursion between `ClosureScope` frames.
|
||||
*/
|
||||
internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx, followClosure: Boolean = false): ObjRecord? {
|
||||
internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx): ObjRecord? {
|
||||
var s: Scope? = this
|
||||
var hops = 0
|
||||
while (s != null && hops++ < 1024) {
|
||||
val visited = HashSet<Long>(4)
|
||||
while (s != null) {
|
||||
if (!visited.add(s.frameId)) return null
|
||||
tryGetLocalRecord(s, name, caller)?.let { return it }
|
||||
for (cls in s.thisObj.objClass.mro) {
|
||||
s.extensions[cls]?.get(name)?.let { return it }
|
||||
}
|
||||
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, caller, name)) {
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, caller)) {
|
||||
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) {
|
||||
// ignore fields, properties and abstracts here, they will be handled by the caller via readField
|
||||
} else return rec
|
||||
}
|
||||
}
|
||||
s = if (followClosure && s is ClosureScope) s.closureScope else s.parent
|
||||
s = s.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -284,22 +277,16 @@ open class Scope(
|
||||
raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name"))
|
||||
|
||||
fun raiseError(message: String): Nothing {
|
||||
val ex = ObjException(this, message)
|
||||
throw ExecutionError(ex, pos, ex.message.value)
|
||||
throw ExecutionError(ObjException(this, message))
|
||||
}
|
||||
|
||||
fun raiseError(obj: ObjException): Nothing {
|
||||
throw ExecutionError(obj, obj.scope.pos, obj.message.value)
|
||||
}
|
||||
|
||||
fun raiseError(obj: Obj, pos: Pos, message: String): Nothing {
|
||||
throw ExecutionError(obj, pos, message)
|
||||
throw ExecutionError(obj)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun raiseNotFound(message: String = "not found"): Nothing {
|
||||
val ex = ObjNotFoundException(this, message)
|
||||
throw ExecutionError(ex, ex.scope.pos, ex.message.value)
|
||||
throw ExecutionError(ObjNotFoundException(this, message))
|
||||
}
|
||||
|
||||
inline fun <reified T : Obj> requiredArg(index: Int): T {
|
||||
@ -327,51 +314,37 @@ open class Scope(
|
||||
|
||||
inline fun <reified T : Obj> thisAs(): T {
|
||||
var s: Scope? = this
|
||||
while (s != null) {
|
||||
val t = s.thisObj
|
||||
do {
|
||||
val t = s!!.thisObj
|
||||
if (t is T) return t
|
||||
s = s.parent
|
||||
}
|
||||
} while (s != null)
|
||||
raiseClassCastError("Cannot cast ${thisObj.objClass.className} to ${T::class.simpleName}")
|
||||
}
|
||||
|
||||
internal val objects = mutableMapOf<String, ObjRecord>()
|
||||
|
||||
open operator fun get(name: String): ObjRecord? {
|
||||
if (name == "this") return thisObj.asReadonly
|
||||
|
||||
// 1. Prefer direct locals/bindings declared in this frame
|
||||
tryGetLocalRecord(this, name, currentClassCtx)?.let { return it }
|
||||
|
||||
val p = parent
|
||||
|
||||
// 2. If we share thisObj with parent, delegate to parent to maintain
|
||||
// "locals shadow members" priority across the this-context.
|
||||
if (p != null && p.thisObj === thisObj) {
|
||||
return p.get(name)
|
||||
open operator fun get(name: String): ObjRecord? =
|
||||
if (name == "this") thisObj.asReadonly
|
||||
else {
|
||||
// Prefer direct locals/bindings declared in this frame
|
||||
(objects[name]?.let { rec ->
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
|
||||
}
|
||||
|
||||
// 3. Otherwise, we are the "primary" scope for this thisObj (or have no parent),
|
||||
// so check members of thisObj before walking up to a different this-context.
|
||||
val receiver = thisObj
|
||||
val effectiveClass = receiver as? ObjClass ?: receiver.objClass
|
||||
for (cls in effectiveClass.mro) {
|
||||
val rec = cls.members[name] ?: cls.classScope?.objects?.get(name)
|
||||
if (rec != null && !rec.isAbstract) {
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass ?: cls, currentClassCtx, name)) {
|
||||
return rec.copy(receiver = receiver)
|
||||
// Then, check known local bindings in this frame (helps after suspension)
|
||||
?: localBindings[name]?.let { rec ->
|
||||
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
|
||||
}
|
||||
// Walk up ancestry
|
||||
?: parent?.get(name)
|
||||
// Finally, fallback to class members on thisObj
|
||||
?: thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) {
|
||||
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) null
|
||||
else rec
|
||||
} else null
|
||||
}
|
||||
}
|
||||
// Finally, root object fallback
|
||||
Obj.rootObjectType.members[name]?.let { rec ->
|
||||
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx, name)) {
|
||||
return rec.copy(receiver = receiver)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Finally, walk up ancestry to a scope with a different thisObj context
|
||||
return p?.get(name)
|
||||
)
|
||||
}
|
||||
|
||||
// Slot fast-path API
|
||||
@ -392,20 +365,6 @@ open class Scope(
|
||||
nameToSlot[name]?.let { slots[it] = record }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all references and maps to prevent memory leaks when pooled.
|
||||
*/
|
||||
fun scrub() {
|
||||
this.parent = null
|
||||
this.skipScopeCreation = false
|
||||
this.currentClassCtx = null
|
||||
objects.clear()
|
||||
slots.clear()
|
||||
nameToSlot.clear()
|
||||
localBindings.clear()
|
||||
extensions.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset this scope instance so it can be safely reused as a fresh child frame.
|
||||
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
|
||||
@ -415,7 +374,6 @@ open class Scope(
|
||||
// that could interact badly with the new parent and produce a cycle.
|
||||
this.parent = null
|
||||
this.skipScopeCreation = false
|
||||
this.currentClassCtx = parent?.currentClassCtx
|
||||
// fresh identity for PIC caches
|
||||
this.frameId = nextFrameId()
|
||||
// clear locals and slot maps
|
||||
@ -516,8 +474,7 @@ open class Scope(
|
||||
declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx,
|
||||
isAbstract: Boolean = false,
|
||||
isClosed: Boolean = false,
|
||||
isOverride: Boolean = false,
|
||||
isTransient: Boolean = false
|
||||
isOverride: Boolean = false
|
||||
): ObjRecord {
|
||||
val rec = ObjRecord(
|
||||
value, isMutable, visibility, writeVisibility,
|
||||
@ -525,8 +482,7 @@ open class Scope(
|
||||
type = recordType,
|
||||
isAbstract = isAbstract,
|
||||
isClosed = isClosed,
|
||||
isOverride = isOverride,
|
||||
isTransient = isTransient
|
||||
isOverride = isOverride
|
||||
)
|
||||
objects[name] = rec
|
||||
// Index this binding within the current frame to help resolve locals across suspension
|
||||
@ -545,16 +501,12 @@ open class Scope(
|
||||
}
|
||||
}
|
||||
// Map to a slot for fast local access (ensure consistency)
|
||||
if (nameToSlot.isEmpty()) {
|
||||
allocateSlotFor(name, rec)
|
||||
} else {
|
||||
val idx = nameToSlot[name]
|
||||
val idx = getSlotIndexOf(name)
|
||||
if (idx == null) {
|
||||
allocateSlotFor(name, rec)
|
||||
} else {
|
||||
slots[idx] = rec
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
@ -670,34 +622,31 @@ open class Scope(
|
||||
}
|
||||
|
||||
suspend fun resolve(rec: ObjRecord, name: String): Obj {
|
||||
val receiver = rec.receiver ?: thisObj
|
||||
return receiver.resolveRecord(this, rec, name, rec.declaringClass).value
|
||||
if (rec.type == ObjRecord.Type.Delegated) {
|
||||
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
|
||||
val th = if (thisObj === ObjVoid) ObjNull else thisObj
|
||||
if (del.objClass.getInstanceMemberOrNull("getValue") == null) {
|
||||
return object : Statement() {
|
||||
override val pos: Pos = Pos.builtIn
|
||||
override suspend fun execute(scope: Scope): Obj {
|
||||
val th2 = if (scope.thisObj === ObjVoid) ObjNull else scope.thisObj
|
||||
val allArgs = (listOf(th2, ObjString(name)) + scope.args.list).toTypedArray()
|
||||
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
|
||||
}
|
||||
}
|
||||
}
|
||||
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)))
|
||||
}
|
||||
return rec.value
|
||||
}
|
||||
|
||||
suspend fun assign(rec: ObjRecord, name: String, newValue: Obj) {
|
||||
if (rec.type == ObjRecord.Type.Delegated) {
|
||||
val receiver = rec.receiver ?: thisObj
|
||||
val del = rec.delegate ?: run {
|
||||
if (receiver is ObjInstance) {
|
||||
(receiver as ObjInstance).writeField(this, name, newValue)
|
||||
return
|
||||
}
|
||||
raiseError("Internal error: delegated property $name has no delegate")
|
||||
}
|
||||
val th = if (receiver === ObjVoid) ObjNull else receiver
|
||||
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
|
||||
val th = if (thisObj === ObjVoid) ObjNull else thisObj
|
||||
del.invokeInstanceMethod(this, "setValue", Arguments(th, ObjString(name), newValue))
|
||||
return
|
||||
}
|
||||
if (rec.value is ObjProperty) {
|
||||
(rec.value as ObjProperty).callSetter(this, rec.receiver ?: thisObj, newValue, rec.declaringClass)
|
||||
return
|
||||
}
|
||||
// If it's a member (explicitly tracked by receiver or declaringClass), use writeField.
|
||||
// Important: locals have receiver == null and declaringClass == null (enforced in addItem).
|
||||
if (rec.receiver != null || (rec.declaringClass != null && (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property))) {
|
||||
(rec.receiver ?: thisObj).writeField(this, name, newValue)
|
||||
return
|
||||
}
|
||||
if (!rec.isMutable && rec.value !== ObjUnset) raiseIllegalAssignment("can't reassign val $name")
|
||||
rec.value = newValue
|
||||
}
|
||||
|
||||
@ -501,9 +501,8 @@ open class Obj {
|
||||
if (obj.type == ObjRecord.Type.Delegated) {
|
||||
val del = obj.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate")
|
||||
val th = if (this === ObjVoid) ObjNull else this
|
||||
val res = del.invokeInstanceMethod(scope, "getValue", Arguments(th, ObjString(name)), onNotFoundResult = {
|
||||
// If getValue not found, return a wrapper that calls invoke
|
||||
object : Statement() {
|
||||
if (del.objClass.getInstanceMemberOrNull("getValue") == null) {
|
||||
val wrapper = object : Statement() {
|
||||
override val pos: Pos = Pos.builtIn
|
||||
override suspend fun execute(s: Scope): Obj {
|
||||
val th2 = if (s.thisObj === ObjVoid) ObjNull else s.thisObj
|
||||
@ -511,7 +510,12 @@ open class Obj {
|
||||
return del.invokeInstanceMethod(s, "invoke", Arguments(*allArgs))
|
||||
}
|
||||
}
|
||||
})
|
||||
return obj.copy(
|
||||
value = wrapper,
|
||||
type = ObjRecord.Type.Other
|
||||
)
|
||||
}
|
||||
val res = del.invokeInstanceMethod(scope, "getValue", Arguments(th, ObjString(name)))
|
||||
return obj.copy(
|
||||
value = res,
|
||||
type = ObjRecord.Type.Other
|
||||
@ -605,16 +609,17 @@ open class Obj {
|
||||
scope.raiseNotImplemented()
|
||||
}
|
||||
|
||||
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj =
|
||||
if (PerfFlags.SCOPE_POOL)
|
||||
scope.withChildFrame(args, newThisObj = thisObj) { child ->
|
||||
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj {
|
||||
if (PerfFlags.SCOPE_POOL) {
|
||||
return scope.withChildFrame(args, newThisObj = thisObj) { child ->
|
||||
if (declaringClass != null) child.currentClassCtx = declaringClass
|
||||
callOn(child)
|
||||
}
|
||||
else
|
||||
callOn(scope.createChildScope(scope.pos, args = args, newThisObj = thisObj).also {
|
||||
if (declaringClass != null) it.currentClassCtx = declaringClass
|
||||
})
|
||||
}
|
||||
val child = scope.createChildScope(scope.pos, args = args, newThisObj = thisObj)
|
||||
if (declaringClass != null) child.currentClassCtx = declaringClass
|
||||
return callOn(child)
|
||||
}
|
||||
|
||||
suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj =
|
||||
callOn(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user