Skip to content

LuaState.kt

src/jvmMain/kotlin/afield/lang/lua/LuaState.kt · 493 lines

package afield.lang.lua

import afield.lang.lua.LuaNative.LUA_GLOBALSINDEX
import afield.lang.lua.LuaNative.LUA_MULTRET
import afield.lang.lua.LuaNative.LUA_REGISTRYINDEX
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout.JAVA_BYTE
import java.lang.foreign.ValueLayout.JAVA_LONG
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

The LuaJIT state behind a Lua box: LuaState opens a lua_State, runs chunks under lua_pcall, and marshals values between Lua and Kotlin.

LuaStateTest exercises it against a live LuaJIT.

class LuaState(raw: Map<String, Handler> = emptyMap()) : AutoCloseable

One LuaJIT lua_State: open, evaluate, marshal values both ways, let Lua call Kotlin, close.

Values cross as plain data. Lua nil, booleans, numbers and strings become null, Boolean, Double and String; a table whose keys are exactly 1..n becomes a List, any other table a Map (keys as String or Double); a function becomes a LuaFunction that Kotlin can call back. Going the other way, Numbers, CharSequences, Maps, Iterables and arrays become their Lua counterparts, a afield.app.protocol.Rect becomes {x=, y=, w=, h=}, a Handler becomes a Lua function, and anything else its toString().

The FFM rule this class is built around: a Lua error must never unwind through a Java frame. LuaJIT raises errors by unwinding the C stack to the nearest lua_pcall. If a Kotlin Handler — an FFM upcall — called lua_error, or touched a table with a metamethod that raised, the unwind would cross the upcall's Java frames and take the VM down. So a handler never raises: dispatch catches everything it throws and returns false, message to Lua, and the Lua-side wrapper the prelude puts around every handler turns that into an ordinary Lua error at the caller's line. Inside a handler only raw table access is used (lua_rawget, lua_next), which calls no metamethods. And every evaluation from Kotlin is a lua_pcall, so nothing Lua raises reaches the panic handler.

Not thread-safe by itself; every public method takes this state's lock. Lua code runs on whichever thread calls run or call — in a box, the main Runloop thread.

class LuaState(raw: Map<String, Handler> = emptyMap()) : AutoCloseable {

fun interface Handler

A Kotlin function Lua can call. args are marshalled Lua values; the returned list is pushed as the results.

fun call(state: LuaState, args: List<Any?>): List<Any?>

class LuaFunction internal constructor(val state: LuaState, internal val ref: Int)

A Lua function held from Kotlin: a reference in the registry, released when the state closes or release is called.

    class LuaFunction internal constructor(val state: LuaState, internal val ref: Int) {

operator fun invoke(vararg args: Any?): Result = state.call(this, *args)

Call it through LuaState.call; the result is Result.Ok with its return values or Result.Err.

fun release() = state.releaseRef(ref)

Drop the registry reference now rather than when the state closes.

override fun toString() = "a Lua function"

sealed class Result

What an evaluation came to.

    sealed class Result {

class Ok(val values: List<Any?>) : Result()

Everything the chunk or function returned.

class Err(val message: String, val line: Int?, val syntax: Boolean = false, val traceback: String? = null) : Result()

The error: message without its box:12: position, line the 1-based line of the chunk it happened on when one is known, syntax true when the chunk did not compile.

override fun toString() = (line?.let { "line $it: " } ?: "") + message
    private val lock = ReentrantLock()
    private var l: MemorySegment? = LuaNative.newState().also {
        require(it.address() != 0L) { "luaL_newstate returned NULL" }
        LuaNative.openLibs(it)
    }
    private val handlerIds = ArrayList<Int>()
    private val refs = HashSet<Int>()

val isClosed: Boolean get() = l == null

True once close has run; every other method then refuses.

init

    init {
        lock.withLock {
            val s = l!!
            // The raw handlers go in a table the prelude takes and then removes from the globals.
            LuaNative.createTable(s, 0, 16)
            (defaultRaw + raw).forEach { (name, handler) -> pushString(s, name); pushHandler(s, handler); LuaNative.rawSet(s, -3) }
            setGlobalFromStack(s, RAW_TABLE)
            val loaded = run(PRELUDE, chunkName = "=field")
            if (loaded !is Result.Ok) {
                LuaNative.close(s)
                l = null
                throw IllegalStateException("the Lua prelude did not load: $loaded")
            }
            openStates.incrementAndGet()
        }
    }

    private fun live(): MemorySegment = l ?: throw IllegalStateException("this Lua state is closed")

val version: String

jit.version — "LuaJIT 2.1.…" — or _VERSION for a plain Lua.

    val version: String
        get() = (run("return jit and jit.version or _VERSION") as? Result.Ok)?.values?.firstOrNull()?.toString() ?: "?"

val memoryKb: Int get() = lock.withLock { LuaNative.gc(live(), LuaNative.LUA_GCCOUNT, 0) }

Kilobytes the Lua heap holds.

internal val stackTop: Int get() = lock.withLock { LuaNative.getTop(live()) }

Stack depth; zero between calls. For tests, which assert nothing is left behind.

fun setGlobal(name: String, value: Any?)

Set global name to value, marshalled. A Handler becomes a Lua function that raises on failure.

    fun setGlobal(name: String, value: Any?) = lock.withLock {
        val s = live()
        if (value is Handler) {
            // Wrapped, so a failing Kotlin function raises a Lua error at the caller's line.
            val wrapped = call("__field_checked", value)
            if (wrapped is Result.Ok) {
                pushString(s, name); push(s, wrapped.values.firstOrNull(), 0); LuaNative.rawSet(s, LUA_GLOBALSINDEX)
                (wrapped.values.firstOrNull() as? LuaFunction)?.release()
            }
            return@withLock
        }
        pushString(s, name); push(s, value, 0); LuaNative.rawSet(s, LUA_GLOBALSINDEX)
    }

fun getGlobal(name: String): Any?

Global name, marshalled; raw access, so a strict-mode metatable on _G is not consulted.

    fun getGlobal(name: String): Any? = lock.withLock {
        val s = live()
        pushString(s, name); LuaNative.rawGet(s, LUA_GLOBALSINDEX)
        try { toKotlin(s, -1, 0, HashSet()) } finally { LuaNative.pop(s, 1) }
    }

fun run(code: String, chunkName: String = CHUNK): Result

Compile and run code as a chunk called chunkName (the name Lua puts in front of an error's line number: =box gives box:3: …). Returns what the chunk returned, or the error.

    fun run(code: String, chunkName: String = CHUNK): Result = lock.withLock {
        val loaded = load(code, chunkName)
        if (loaded !is LuaFunction) return@withLock loaded as Result
        try { call(loaded) } finally { loaded.release() }
    }

fun load(code: String, chunkName: String = CHUNK): Any

Compile code without running it: a LuaFunction, or the syntax error as a Result.Err.

    fun load(code: String, chunkName: String = CHUNK): Any = lock.withLock {
        val s = live()
        val top = LuaNative.getTop(s)
        Arena.ofConfined().use { arena ->
            val bytes = code.toByteArray(Charsets.UTF_8)
            val buf = arena.allocate(bytes.size.toLong().coerceAtLeast(1))
            MemorySegment.copy(bytes, 0, buf, JAVA_BYTE, 0, bytes.size)
            val status = LuaNative.loadBuffer(s, buf, bytes.size.toLong(), arena.allocateFrom(chunkName))
            if (status != LuaNative.LUA_OK) {
                val message = if (LuaNative.type(s, -1) == LuaNative.LUA_TSTRING) readString(s, -1) else "could not load the chunk (status $status)"
                LuaNative.setTop(s, top)
                return@withLock errorFrom(message, null, null, syntax = true)
            }
            LuaFunction(this, refTop(s))
        }
    }

class Outcome(val result: Result, val lines: Map<Int, LineRecord>, val truncated: Boolean)

What one guarded run came to: the result, and when lines were recorded, for each 1-based line of the chunk that ran, how often and (for the main chunk) the names it assigned with their values.

class LineRecord(val count: Int, val main: Boolean, val values: List<Pair<String, String>>)

One recorded line: count times it ran, main whether in the chunk's top level, values name → value as shown.

fun runGuarded(code: String, lineNames: Map<Int, List<String>>?, asExpression: Boolean = false): Outcome

Run code under the prelude's guard — stopped with an error after field.budget seconds — and, when lineNames is given (1-based chunk line → names assigned on it), record its lines as it runs. With asExpression, return <code> is tried first, the way the Lua REPL treats a line.

    fun runGuarded(code: String, lineNames: Map<Int, List<String>>?, asExpression: Boolean = false): Outcome = lock.withLock {
        val compiled = (if (asExpression) load("return $code") as? LuaFunction else null) ?: load(code)
        if (compiled !is LuaFunction) return@withLock Outcome(compiled as Result, emptyMap(), false)
        try {
            guarded(compiled, lineNames?.mapValues { it.value } ?: false, perFrame = false) { call(compiled) }
        } finally {
            compiled.release()
        }
    }

fun tick(now: Double, dt: Double): Result

One frame of a launched box: update(dt) and the due tasks, guarded by field.frameBudget.

    fun tick(now: Double, dt: Double): Result = lock.withLock {
        guarded(null, false, perFrame = true) { call("__field_tick", now, dt) }.result
    }

fun callGuarded(fn: LuaFunction, vararg args: Any?): Result

Call fn under the frame budget: a callback from Field (a slider moving) into the box's Lua.

    fun callGuarded(fn: LuaFunction, vararg args: Any?): Result = lock.withLock {
        guarded(null, false, perFrame = true) { call(fn, *args) }.result
    }

    private fun guarded(fn: LuaFunction?, names: Any, perFrame: Boolean, body: () -> Result): Outcome {
        val begun = call("__field_begin", fn, names.takeIf { it != false }, perFrame)
        if (begun is Result.Err) return Outcome(begun, emptyMap(), false)
        val result = body()
        val ended = call("__field_end")
        val values = (ended as? Result.Ok)?.values.orEmpty()
        // The record is keyed by line; when the lines that ran are exactly 1..n it arrives as a List.
        val entries: List<Pair<Int, Any?>> = when (val rec = values.getOrNull(0)) {
            is Map<*, *> -> rec.entries.mapNotNull { (k, v) -> (k as? Double)?.toInt()?.let { it to v } }
            is List<*> -> rec.mapIndexed { i, v -> (i + 1) to v }
            else -> emptyList()
        }
        val lines = LinkedHashMap<Int, LineRecord>()
        for ((line, v) in entries) {
            val r = v as? Map<*, *> ?: continue
            val vals = (r["values"] as? List<*>).orEmpty().mapNotNull { pair ->
                val p = pair as? List<*> ?: return@mapNotNull null
                (p.getOrNull(0)?.toString() ?: return@mapNotNull null) to (p.getOrNull(1)?.toString() ?: "nil")
            }
            lines[line] = LineRecord((r["count"] as? Double)?.toInt() ?: 0, r["main"] == true, vals)
        }
        return Outcome(result, lines, values.getOrNull(1) == true)
    }

fun call(name: String, vararg args: Any?): Result

Call the global function name with args.

    fun call(name: String, vararg args: Any?): Result = lock.withLock {
        val s = live()
        pushString(s, name); LuaNative.rawGet(s, LUA_GLOBALSINDEX)
        callTop(s, args)
    }

fun call(fn: LuaFunction, vararg args: Any?): Result

Call fn with args.

    fun call(fn: LuaFunction, vararg args: Any?): Result = lock.withLock {
        require(fn.state === this) { "that function belongs to another Lua state" }
        val s = live()
        LuaNative.rawGetI(s, LUA_REGISTRYINDEX, fn.ref)
        callTop(s, args)
    }

Call the value on top of the stack under the prelude's message handler; leaves the stack as it found it.

    private fun callTop(s: MemorySegment, args: Array<out Any?>): Result {
        val base = LuaNative.getTop(s) - 1 // the function's slot
        // The message handler goes below the function: it records where the error happened while the
        // failing frame is still on the stack, which is the only moment that is knowable.
        pushString(s, "__field_msgh"); LuaNative.rawGet(s, LUA_GLOBALSINDEX)
        LuaNative.insert(s, base + 1)
        val handlerIndex = base + 1
        args.forEach { push(s, it, 0) }
        val status = LuaNative.pcall(s, args.size, LUA_MULTRET, handlerIndex)
        try {
            if (status != LuaNative.LUA_OK) {
                return when (LuaNative.type(s, -1)) {
                    LuaNative.LUA_TTABLE -> {
                        @Suppress("UNCHECKED_CAST")
                        val e = toKotlin(s, -1, 0, HashSet()) as? Map<Any?, Any?> ?: emptyMap()
                        errorFrom(e["msg"]?.toString() ?: "error", (e["line"] as? Double)?.toInt(), e["trace"]?.toString())
                    }
                    LuaNative.LUA_TSTRING -> errorFrom(readString(s, -1), null, null)
                    else -> Result.Err(if (status == LuaNative.LUA_ERRMEM) "Lua ran out of memory" else "Lua error (status $status)", null)
                }
            }
            val results = (handlerIndex + 1..LuaNative.getTop(s)).map { toKotlin(s, it, 0, HashSet()) }
            return Result.Ok(results)
        } finally {
            LuaNative.setTop(s, base)
        }
    }

    private fun errorFrom(raw: String, line: Int?, trace: String?, syntax: Boolean = false): Result.Err {
        val m = POSITION.find(raw)
        val at = m?.groupValues?.get(2)?.toIntOrNull()
        val stripped = if (m != null && m.groupValues[1] == CHUNK.removePrefix("=")) raw.substring(m.range.last + 1) else raw
        return Result.Err(stripped.trim(), line ?: if (m?.groupValues?.get(1) == CHUNK.removePrefix("=")) at else null, syntax, trace)
    }

internal fun releaseRef(ref: Int)

    internal fun releaseRef(ref: Int) = lock.withLock {
        val s = l ?: return@withLock
        if (refs.remove(ref)) LuaNative.unref(s, LUA_REGISTRYINDEX, ref)
    }

override fun close()

Close the state: every LuaFunction and Handler it handed out goes with it. Idempotent.

    override fun close() = lock.withLock {
        val s = l ?: return@withLock
        l = null
        handlerIds.forEach { handlers.remove(it) }
        handlerIds.clear()
        refs.clear()
        LuaNative.close(s)
        openStates.decrementAndGet()
        Unit
    }

marshalling

    private fun absIndex(s: MemorySegment, idx: Int) = if (idx < 0 && idx > LUA_REGISTRYINDEX) LuaNative.getTop(s) + idx + 1 else idx

    private fun refTop(s: MemorySegment): Int = LuaNative.ref(s, LUA_REGISTRYINDEX).also { refs += it }

    private fun readString(s: MemorySegment, idx: Int): String = Arena.ofConfined().use { arena ->
        val len = arena.allocate(JAVA_LONG)
        val p = LuaNative.toLString(s, idx, len)
        if (p.address() == 0L) return "" else {
            val n = len.get(JAVA_LONG, 0)
            String(p.reinterpret(n).toArray(JAVA_BYTE), Charsets.UTF_8)
        }
    }

    private fun pushString(s: MemorySegment, text: String) = Arena.ofConfined().use { arena ->
        val bytes = text.toByteArray(Charsets.UTF_8)
        val buf = arena.allocate(bytes.size.toLong().coerceAtLeast(1))
        MemorySegment.copy(bytes, 0, buf, JAVA_BYTE, 0, bytes.size)
        LuaNative.pushLString(s, buf, bytes.size.toLong())
    }

    private fun setGlobalFromStack(s: MemorySegment, name: String) {
        pushString(s, name); LuaNative.insert(s, -2); LuaNative.rawSet(s, LUA_GLOBALSINDEX)
    }

    private fun pushHandler(s: MemorySegment, handler: Handler) {
        val id = nextHandlerId.getAndIncrement()
        handlers[id] = Registered(this, handler)
        handlerIds += id
        LuaNative.pushNumber(s, id.toDouble())
        LuaNative.pushCClosure(s, LuaNative.dispatchStub, 1)
    }

internal fun push(s: MemorySegment, value: Any?, depth: Int)

Push value as Lua; see the class comment for the mapping.

    internal fun push(s: MemorySegment, value: Any?, depth: Int) {
        if (depth > MAX_DEPTH) { LuaNative.pushNil(s); return }
        when (value) {
            null, Unit -> LuaNative.pushNil(s)
            is Boolean -> LuaNative.pushBoolean(s, value)
            is Number -> LuaNative.pushNumber(s, value.toDouble())
            is CharSequence -> pushString(s, value.toString())
            is Char -> pushString(s, value.toString())
            is LuaFunction -> if (value.state === this && value.ref in refs) LuaNative.rawGetI(s, LUA_REGISTRYINDEX, value.ref) else LuaNative.pushNil(s)
            is Handler -> pushHandler(s, value)
            is Map<*, *> -> {
                LuaNative.createTable(s, 0, value.size)
                value.forEach { (k, v) ->
                    if (k == null) return@forEach
                    if (k is Number) LuaNative.pushNumber(s, k.toDouble()) else pushString(s, k.toString())
                    push(s, v, depth + 1)
                    LuaNative.rawSet(s, -3)
                }
            }
            is afield.app.protocol.Rect -> push(s, linkedMapOf("x" to value.x, "y" to value.y, "w" to value.w, "h" to value.h), depth)
            is DoubleArray -> pushList(s, value.asList(), depth)
            is FloatArray -> pushList(s, value.asList(), depth)
            is IntArray -> pushList(s, value.asList(), depth)
            is Array<*> -> pushList(s, value.asList(), depth)
            is Iterable<*> -> pushList(s, value.toList(), depth)
            else -> pushString(s, value.toString())
        }
    }

    private fun pushList(s: MemorySegment, list: List<Any?>, depth: Int) {
        LuaNative.createTable(s, list.size, 0)
        list.forEachIndexed { i, v -> push(s, v, depth + 1); LuaNative.rawSetI(s, -2, i + 1) }
    }

internal fun toKotlin(s: MemorySegment, idx: Int, depth: Int, seen: MutableSet<Long>): Any?

The Lua value at idx as Kotlin; see the class comment. Raw access only, so no metamethod runs. A table seen twice on the way down (a cycle) or deeper than MAX_DEPTH becomes the string "…".

body — 43 lines, from line 364
    internal fun toKotlin(s: MemorySegment, idx: Int, depth: Int, seen: MutableSet<Long>): Any? {
        val i = absIndex(s, idx)
        return when (LuaNative.type(s, i)) {
            LuaNative.LUA_TNIL, LuaNative.LUA_TNONE -> null
            LuaNative.LUA_TBOOLEAN -> LuaNative.toBoolean(s, i)
            LuaNative.LUA_TNUMBER -> LuaNative.toNumber(s, i)
            LuaNative.LUA_TSTRING -> readString(s, i)
            LuaNative.LUA_TFUNCTION -> { LuaNative.pushValue(s, i); LuaFunction(this, refTop(s)) }
            LuaNative.LUA_TTABLE -> tableToKotlin(s, i, depth, seen)
            LuaNative.LUA_TUSERDATA -> "a userdata"
            LuaNative.LUA_TLIGHTUSERDATA -> "a light userdata"
            LuaNative.LUA_TTHREAD -> "a coroutine"
            else -> null
        }
    }

    private fun tableToKotlin(s: MemorySegment, i: Int, depth: Int, seen: MutableSet<Long>): Any? {
        // A Field box proxy carries its box's id in its metatable's `__box`; it crosses as a BoxRef.
        if (LuaNative.getMetatable(s, i)) {
            pushString(s, "__box"); LuaNative.rawGet(s, -2)
            val id = if (LuaNative.type(s, -1) == LuaNative.LUA_TSTRING) readString(s, -1) else null
            LuaNative.pop(s, 2)
            if (id != null) return BoxRef(id)
        }
        val pointer = LuaNative.toPointer(s, i)
        if (depth > MAX_DEPTH || !seen.add(pointer)) return "…"
        val entries = LinkedHashMap<Any, Any?>()
        LuaNative.pushNil(s)
        while (LuaNative.next(s, i) != 0) {
            val key: Any? = when (LuaNative.type(s, -2)) {
                LuaNative.LUA_TNUMBER -> LuaNative.toNumber(s, -2)
                LuaNative.LUA_TSTRING -> readString(s, -2)
                LuaNative.LUA_TBOOLEAN -> LuaNative.toBoolean(s, -2).toString()
                else -> null
            }
            if (key != null) entries[key] = toKotlin(s, -1, depth + 1, seen)
            LuaNative.pop(s, 1)
        }
        seen.remove(pointer)
        val n = entries.size
        val isList = entries.keys.all { it is Double && it == Math.floor(it) && it >= 1.0 && it <= n }
        return if (isList) List(n) { entries[(it + 1).toDouble()] } else entries
    }

data class BoxRef(val id: String)

A Lua proxy for a Field box, as it arrives in Kotlin: the box's id.

    private class Registered(val state: LuaState, val handler: Handler)

companion object

    companion object {

const val CHUNK = "=box"

The chunk name box code runs under, so its errors read box:12: ….

internal const val RAW_TABLE = "__field_raw"

Where the prelude finds the raw Kotlin handlers before it hides them.

        private const val MAX_DEPTH = 24

        private val POSITION = Regex("""^([^:\n]{1,40}):(\d+):""")

        private val handlers = ConcurrentHashMap<Int, Registered>()

Handlers every state has: a wall clock in seconds, which the guard reads.

        private val defaultRaw: Map<String, Handler> = mapOf(
            "clock" to Handler { _, _ -> listOf(System.nanoTime() / 1e9) },
        )
        private val nextHandlerId = AtomicInteger(1)

internal val openStates = AtomicInteger(0)

States opened and not yet closed, process-wide; for the leak tests.

internal val liveHandlers: Int get() = handlers.size

Handlers registered and not yet released with their state; for the leak tests.

fun dispatch(s: MemorySegment): Int

The upcall behind every Kotlin function Lua calls (LuaNative.dispatchStub). Returns true, results… or false, message: it never raises into Lua and never lets a Kotlin exception reach the FFM boundary, which would abort the VM.

        @JvmStatic
        fun dispatch(s: MemorySegment): Int {
            return try {
                val id = LuaNative.toNumber(s, LuaNative.upvalueIndex(1)).toInt()
                val registered = handlers[id]
                if (registered == null) {
                    LuaNative.setTop(s, 0)
                    LuaNative.pushBoolean(s, false)
                    registered(s, "this Field function belongs to a Lua state that has been closed")
                    return 2
                }
                val state = registered.state
                val n = LuaNative.getTop(s)
                val args = (1..n).map { state.toKotlin(s, it, 0, HashSet()) }
                val results = try {
                    registered.handler.call(state, args)
                } catch (t: Throwable) {
                    LuaNative.setTop(s, 0)
                    LuaNative.pushBoolean(s, false)
                    state.pushString(s, t.message ?: t.javaClass.simpleName)
                    return 2
                }
                LuaNative.setTop(s, 0)
                LuaNative.pushBoolean(s, true)
                results.forEach { state.push(s, it, 0) }
                1 + results.size
            } catch (t: Throwable) {
                // Marshalling itself failed. Nothing sensible can be pushed if the stack is the problem.
                runCatching { LuaNative.setTop(s, 0); LuaNative.pushBoolean(s, false) }.getOrNull() ?: return 0
                1
            }
        }

        private fun registered(s: MemorySegment, message: String) = Arena.ofConfined().use { arena ->
            val bytes = message.toByteArray(Charsets.UTF_8)
            val buf = arena.allocate(bytes.size.toLong())
            MemorySegment.copy(bytes, 0, buf, JAVA_BYTE, 0, bytes.size)
            LuaNative.pushLString(s, buf, bytes.size.toLong())
        }

val PRELUDE: String by lazy

The Lua half of Field's API, run once in every state. Everything a box sees is defined here in Lua over the raw Kotlin handlers, which it takes from __field_raw and then hides.

        val PRELUDE: String by lazy {
            LuaState::class.java.getResource("/afield/lua/prelude.lua")?.readText()
                ?: error("afield/lua/prelude.lua is missing from the classpath")
        }
    }
}