Compare commits

...

2 Commits

6 changed files with 140 additions and 51 deletions

8
AGENTS.md Normal file
View File

@ -0,0 +1,8 @@
# AI Agent Notes
## Kotlin/Wasm generation guardrails
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.
- Do not use `statement { ... }` or other inline suspend lambdas in compiler hot paths (e.g., parsing/var declarations, initializer thunks).
- If you need a wrapper for delegated properties, check for `getValue` explicitly and return a concrete `Statement` object when missing; avoid `onNotFoundResult` lambdas.
- If wasmJs browser tests hang, first run `:lynglib:wasmJsNodeTest` and look for wasm compilation errors; hangs usually mean module instantiation failed.
- Do not increase test timeouts to mask wasm generation errors; fix the invalid IR instead.

View File

@ -45,6 +45,7 @@ fun swapEnds(first, args..., last, f) {
- [Samples directory](docs/samples)
- [Formatter (core + CLI + IDE)](docs/formatter.md)
- [Books directory](docs)
- [AI agent guidance](AGENTS.md)
## Integration in Kotlin multiplatform

View 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.

View 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.

View File

@ -3267,17 +3267,23 @@ class Compiler(
// when creating instance, but we need to execute it in the class initializer which
// is missing as for now. Add it to the compiler context?
currentInitScope += statement {
val initValue = initialExpression?.execute(this)?.byValueCopy() ?: ObjNull
currentInitScope += object : Statement() {
override val pos: Pos = start
override suspend fun execute(scope: Scope): Obj {
val initValue = initialExpression?.execute(scope)?.byValueCopy() ?: ObjNull
if (isDelegate) {
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val accessType = scope.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(this, "bind", Arguments(ObjString(name), accessType, thisObj))
initValue.invokeInstanceMethod(
scope,
"bind",
Arguments(ObjString(name), accessType, scope.thisObj)
)
} catch (e: Exception) {
initValue
}
(thisObj as ObjClass).createClassField(
(scope.thisObj as ObjClass).createClassField(
name,
ObjUnset,
isMutable,
@ -3290,14 +3296,31 @@ class Compiler(
delegate = finalDelegate
}
// Also expose in current init scope
addItem(name, isMutable, ObjUnset, visibility, null, ObjRecord.Type.Delegated, isTransient = isTransient).apply {
scope.addItem(
name,
isMutable,
ObjUnset,
visibility,
null,
ObjRecord.Type.Delegated,
isTransient = isTransient
).apply {
delegate = finalDelegate
}
} else {
(thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start, isTransient = isTransient)
addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient)
(scope.thisObj as ObjClass).createClassField(
name,
initValue,
isMutable,
visibility,
null,
start,
isTransient = isTransient
)
scope.addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient)
}
return ObjVoid
}
ObjVoid
}
return NopStatement
}
@ -3349,10 +3372,13 @@ class Compiler(
val body = inCodeContext(CodeContext.Function("<setter>")) {
parseBlock()
}
statement(body.pos) { scope ->
object : Statement() {
override val pos: Pos = body.pos
override suspend fun execute(scope: Scope): Obj {
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument)
body.execute(scope)
return body.execute(scope)
}
}
} else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) {
cc.skipWsTokens()
@ -3362,10 +3388,13 @@ class Compiler(
?: throw ScriptError(cc.current().pos, "Expected setter expression")
}
val st = expr
statement(st.pos) { scope ->
object : Statement() {
override val pos: Pos = st.pos
override suspend fun execute(scope: Scope): Obj {
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument)
st.execute(scope)
return st.execute(scope)
}
}
} else {
throw ScriptError(cc.current().pos, "Expected { or = after set(...)")
@ -3388,10 +3417,13 @@ class Compiler(
val body = inCodeContext(CodeContext.Function("<setter>")) {
parseBlock()
}
statement(body.pos) { scope ->
object : Statement() {
override val pos: Pos = body.pos
override suspend fun execute(scope: Scope): Obj {
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
body.execute(scope)
return body.execute(scope)
}
}
} else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) {
cc.skipWsTokens()
@ -3402,10 +3434,13 @@ class Compiler(
"Expected setter expression"
)
}
statement(st.pos) { scope ->
object : Statement() {
override val pos: Pos = st.pos
override suspend fun execute(scope: Scope): Obj {
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
st.execute(scope)
return st.execute(scope)
}
}
} else {
throw ScriptError(cc.current().pos, "Expected { or = after set(...)")
@ -3994,4 +4029,3 @@ class Compiler(
suspend fun eval(code: String) = compile(code).execute()
suspend fun evalNamed(name: String, code: String, importManager: ImportManager = Script.defaultImportManager) =
compile(Source(name,code), importManager).execute()

View File

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