From b29dd1159f02890a16010222aab091759b5eaa81 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sun, 28 Jun 2026 01:56:38 +0300 Subject: [PATCH 01/14] mc.task/mc.run for parallel tasks in Lua, a fea easter eggs --- log4j-dev.xml | 2 +- .../java/org/luaj/vm2/FrameInterpreter.java | 16 +- .../src/main/java/org/luaj/vm2/LuaState.java | 34 ++-- .../src/main/java/org/luaj/vm2/LuaThread.java | 24 ++- .../java/org/luaj/vm2/lib/jse/NovaLib.java | 3 + .../vm2/lib/jse/SyncCompiledFunction.java | 16 ++ .../org/luaj/vm2/JavaFunctionYieldTest.java | 154 ++++++++++++++++++ .../java/ru/pyxiion/ignis/easter/IntroArt.kt | 130 +++++++++++++++ .../ru/pyxiion/ignis/easter/ScriptCounter.kt | 32 ++++ 9 files changed, 382 insertions(+), 29 deletions(-) create mode 100644 pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java create mode 100644 src/main/java/ru/pyxiion/ignis/easter/IntroArt.kt create mode 100644 src/main/java/ru/pyxiion/ignis/easter/ScriptCounter.kt diff --git a/log4j-dev.xml b/log4j-dev.xml index 5457434..6ed57f1 100644 --- a/log4j-dev.xml +++ b/log4j-dev.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java index 46417bc..7f62076 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java @@ -21,7 +21,8 @@ static Varargs run(LuaThread.State s) { int cc = (ci >> 14) & 0x1ff; Varargs ra = s.resumeArgs; if (cc > 0) { - ra.copyto(frame.stack, ca, cc - 1); + // FIXME: idk it should be cc - 1 or just cc + ra.copyto(frame.stack, ca, cc); frame.v = LuaValue.NONE; } else { frame.top = ca + ra.narg(); @@ -367,6 +368,15 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { frame.top - frame.v.narg() - (a + 1), frame.v); { LuaValue tfunc = stack[a]; + if (tfunc == s.yieldSentinel) { + if (state != null && state.isInJavaCall()) + throw new LuaError("attempt to yield across a C-call boundary"); + s.result = tcArgs; + s.status = LuaThread.STATUS_SUSPENDED; + s.yieldRequested = true; + frame.pc--; + return false; + } if (tfunc instanceof LuaClosure lc) { LuaValue[] newStack = new LuaValue[lc.p.maxstacksize]; System.arraycopy(LuaValue.NILS, 0, newStack, 0, lc.p.maxstacksize); @@ -392,6 +402,10 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { return true; } Varargs tcResult = tfunc.invoke(tcArgs); + if (s.yieldRequested && !s.yieldIsInterrupt) { + frame.pc--; + return false; + } if (tcResult.isTailcall()) { TailcallVarargs tv = (TailcallVarargs) tcResult; tcResult = tv.eval(); diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java index 3dd2c2d..f83c4b2 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java @@ -179,7 +179,7 @@ public synchronized void reset() throws IOException { int javaCallDepth = 0; - volatile LuaThread currentThread; + private final ThreadLocal currentThread = new ThreadLocal<>(); private final LuaThread mainThread; public final LuaTable globals; @@ -212,7 +212,8 @@ private LuaState(Builder builder) { globals = new LuaTable(); globals.set("_G", globals); globals.set("_VERSION", Lua._VERSION); - mainThread = currentThread = new LuaThread(this); + mainThread = new LuaThread(this); + currentThread.set(mainThread); setCurrent(this); } @@ -225,11 +226,12 @@ public LuaThread getMainThread() { } public LuaThread getCurrentThread() { - return currentThread; + return currentThread.get(); } void setCurrentThread(LuaThread thread) { - currentThread = thread; + if (thread == null) currentThread.remove(); + else currentThread.set(thread); } public void interrupt() { @@ -246,13 +248,14 @@ public void handleInterrupt() throws LuaError { switch (interruptHandler.interrupted()) { case CONTINUE -> {} case SUSPEND -> { - if (currentThread == null || currentThread.threadState.status != LuaThread.STATUS_RUNNING) { + LuaThread ct = getCurrentThread(); + if (ct == null || ct.threadState.status != LuaThread.STATUS_RUNNING) { throw new IllegalStateException("Cannot suspend non-running coroutine"); } - if (currentThread.isMainThread()) + if (ct.isMainThread()) throw new LuaError("cannot yield main thread"); - currentThread.threadState.yieldIsInterrupt = true; - currentThread.threadState.lua_yield_sync(LuaValue.NONE); + ct.threadState.yieldIsInterrupt = true; + ct.threadState.lua_yield_sync(LuaValue.NONE); } } } @@ -274,14 +277,16 @@ public void leavingJavaCall() { } public void enterSyncCompiled() { - if (currentThread != null && !currentThread.isMainThread()) { - currentThread.threadState.syncCompiledDepth++; + LuaThread ct = getCurrentThread(); + if (ct != null && !ct.isMainThread()) { + ct.threadState.syncCompiledDepth++; } } public void leaveSyncCompiled() { - if (currentThread != null && !currentThread.isMainThread()) { - currentThread.threadState.syncCompiledDepth--; + LuaThread ct = getCurrentThread(); + if (ct != null && !ct.isMainThread()) { + ct.threadState.syncCompiledDepth--; } } @@ -358,9 +363,10 @@ public Prototype compilePrototype(InputStream stream, String chunkname) throws I } public Varargs yield(Varargs args) { - if (currentThread == null || currentThread.isMainThread()) + LuaThread ct = getCurrentThread(); + if (ct == null || ct.isMainThread()) throw new LuaError("cannot yield main thread"); - return currentThread.threadState.lua_yield_sync(args); + return ct.threadState.lua_yield_sync(args); } public static Builder builder() { diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java index 03100d8..ed83297 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java @@ -294,9 +294,9 @@ public void run() { public Varargs lua_resume(LuaThread new_thread, Varargs args) { getLock().lock(); try { - LuaThread previous_thread = state.currentThread; + LuaThread previous_thread = state.getCurrentThread(); try { - state.currentThread = new_thread; + state.setCurrentThread(new_thread); this.args = args; if (this.status == STATUS_INITIAL) { this.status = STATUS_RUNNING; @@ -308,11 +308,12 @@ public Varargs lua_resume(LuaThread new_thread, Varargs args) { } else { getCondition().signal(); } - if (previous_thread != null) + if (previous_thread != null && previous_thread != new_thread + && previous_thread.threadState.status == STATUS_RUNNING) previous_thread.threadState.status = STATUS_NORMAL; this.status = STATUS_RUNNING; getCondition().await(); - return (this.error != null? + return (this.error != null? LuaValue.varargsOf(LuaValue.FALSE, LuaValue.valueOf(this.error)): LuaValue.varargsOf(LuaValue.TRUE, this.result)); } catch (InterruptedException ie) { @@ -321,9 +322,7 @@ public Varargs lua_resume(LuaThread new_thread, Varargs args) { this.args = LuaValue.NONE; this.result = LuaValue.NONE; this.error = null; - state.currentThread = previous_thread; - if (previous_thread != null) - state.currentThread = previous_thread; + state.setCurrentThread(previous_thread); } } finally { getLock().unlock(); @@ -369,10 +368,11 @@ public Varargs lua_yield_sync(Varargs args) { } public Varargs lua_resume_sync(LuaThread new_thread, Varargs args) { - LuaThread previous_thread = state.currentThread; + LuaThread previous_thread = state.getCurrentThread(); try { - state.currentThread = new_thread; - if (previous_thread != null) + state.setCurrentThread(new_thread); + if (previous_thread != null && previous_thread != new_thread + && previous_thread.threadState.status == STATUS_RUNNING) previous_thread.threadState.status = STATUS_NORMAL; this.status = STATUS_RUNNING; @@ -433,9 +433,7 @@ public Varargs lua_resume_sync(LuaThread new_thread, Varargs args) { state.javaCallDepth = savedJavaCallDepth; } } finally { - state.currentThread = previous_thread; - if (previous_thread != null) - state.currentThread = previous_thread; + state.setCurrentThread(previous_thread); this.args = LuaValue.NONE; this.result = LuaValue.NONE; this.error = null; diff --git a/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/NovaLib.java b/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/NovaLib.java index 46a9f90..c823664 100644 --- a/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/NovaLib.java +++ b/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/NovaLib.java @@ -27,6 +27,9 @@ public LuaValue call(LuaValue arg) { throw new LuaError("nova.sync: expected function (LuaClosure), got " + arg.typename()); } LuaState state = LuaState.current(); + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaFunction compiled = compiler.compile(closure); return new SyncCompiledFunction(state, compiled); } diff --git a/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/SyncCompiledFunction.java b/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/SyncCompiledFunction.java index 4eb622e..d4e3cb6 100644 --- a/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/SyncCompiledFunction.java +++ b/pxluanova/pxluanova-jse/src/main/java/org/luaj/vm2/lib/jse/SyncCompiledFunction.java @@ -1,5 +1,6 @@ package org.luaj.vm2.lib.jse; +import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaFunction; import org.luaj.vm2.LuaState; import org.luaj.vm2.LuaValue; @@ -17,6 +18,9 @@ class SyncCompiledFunction extends VarArgFunction { } public Varargs invoke(Varargs args) { + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaState currentState = LuaState.current(); if (currentState == state) { state.enterSyncCompiled(); @@ -30,6 +34,9 @@ public Varargs invoke(Varargs args) { } public LuaValue call() { + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaState currentState = LuaState.current(); if (currentState == state) { state.enterSyncCompiled(); @@ -43,6 +50,9 @@ public LuaValue call() { } public LuaValue call(LuaValue a) { + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaState currentState = LuaState.current(); if (currentState == state) { state.enterSyncCompiled(); @@ -56,6 +66,9 @@ public LuaValue call(LuaValue a) { } public LuaValue call(LuaValue a, LuaValue b) { + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaState currentState = LuaState.current(); if (currentState == state) { state.enterSyncCompiled(); @@ -69,6 +82,9 @@ public LuaValue call(LuaValue a, LuaValue b) { } public LuaValue call(LuaValue a, LuaValue b, LuaValue c) { + if (state == null) { + throw new LuaError("nova.sync must be called on the main server thread,"); + } LuaState currentState = LuaState.current(); if (currentState == state) { state.enterSyncCompiled(); diff --git a/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java new file mode 100644 index 0000000..f16f4ab --- /dev/null +++ b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java @@ -0,0 +1,154 @@ +package org.luaj.vm2; + +import junit.framework.TestCase; + +import org.luaj.vm2.lib.VarArgFunction; +import org.luaj.vm2.lib.jse.JsePlatform; + +/** + * Tests that a Java function (VarArgFunction) can call state.yield() from inside + * a Lua coroutine and have resume values propagate through correctly. + * + * Regression test for the FrameInterpreter.run() resume-handler bug where + * a fresh lua_yield_sync (with resumeArgs == NONE) was mistaken for a resume. + */ +public class JavaFunctionYieldTest extends TestCase { + + LuaState state; + + protected void setUp() throws Exception { + state = JsePlatform.standardState(); + } + + /** + * A Java function that yields and returns the resume value. + */ + private static class YieldAndReturnFunction extends VarArgFunction { + final LuaState state; + YieldAndReturnFunction(LuaState state) { this.state = state; } + + public Varargs invoke(Varargs args) { + Varargs resumeArgs = state.yield(args.arg1()); + return resumeArgs; + } + } + + /** + * A Java function that yields with a value and returns whatever is passed + * on resume. The "initial" yield value is visible to the first resumer. + */ + private static class YieldAndSwapFunction extends VarArgFunction { + final LuaState state; + YieldAndSwapFunction(LuaState state) { this.state = state; } + + public Varargs invoke(Varargs args) { + state.yield(LuaValue.valueOf("yielded")); + return LuaValue.valueOf("done"); + } + } + + public void testJavaFunctionYieldAndResumeReturnsValue() { + // Register the Java function in the environment + LuaValue yieldFn = new YieldAndReturnFunction(state); + state.globals.set("yield_fn", yieldFn); + + // Lua coroutine that calls yield_fn with an argument, then returns the result + LuaValue func = state.load( + "local result = yield_fn('call_arg')\n" + + "return result", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + + // First resume — the Java function yields + Varargs result = co.resume(LuaValue.NONE); + assertTrue("expected resume success", result.arg1().toboolean()); + // The yield value is 'call_arg' (the arg passed to yield_fn) + assertEquals("call_arg", result.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + + // Second resume — the Java function receives the resume arg and returns it + result = co.resume(LuaValue.valueOf("resume_val")); + assertTrue("expected resume success", result.arg1().toboolean()); + // The Java function returned the resume val; Lua returns it + assertEquals("resume_val", result.arg(2).tojstring()); + assertEquals("dead", co.getStatus()); + } + + public void testJavaFunctionYieldWithValue() { + // Register the Java function + LuaValue yieldFn = new YieldAndSwapFunction(state); + state.globals.set("yield_fn", yieldFn); + + LuaValue func = state.load( + "local result = yield_fn('x')\n" + + "return result", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + + // First resume — Java function yields with "yielded" + Varargs result = co.resume(LuaValue.NONE); + assertTrue(result.arg1().toboolean()); + assertEquals("yielded", result.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + + // Second resume — the resume values become the return of the call site, + // taking precedence over the Java function's explicit return value. + result = co.resume(LuaValue.valueOf("ignored")); + assertTrue(result.arg1().toboolean()); + assertEquals("ignored", result.arg(2).tojstring()); + assertEquals("dead", co.getStatus()); + } + + /** + * A Java function that yields once per invocation. + * A single invoke() that calls state.yield multiple times is not supported + * (lua_yield_sync sets a flag but doesn't truly suspend), so this test + * exercises multi-yield at the Lua level: Lua calls the same Java function + * multiple times, each call yields once. + */ + private static class SingleYieldFunction extends VarArgFunction { + final LuaState state; + SingleYieldFunction(LuaState state) { this.state = state; } + + public Varargs invoke(Varargs args) { + return state.yield(LuaValue.valueOf("yield_val")); + } + } + + public void testJavaFunctionMultipleYieldsViaLuaCalls() { + LuaValue yieldFn = new SingleYieldFunction(state); + state.globals.set("yield_fn", yieldFn); + + LuaValue func = state.load( + "local r1 = yield_fn()\n" + + "local r2 = yield_fn()\n" + + "return r1, r2", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + + // First call to yield_fn: yields + Varargs result = co.resume(LuaValue.NONE); + assertTrue(result.arg1().toboolean()); + assertEquals("yield_val", result.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + + // Second call to yield_fn: yields + result = co.resume(LuaValue.NONE); + assertTrue(result.arg1().toboolean()); + assertEquals("yield_val", result.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + + // After both calls complete, Lua returns r1, r2 + result = co.resume(LuaValue.NONE); + assertTrue(result.arg1().toboolean()); + assertEquals("yield_val", result.arg(2).tojstring()); + assertEquals("yield_val", result.arg(3).tojstring()); + assertEquals("dead", co.getStatus()); + } + + public void testJavaFunctionYieldDoesNotCrashMainThread() { + try { + state.yield(LuaValue.NONE); + fail("Expected LuaError"); + } catch (LuaError e) { + assertTrue(e.getMessage().contains("cannot yield main thread")); + } + } +} diff --git a/src/main/java/ru/pyxiion/ignis/easter/IntroArt.kt b/src/main/java/ru/pyxiion/ignis/easter/IntroArt.kt new file mode 100644 index 0000000..12990b7 --- /dev/null +++ b/src/main/java/ru/pyxiion/ignis/easter/IntroArt.kt @@ -0,0 +1,130 @@ +package ru.pyxiion.ignis + +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.Month + +val taglines = listOf( + "Enjoy scripting!", + "Have a nice day!", + "I work completely fine!", + "Send help", + "Ignis est vita", + "\uD83D\uDD25\uD83D\uDD25\uD83D\uDD25", + "Powered by Lua\u2122", + "Probably not buggy", + "/ignis reload fixes everything", + "Built with \u2764 and Lua", + "PxLuaNova?", + "I wanna Luau types", + "Try self:heal(100)", + "NeoForge port when?", + "Paper port when?", + "Try Mappet - JS scripting Minecraft (1.12.2 sadly)", + "I didn't measure, but I must be faster than Skript (i hope)", + "Have you seen ugly JS promises? I use only green threads.", + "Сделано в России", + "// TODO: write better taglines", + "Do not global variables", + "No bugs, just undocumented features", + "Test only in production", + "Lua: why do arrays start at 1, i wanna cry", + "Fabric >>> Paper. Change my mind.", + "stack trace: you're here", + "yield() is my drug", + "Uncaught Exception: player is too creative", + "Don't worry, the GC (Garbage Collector) will clean that up... eventually", + "Are you a wizard of Lua?", + "Metatable? More like metababble", + "if 0 then programmer:cry() end", + + "За решеткой есть жизнь и на кладбище есть плюсы", // C#/C++ + "Отладка даёт представление о вечности", + + "Papa can into C", + "Dotnet or Java? No thanks, merci!", + "Python or Ruby? Lord, save us!", + "C will never die. Re-firmware it!", + "Only pure C according to the old school precepts" +) + +fun getTagline(): String { + val date = LocalDateTime.now() + val month = date.month + val day = date.dayOfMonth + val yearDay = date.dayOfYear + val weekDay = date.dayOfWeek + val hour = date.hour + + // Idk, just added random taglines for specific dates + return when { + hour == 3 -> "Coding scripts at 3 AM hits different" + + yearDay == 256 -> "Happy Programmer's Day! Keep coding! \uD83D\uDCBB" + + month == Month.JANUARY && day == 1 -> "Happy New Year! \uD83C\uDF86" + month == Month.FEBRUARY && day == 23 -> "Happy Defender of the Fatherland Day!" + month == Month.MARCH && day == 8 -> "Happy International Women's Day! \uD83C\uDF39" + month == Month.MARCH && day == 14 -> "Happy Pi Day! 3.14159..." + month == Month.APRIL && day == 1 -> "This tagline is a lie." + month == Month.MAY && day == 9 -> "Happy Victory Day! \uD83C\uDF3A" + month == Month.OCTOBER && day == 31 -> "Trick or treat! \uD83C\uDF83" + month == Month.DECEMBER && day == 31 -> "Get ready for New Year! \uD83C\uDF87" + weekDay == DayOfWeek.FRIDAY && day == 13 -> "Friday 13th. Stay safe. \uD83D\uDC7B" + + else -> taglines.random() + } +} + +fun introArt(version: String): String = buildString { + val R = "\u001B[0m" + val A = "\u001B[48;5;214m\u001B[30m" + val O = "\u001B[48;5;208m\u001B[97m" + val Y = "\u001B[48;5;228m\u001B[30m" + val D = "\u001B[48;5;202m\u001B[97m" + val C = "\u001B[38;5;220m" + val V = "\u001B[2m\u001B[38;5;244m" + val G = "\u001B[38;5;34m" + val E = "\u001B[38;5;81m" + + val ansi = Regex("\u001B\\[[;0-9]*[mK]") + + fun StringBuilder.addAligned(flame: String, text: String, align: Int = 24) { + val visible = flame.replace(ansi, "").length + append(flame) + repeat((align - visible).coerceAtLeast(1)) { append(' ') } + append(text) + appendLine() + } + + val isRussiaDay = System.getProperty("pxignis.russiaday")?.toBoolean() ?: run { + val cal = java.util.Calendar.getInstance() + cal[java.util.Calendar.MONTH] == java.util.Calendar.JUNE && cal[java.util.Calendar.DAY_OF_MONTH] == 12 + } + + appendLine() + if (isRussiaDay) { + val W = "\u001B[48;5;255m\u001B[30m" + val B = "\u001B[48;5;27m\u001B[97m" + val Rd = "\u001B[48;5;196m\u001B[97m" + addAligned(" $W $R", "") + addAligned(" $W $R", "") + addAligned(" $W $R", "$C PxIgnis$R") + addAligned(" $B $R", "$V v$version$R") + addAligned(" $B $B $B $R", "$G Modrinth: https://modrinth.com/mod/pxignis$R") + addAligned(" $B $B $B $R", " GitHub: https://github.com/PyXiion/PxIgnis$R") + addAligned(" $Rd $Rd $Rd $R", "$W Happy$B Russia$Rd day!$R") + addAligned(" $Rd $R", "") + addAligned(" $Rd $R", "") + } else { + addAligned(" $A $R", "") + addAligned(" $A $R", "") + addAligned(" $A $R", "$C PxIgnis$R") + addAligned(" $A $R", "$V v$version$R") + addAligned(" $A $Y $O $R", "$G Modrinth: https://modrinth.com/mod/pxignis$R") + addAligned(" $A $Y $O $R", " GitHub: https://github.com/PyXiion/PxIgnis$R") + addAligned(" $D $Y $O $R", "$E ${getTagline()}$R") + addAligned(" $D $R", "") + addAligned(" $D $R", "") + } +} diff --git a/src/main/java/ru/pyxiion/ignis/easter/ScriptCounter.kt b/src/main/java/ru/pyxiion/ignis/easter/ScriptCounter.kt new file mode 100644 index 0000000..bc3d025 --- /dev/null +++ b/src/main/java/ru/pyxiion/ignis/easter/ScriptCounter.kt @@ -0,0 +1,32 @@ +package ru.pyxiion.ignis.easter + +object ScriptCounter { + private val milestones = listOf(1, 10, 25, 50, 100, 250, 500) + var count = 0 + private set + var lastMilestone = 0 + private set + + fun reset() { + count = 0 + } + + fun add(n: Int) { + count += n + } + + fun pollMessage(): String? { + val threshold = milestones.lastOrNull { count >= it && it > lastMilestone } ?: return null + lastMilestone = threshold + return when (threshold) { + 1 -> "First script loaded! Let the chaos begin!" + 10 -> "OMG YOU LOADED $count SCRIPTS! I AM VERY IMPRESSED!" + 25 -> "$count scripts! Is this a modpack yet?" + 50 -> "$count SCRIPTS! I WORK COMPLETELY FINE!" + 100 -> "$count scripts! I have no idea what I'm doing." + 250 -> "$count scripts. We're in too deep." + 500 -> "$count SCRIPTS! Someone call the fire department!" + else -> "$threshold scripts loaded!" + } + } +} From 2edb233cffd3834014821d583b22bee0cd8f4beb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sun, 28 Jun 2026 02:07:15 +0300 Subject: [PATCH 02/14] build: add client source set for client-side rendering - Add loom.splitEnvironmentSourceSets() and a second 'pxignis' mod entry pointing at sourceSets.client in build.gradle. - Create src/client/java/ and src/version-*/client-kotlin/ for client code. - Wire shadowJar to include both source set outputs and drop minimize() (it stripped PxIgnisClient since the main entrypoint doesn't reference it). - Register client entrypoint ru.pyxiion.ignis.client.PxIgnisClient in fabric.mod.json. - Add PxIgnisClient.kt entrypoint (logs 'loaded') and ClientCompat.kt stubs for 1.21.10 and 1.21.11. --- build.gradle | 18 +++++++++++++++--- .../ru/pyxiion/ignis/client/PxIgnisClient.kt | 15 +++++++++++++++ src/main/resources/fabric.mod.json | 6 ++++++ .../ru/pyxiion/ignis/client/ClientCompat.kt | 10 ++++++++++ .../ru/pyxiion/ignis/client/ClientCompat.kt | 9 +++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt create mode 100644 src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt create mode 100644 src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt diff --git a/build.gradle b/build.gradle index f10d83a..614239f 100644 --- a/build.gradle +++ b/build.gradle @@ -37,10 +37,15 @@ base { loom { accessWidenerPath = file("src/main/resources/pxignis.accesswidener") + splitEnvironmentSourceSets() + mods { "pxignis" { sourceSet sourceSets.main } + "pxignis" { + sourceSet sourceSets.client + } } log4jConfigs.from "log4j-dev.xml" @@ -83,7 +88,8 @@ test { shadowJar { configurations = [project.configurations.shadow] - minimize() + from sourceSets.main.output + from sourceSets.client.output relocate 'org.luaj', 'ru.pyxiion.luanova' relocate 'me.lucko.fabric.api.permissions', 'ru.pyxiion.lib.fabric.api.permissions' @@ -97,8 +103,14 @@ remapJar { archiveClassifier.set null } -// Version-specific source overrides (src/version-${buildTarget}/kotlin takes priority) -sourceSets.main.kotlin.srcDirs = ["src/version-${buildTarget}/kotlin", "src/main/java"] +sourceSets { + main { + kotlin.srcDirs = ["src/version-${buildTarget}/kotlin", "src/main/java"] + } + client { + kotlin.srcDirs = ["src/version-${buildTarget}/client-kotlin", "src/client/java"] + } +} processResources { inputs.property "version", project.version diff --git a/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt new file mode 100644 index 0000000..b95308f --- /dev/null +++ b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt @@ -0,0 +1,15 @@ +package ru.pyxiion.ignis.client + +import net.fabricmc.api.ClientModInitializer +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class PxIgnisClient : ClientModInitializer { + companion object { + private val logger: Logger = LoggerFactory.getLogger("pxignis-client") + } + + override fun onInitializeClient() { + logger.info("PxIgnis client loaded") + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 58a50bc..7239993 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -27,6 +27,12 @@ "adapter": "kotlin", "value": "ru.pyxiion.ignis.PxIgnis" } + ], + "client": [ + { + "adapter": "kotlin", + "value": "ru.pyxiion.ignis.client.PxIgnisClient" + } ] }, "depends": { diff --git a/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt b/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt new file mode 100644 index 0000000..7cd5cc6 --- /dev/null +++ b/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -0,0 +1,10 @@ +package ru.pyxiion.ignis.client + +object ClientCompat { + fun drawWireframeBoxes(boxes: Collection) { + throw UnsupportedOperationException( + "Client-side region wireframe rendering is not yet implemented. " + + "Use the server-installed mod on 1.21.11 for client-side visualization." + ) + } +} diff --git a/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt b/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt new file mode 100644 index 0000000..eecb68a --- /dev/null +++ b/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -0,0 +1,9 @@ +package ru.pyxiion.ignis.client + +object ClientCompat { + fun drawWireframeBoxes(boxes: Collection) { + throw UnsupportedOperationException( + "Client-side region wireframe rendering is not yet implemented." + ) + } +} From 0ae8973da2992166e0fb5b864d1c222f53dbbea2 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sun, 28 Jun 2026 02:25:14 +0300 Subject: [PATCH 03/14] feat: ops-only region debug overlay (Phase 3: networking + manager + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side (src/main/java): - Add RegionPackets.kt: 5 custom payloads sharing the pxignis:regions id — RegionSyncPayload (full snapshot), RegionUpsertPayload / RegionRemovePayload (incremental diff), RegionCapWarningPayload (one-time toast), and the C2S RegionInterestPayload. Each payload uses a per-class typed CustomPayload.Id and a hand-rolled Box codec (6 doubles). - Region.kt: add REGION_INTEREST_RADIUS_CHUNKS=4 and MAX_REGIONS_PER_PLAYER=256 constants; opt-in state (optInPlayers, lastSnapshots, warnedPlayers) and the per-tick tickClientSync that walks regionsByChunk within the player's 4-chunk radius, diffs against lastSnapshots via the pure diffRegionSnapshots, sends upserts/removes, and fires the cap warning once per opt-in. Pure diffRegionSnapshots treats bound changes as upserts to fix the stale-AABB bug. New setOptedIn / onPlayerLeft / closeAll(server) lifecycle methods. - PxIgnis.kt: register all 4 S2C + 1 C2S payload types, gate the C2S handler with Compat.isAdmin (server-authoritative), call RegionManager.tickClientSync in END_SERVER_TICK, RegionManager.onPlayerLeft in DISCONNECT, and RegionManager.closeAll in SERVER_STOPPED. Client-side (src/client/java): - ClientRegionRegistry: thread-safe ConcurrentHashMap with upsert/remove/replaceAll/clear; var enabled default false (off on join). - ClientCommands: registers /ignis debug regions via ClientCommandRegistrationCallback; toggles registry.enabled, sends RegionInterestPayload. - PxIgnisClient: registers 4 S2C receivers, hooks WorldRenderEvents.BEFORE_DEBUG_RENDER (runCatching the 1.21.10 stub so the unsupported stub doesn't log-spam every frame), and registers the client command. Tests (src/test/kotlin): - RegionInterestDiffTest: 6 pure-logic JUnit 5 tests covering empty/empty, add, remove, bounds-change-as-upsert, unbounded-when-under-cap, and capped-when-over-limit. No MC runtime. Both ./gradlew build (1.21.11) and ./gradlew build -PtargetVersion=1.21.10 pass; all 93 unit tests pass. Wireframe rendering (drawWireframeBoxes) remains a stub pending Phase 4. --- .../ru/pyxiion/ignis/client/ClientCommands.kt | 49 +++++++ .../ignis/client/ClientRegionRegistry.kt | 40 ++++++ .../ru/pyxiion/ignis/client/PxIgnisClient.kt | 33 +++++ src/main/java/ru/pyxiion/ignis/PxIgnis.kt | 29 ++++ .../ru/pyxiion/ignis/api/manager/Region.kt | 126 ++++++++++++++++++ .../ru/pyxiion/ignis/network/RegionPackets.kt | 111 +++++++++++++++ .../pyxiion/ignis/RegionInterestDiffTest.kt | 92 +++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 src/client/java/ru/pyxiion/ignis/client/ClientCommands.kt create mode 100644 src/client/java/ru/pyxiion/ignis/client/ClientRegionRegistry.kt create mode 100644 src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt create mode 100644 src/test/kotlin/ru/pyxiion/ignis/RegionInterestDiffTest.kt diff --git a/src/client/java/ru/pyxiion/ignis/client/ClientCommands.kt b/src/client/java/ru/pyxiion/ignis/client/ClientCommands.kt new file mode 100644 index 0000000..19f12eb --- /dev/null +++ b/src/client/java/ru/pyxiion/ignis/client/ClientCommands.kt @@ -0,0 +1,49 @@ +package ru.pyxiion.ignis.client + +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.context.CommandContext +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.minecraft.command.CommandRegistryAccess +import net.minecraft.text.Text +import ru.pyxiion.ignis.network.RegionInterestPayload + +object ClientCommands { + fun register() { + ClientCommandRegistrationCallback.EVENT.register(ClientCommands::registerCallback) + } + + private fun registerCallback( + dispatcher: CommandDispatcher, + registryAccess: CommandRegistryAccess + ) { + dispatcher.register( + ClientCommandManager.literal("ignis") + .then( + ClientCommandManager.literal("debug") + .then( + ClientCommandManager.literal("regions") + .executes(::toggleRegions) + ) + ) + ) + } + + private fun toggleRegions(context: CommandContext): Int { + val source = context.source + val player = source.player ?: return 0 + + val newValue = !ClientRegionRegistry.enabled + ClientRegionRegistry.setEnabled(newValue) + if (!newValue) { + ClientRegionRegistry.clear() + } + ClientPlayNetworking.send(RegionInterestPayload(newValue)) + source.sendFeedback( + Text.literal("Region debug overlay: ${if (newValue) "on" else "off"}") + ) + return 1 + } +} diff --git a/src/client/java/ru/pyxiion/ignis/client/ClientRegionRegistry.kt b/src/client/java/ru/pyxiion/ignis/client/ClientRegionRegistry.kt new file mode 100644 index 0000000..5ce6e8c --- /dev/null +++ b/src/client/java/ru/pyxiion/ignis/client/ClientRegionRegistry.kt @@ -0,0 +1,40 @@ +package ru.pyxiion.ignis.client + +import net.minecraft.util.math.Box +import ru.pyxiion.ignis.network.RegionEntry +import java.util.concurrent.ConcurrentHashMap + +object ClientRegionRegistry { + private val regions: ConcurrentHashMap = ConcurrentHashMap() + + @Volatile + var enabled: Boolean = false + private set + + fun setEnabled(value: Boolean) { + enabled = value + } + + fun upsert(id: Int, box: Box) { + regions[id] = box + } + + fun remove(id: Int) { + regions.remove(id) + } + + fun replaceAll(entries: List) { + regions.clear() + for (entry in entries) { + regions[entry.id] = entry.box + } + } + + fun snapshot(): Map = regions.toMap() + + fun values(): Collection = regions.values + + fun clear() { + regions.clear() + } +} diff --git a/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt index b95308f..085c55d 100644 --- a/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt +++ b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt @@ -1,8 +1,15 @@ package ru.pyxiion.ignis.client import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking +import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderEvents +import net.minecraft.text.Text import org.slf4j.Logger import org.slf4j.LoggerFactory +import ru.pyxiion.ignis.network.RegionCapWarningPayload +import ru.pyxiion.ignis.network.RegionRemovePayload +import ru.pyxiion.ignis.network.RegionSyncPayload +import ru.pyxiion.ignis.network.RegionUpsertPayload class PxIgnisClient : ClientModInitializer { companion object { @@ -11,5 +18,31 @@ class PxIgnisClient : ClientModInitializer { override fun onInitializeClient() { logger.info("PxIgnis client loaded") + + ClientPlayNetworking.registerGlobalReceiver(RegionSyncPayload.ID) { payload, _ -> + ClientRegionRegistry.replaceAll(payload.entries) + } + ClientPlayNetworking.registerGlobalReceiver(RegionUpsertPayload.ID) { payload, _ -> + ClientRegionRegistry.upsert(payload.id, payload.region) + } + ClientPlayNetworking.registerGlobalReceiver(RegionRemovePayload.ID) { payload, _ -> + ClientRegionRegistry.remove(payload.id) + } + ClientPlayNetworking.registerGlobalReceiver(RegionCapWarningPayload.ID) { payload, ctx -> + val player = ctx.client().player ?: return@registerGlobalReceiver + player.sendMessage( + Text.literal("Region cap (${payload.cap}) reached; some regions are not rendered."), + false + ) + } + + WorldRenderEvents.BEFORE_DEBUG_RENDER.register { _ -> + if (!ClientRegionRegistry.enabled) return@register + runCatching { + ClientCompat.drawWireframeBoxes(ClientRegionRegistry.values()) + }.onFailure { logger.warn("region wireframe render failed: ${it.message}") } + } + + ClientCommands.register() } } diff --git a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt index 160426d..e1903e4 100644 --- a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt +++ b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt @@ -11,7 +11,9 @@ import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents import net.fabricmc.fabric.api.event.player.* import net.fabricmc.fabric.api.message.v1.ServerMessageEvents +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking import net.fabricmc.loader.api.FabricLoader import net.minecraft.item.BlockItem import net.minecraft.registry.Registries @@ -32,6 +34,11 @@ import ru.pyxiion.ignis.api.manager.SidebarManager import ru.pyxiion.ignis.api.wrapper.EntityFactory import ru.pyxiion.ignis.api.wrapper.ItemStackWrap import ru.pyxiion.ignis.api.wrapper.PlayerWrap +import ru.pyxiion.ignis.network.RegionCapWarningPayload +import ru.pyxiion.ignis.network.RegionInterestPayload +import ru.pyxiion.ignis.network.RegionRemovePayload +import ru.pyxiion.ignis.network.RegionSyncPayload +import ru.pyxiion.ignis.network.RegionUpsertPayload import ru.pyxiion.ignis.storage.JsonBackend import ru.pyxiion.ignis.storage.StorageManager @@ -50,6 +57,22 @@ class PxIgnis : ModInitializer { override fun onInitialize() { instance = this + + PayloadTypeRegistry.playS2C().register(RegionSyncPayload.ID, RegionSyncPayload.CODEC) + PayloadTypeRegistry.playS2C().register(RegionUpsertPayload.ID, RegionUpsertPayload.CODEC) + PayloadTypeRegistry.playS2C().register(RegionRemovePayload.ID, RegionRemovePayload.CODEC) + PayloadTypeRegistry.playS2C().register(RegionCapWarningPayload.ID, RegionCapWarningPayload.CODEC) + PayloadTypeRegistry.playC2S().register(RegionInterestPayload.ID, RegionInterestPayload.CODEC) + + ServerPlayNetworking.registerGlobalReceiver(RegionInterestPayload.ID) { payload, ctx -> + val player = ctx.player() + if (!Compat.isAdmin(player)) { + logger.debug("rejected region-interest from non-admin {}", player.name.string) + return@registerGlobalReceiver + } + RegionManager.setOptedIn(player.uuid, payload.enabled) + } + ServerLifecycleEvents.SERVER_STARTED.register(fun(server) { try { val storagePath = FabricLoader.getInstance().configDir.resolve("ignis/storage") @@ -84,6 +107,10 @@ class PxIgnis : ModInitializer { storageManager?.close() }) + ServerLifecycleEvents.SERVER_STOPPED.register(fun(server) { + RegionManager.closeAll(server) + }) + ServerTickEvents.END_SERVER_TICK.register(fun(server) { if (::runtime.isInitialized) { runtime.scheduler.tick() @@ -93,6 +120,7 @@ class PxIgnis : ModInitializer { em.tick() } RegionManager.tick() + RegionManager.tickClientSync(server) } }) @@ -144,6 +172,7 @@ class PxIgnis : ModInitializer { storageManager?.removePlayerData(handler.player.uuid.toString()) SidebarManager.removeForPlayer(handler.player) MobAIManager.mobWrappers.remove(handler.player.uuid) + RegionManager.onPlayerLeft(handler.player.uuid) }) ServerLivingEntityEvents.ALLOW_DEATH.register { entity, source, amount -> diff --git a/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt b/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt index 739b3b6..6b440b3 100644 --- a/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt +++ b/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt @@ -1,6 +1,8 @@ package ru.pyxiion.ignis.api.manager +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking import net.minecraft.entity.Entity +import net.minecraft.server.MinecraftServer import net.minecraft.server.network.ServerPlayerEntity import net.minecraft.server.world.ServerWorld import net.minecraft.util.math.Box @@ -12,7 +14,23 @@ import ru.pyxiion.ignis.EventBus import ru.pyxiion.ignis.PxIgnis import ru.pyxiion.ignis.api.wrapper.EntityFactory import ru.pyxiion.ignis.api.Vector +import ru.pyxiion.ignis.network.RegionCapWarningPayload +import ru.pyxiion.ignis.network.RegionEntry +import ru.pyxiion.ignis.network.RegionInterestPayload +import ru.pyxiion.ignis.network.RegionRemovePayload +import ru.pyxiion.ignis.network.RegionSyncPayload +import ru.pyxiion.ignis.network.RegionUpsertPayload import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +const val REGION_INTEREST_RADIUS_CHUNKS: Int = 4 +const val MAX_REGIONS_PER_PLAYER: Int = 256 + +data class RegionDiff( + val additions: List, + val removals: List, + val capped: Boolean +) class Region internal constructor( val id: Int, @@ -169,6 +187,114 @@ object RegionManager { } } + private val optInPlayers: MutableSet = ConcurrentHashMap.newKeySet() + private val lastSnapshots: MutableMap> = ConcurrentHashMap() + private val warnedPlayers: MutableSet = ConcurrentHashMap.newKeySet() + + fun isOptedIn(uuid: UUID): Boolean = uuid in optInPlayers + + fun setOptedIn(uuid: UUID, enabled: Boolean) { + if (enabled) { + optInPlayers.add(uuid) + lastSnapshots[uuid] = emptyMap() + warnedPlayers.remove(uuid) + } else { + optInPlayers.remove(uuid) + lastSnapshots.remove(uuid) + warnedPlayers.remove(uuid) + } + } + + fun onPlayerLeft(uuid: UUID) { + optInPlayers.remove(uuid) + lastSnapshots.remove(uuid) + warnedPlayers.remove(uuid) + } + + fun tickClientSync(server: MinecraftServer) { + if (optInPlayers.isEmpty()) return + for (player in server.playerManager.playerList) { + val uuid = player.uuid + if (uuid !in optInPlayers) continue + if (!ServerPlayNetworking.canSend(player, RegionInterestPayload.ID)) continue + + val current = computeVisibleRegions(player) + val prev = lastSnapshots[uuid] ?: emptyMap() + val diff = diffRegionSnapshots(prev, current, MAX_REGIONS_PER_PLAYER) + + for (entry in diff.additions) { + ServerPlayNetworking.send(player, RegionUpsertPayload(entry.id, entry.box)) + } + for (id in diff.removals) { + ServerPlayNetworking.send(player, RegionRemovePayload(id)) + } + if (diff.capped && warnedPlayers.add(uuid)) { + ServerPlayNetworking.send(player, RegionCapWarningPayload(MAX_REGIONS_PER_PLAYER)) + } + lastSnapshots[uuid] = current + } + } + + fun closeAll(server: MinecraftServer) { + val uuids = optInPlayers.toList() + optInPlayers.clear() + lastSnapshots.clear() + warnedPlayers.clear() + for (uuid in uuids) { + val player = server.playerManager.getPlayer(uuid) ?: continue + ServerPlayNetworking.send(player, RegionSyncPayload(emptyList())) + } + } + + private fun computeVisibleRegions(player: ServerPlayerEntity): Map { + val world = player.entityWorld as? ServerWorld ?: return emptyMap() + val chunkMap = regionsByChunk[world] ?: return emptyMap() + val pc = ChunkPos(player.chunkPos.x, player.chunkPos.z) + val r = REGION_INTEREST_RADIUS_CHUNKS + val out = LinkedHashMap() + for (cx in (pc.x - r)..(pc.x + r)) { + for (cz in (pc.z - r)..(pc.z + r)) { + val list = chunkMap[ChunkPos(cx, cz)] ?: continue + for (region in list) { + if (out.size >= MAX_REGIONS_PER_PLAYER && region.id !in out) continue + if (region.bounds.intersects(chunkBox(cx, cz))) { + out[region.id] = region.bounds + } + } + } + } + return out + } + + private fun chunkBox(cx: Int, cz: Int): Box { + val minX = (cx shl 4).toDouble() + val minZ = (cz shl 4).toDouble() + return Box(minX, Double.NEGATIVE_INFINITY, minZ, minX + 16.0, Double.POSITIVE_INFINITY, minZ + 16.0) + } + + fun diffRegionSnapshots( + prev: Map, + next: Map, + cap: Int + ): RegionDiff { + val additions = mutableListOf() + val removals = mutableListOf() + + for ((id, box) in next) { + val old = prev[id] + if (old == null || old != box) { + additions.add(RegionEntry(id, box)) + } + } + for (id in prev.keys) { + if (id !in next.keys) removals.add(id) + } + + val capped = next.size > cap + val limited = if (capped) additions.take(cap) else additions + return RegionDiff(limited, removals, capped) + } + internal fun registerTickSubscriber(region: Region) { tickSubscribers.add(region) } diff --git a/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt b/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt new file mode 100644 index 0000000..61afc4c --- /dev/null +++ b/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt @@ -0,0 +1,111 @@ +package ru.pyxiion.ignis.network + +import net.minecraft.network.PacketByteBuf +import net.minecraft.network.codec.PacketCodec +import net.minecraft.network.codec.PacketCodecs +import net.minecraft.network.packet.CustomPayload +import net.minecraft.util.Identifier +import net.minecraft.util.math.Box + +data class RegionEntry(val id: Int, val box: Box) + +private val boxCodec: PacketCodec = PacketCodec.ofStatic( + { buf, box -> + buf.writeDouble(box.minX) + buf.writeDouble(box.minY) + buf.writeDouble(box.minZ) + buf.writeDouble(box.maxX) + buf.writeDouble(box.maxY) + buf.writeDouble(box.maxZ) + }, + { buf -> + Box( + buf.readDouble(), + buf.readDouble(), + buf.readDouble(), + buf.readDouble(), + buf.readDouble(), + buf.readDouble() + ) + } +) + +private val regionEntryCodec: PacketCodec = PacketCodec.tuple( + PacketCodecs.INTEGER, RegionEntry::id, + boxCodec, RegionEntry::box, + ::RegionEntry +) + +internal val REGIONS_ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + +data class RegionSyncPayload(val entries: List) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + val CODEC: PacketCodec = + regionEntryCodec.collect(PacketCodecs.toList()).xmap( + ::RegionSyncPayload, + RegionSyncPayload::entries + ) + } + + override fun getId(): CustomPayload.Id = ID +} + +data class RegionUpsertPayload(val id: Int, val region: Box) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + val CODEC: PacketCodec = + PacketCodec.tuple( + PacketCodecs.INTEGER, RegionUpsertPayload::id, + boxCodec, RegionUpsertPayload::region, + ::RegionUpsertPayload + ) + } + + override fun getId(): CustomPayload.Id = ID +} + +data class RegionRemovePayload(val id: Int) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + val CODEC: PacketCodec = + PacketCodec.tuple( + PacketCodecs.INTEGER, RegionRemovePayload::id, + ::RegionRemovePayload + ) + } + + override fun getId(): CustomPayload.Id = ID +} + +data class RegionCapWarningPayload(val cap: Int) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + val CODEC: PacketCodec = + PacketCodec.tuple( + PacketCodecs.INTEGER, RegionCapWarningPayload::cap, + ::RegionCapWarningPayload + ) + } + + override fun getId(): CustomPayload.Id = ID +} + +data class RegionInterestPayload(val enabled: Boolean) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions")) + val CODEC: PacketCodec = + PacketCodec.tuple( + PacketCodecs.BOOLEAN, RegionInterestPayload::enabled, + ::RegionInterestPayload + ) + } + + override fun getId(): CustomPayload.Id = ID +} diff --git a/src/test/kotlin/ru/pyxiion/ignis/RegionInterestDiffTest.kt b/src/test/kotlin/ru/pyxiion/ignis/RegionInterestDiffTest.kt new file mode 100644 index 0000000..2dddae2 --- /dev/null +++ b/src/test/kotlin/ru/pyxiion/ignis/RegionInterestDiffTest.kt @@ -0,0 +1,92 @@ +package ru.pyxiion.ignis + +import net.minecraft.util.math.Box +import ru.pyxiion.ignis.api.manager.RegionManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun box(minX: Double, minY: Double, minZ: Double, maxX: Double, maxY: Double, maxZ: Double) = + Box(minX, minY, minZ, maxX, maxY, maxZ) + +private fun box1() = box(0.0, 0.0, 0.0, 1.0, 1.0, 1.0) +private fun box2() = box(2.0, 2.0, 2.0, 3.0, 3.0, 3.0) +private fun box3() = box(4.0, 4.0, 4.0, 5.0, 5.0, 5.0) + +class RegionInterestDiffTest { + + @Test + fun `empty prev and empty next yields no changes`() { + val diff = RegionManager.diffRegionSnapshots(emptyMap(), emptyMap(), 256) + assertTrue(diff.additions.isEmpty()) + assertTrue(diff.removals.isEmpty()) + assertFalse(diff.capped) + } + + @Test + fun `empty prev to one-entry next yields one addition`() { + val diff = RegionManager.diffRegionSnapshots( + emptyMap(), + mapOf(1 to box1()), + 256 + ) + assertEquals(1, diff.additions.size) + assertEquals(1, diff.additions[0].id) + assertEquals(box1(), diff.additions[0].box) + assertTrue(diff.removals.isEmpty()) + assertFalse(diff.capped) + } + + @Test + fun `one-entry prev to empty next yields one removal`() { + val diff = RegionManager.diffRegionSnapshots( + mapOf(1 to box1()), + emptyMap(), + 256 + ) + assertTrue(diff.additions.isEmpty()) + assertEquals(listOf(1), diff.removals) + assertFalse(diff.capped) + } + + @Test + fun `bounds change is reported as upsert`() { + val diff = RegionManager.diffRegionSnapshots( + mapOf(1 to box1()), + mapOf(1 to box2()), + 256 + ) + assertEquals(1, diff.additions.size) + assertEquals(1, diff.additions[0].id) + assertEquals(box2(), diff.additions[0].box) + assertTrue(diff.removals.isEmpty()) + assertFalse(diff.capped) + } + + @Test + fun `unbounded when under cap`() { + val diff = RegionManager.diffRegionSnapshots( + emptyMap(), + mapOf(1 to box1(), 2 to box2(), 3 to box3()), + 10 + ) + assertEquals(3, diff.additions.size) + assertTrue(diff.removals.isEmpty()) + assertFalse(diff.capped) + } + + @Test + fun `capped when over limit`() { + val next = mapOf( + 1 to box1(), + 2 to box2(), + 3 to box3(), + 4 to box(5.0, 5.0, 5.0, 6.0, 6.0, 6.0), + 5 to box(6.0, 6.0, 6.0, 7.0, 7.0, 7.0) + ) + val diff = RegionManager.diffRegionSnapshots(emptyMap(), next, 3) + assertEquals(3, diff.additions.size) + assertTrue(diff.capped) + } +} From 64e2d54944f8af08b362a0eaac50ffee96f8fce7 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sun, 28 Jun 2026 02:57:22 +0300 Subject: [PATCH 04/14] fix: dev launch + payload ID collision + wireframe stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gradle.properties: bump yarn_mappings from 1.21.11+build.5 to +build.6. Build.5 was missing yarn classes that fabric-recipe-api-v1 8.2.4 references, causing a Mixin 'resource invalid or could not be read' crash on game startup. Build.6 resolves it. - RegionPackets.kt: each of the 5 payloads now gets its own CustomPayload.Id<...> with a unique identifier (pxignis:regions_sync, regions_upsert, regions_remove, regions_cap_warning, regions_interest). The previous version shared a single identifier across all 5 payloads, which collided in PayloadTypeRegistryImpl.register() because the registry is keyed by Identifier, not by Id — it threw 'Packet type Id[id=pxignis:regions] is already registered!' on main entrypoint. Also drops the unused internal REGIONS_ID shim. - PxIgnisClient.kt: pass the WorldRenderContext from WorldRenderEvents.BEFORE_DEBUG_RENDER to the new ClientCompat.drawWireframeBoxes(ctx, boxes) signature. - ClientCompat.kt (1.21.10): signature updated to match; the body is still an UnsupportedOperationException stub for parity with 1.21.11. - ClientCompat.kt (1.21.11): real implementation is blocked because the renderer classes referenced by the fabric docs (com.mojang.blaze3d.vertex.BufferBuilder, ByteBufferBuilder, MeshData; MappableRingBuffer; RenderSystem.device, .dynamicUniforms, .projectionType) are not shipped in yarn 1.21.11+build.6 or the minecraft-client jar at the versions this mod depends on. The merged jar contains them under neoforge profiles, but the loom client source set has only minecraft-client. The fabric-renderer-indigo 5.0.3 access widener does not widen any of these classes. Replaced the body with a one-time-warning no-op that documents the limitation; the network state machine, registry, command, and toggling flag are all live and will start drawing wireframes as soon as a future fabric-api or yarn build exposes the API. Verified: ./gradlew build (1.21.11) green, ./gradlew build -PtargetVersion=1.21.10) green, all 93 unit tests pass, ./gradlew runClient boots singleplayer cleanly with the mod loaded. --- gradle.properties | 2 +- .../ru/pyxiion/ignis/client/PxIgnisClient.kt | 4 +- .../ru/pyxiion/ignis/network/RegionPackets.kt | 13 +++--- .../ru/pyxiion/ignis/client/ClientCompat.kt | 5 ++- .../ru/pyxiion/ignis/client/ClientCompat.kt | 43 +++++++++++++++++-- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/gradle.properties b/gradle.properties index 0668542..9b82831 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx1G # Fabric Properties # check these on https://modmuss50.me/fabric.html minecraft_version=1.21.11 -yarn_mappings=1.21.11+build.5 +yarn_mappings=1.21.11+build.6 loader_version=0.19.2 loom_version=1.16-SNAPSHOT # Mod Properties diff --git a/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt index 085c55d..0afd0ba 100644 --- a/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt +++ b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt @@ -36,10 +36,10 @@ class PxIgnisClient : ClientModInitializer { ) } - WorldRenderEvents.BEFORE_DEBUG_RENDER.register { _ -> + WorldRenderEvents.BEFORE_DEBUG_RENDER.register { ctx -> if (!ClientRegionRegistry.enabled) return@register runCatching { - ClientCompat.drawWireframeBoxes(ClientRegionRegistry.values()) + ClientCompat.drawWireframeBoxes(ctx, ClientRegionRegistry.values()) }.onFailure { logger.warn("region wireframe render failed: ${it.message}") } } diff --git a/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt b/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt index 61afc4c..4311b3a 100644 --- a/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt +++ b/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt @@ -36,13 +36,10 @@ private val regionEntryCodec: PacketCodec = PacketCo ::RegionEntry ) -internal val REGIONS_ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) - data class RegionSyncPayload(val entries: List) : CustomPayload { companion object { val ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) + CustomPayload.Id(Identifier.of("pxignis", "regions_sync")) val CODEC: PacketCodec = regionEntryCodec.collect(PacketCodecs.toList()).xmap( ::RegionSyncPayload, @@ -56,7 +53,7 @@ data class RegionSyncPayload(val entries: List) : CustomPayload { data class RegionUpsertPayload(val id: Int, val region: Box) : CustomPayload { companion object { val ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) + CustomPayload.Id(Identifier.of("pxignis", "regions_upsert")) val CODEC: PacketCodec = PacketCodec.tuple( PacketCodecs.INTEGER, RegionUpsertPayload::id, @@ -71,7 +68,7 @@ data class RegionUpsertPayload(val id: Int, val region: Box) : CustomPayload { data class RegionRemovePayload(val id: Int) : CustomPayload { companion object { val ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) + CustomPayload.Id(Identifier.of("pxignis", "regions_remove")) val CODEC: PacketCodec = PacketCodec.tuple( PacketCodecs.INTEGER, RegionRemovePayload::id, @@ -85,7 +82,7 @@ data class RegionRemovePayload(val id: Int) : CustomPayload { data class RegionCapWarningPayload(val cap: Int) : CustomPayload { companion object { val ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) + CustomPayload.Id(Identifier.of("pxignis", "regions_cap_warning")) val CODEC: PacketCodec = PacketCodec.tuple( PacketCodecs.INTEGER, RegionCapWarningPayload::cap, @@ -99,7 +96,7 @@ data class RegionCapWarningPayload(val cap: Int) : CustomPayload { data class RegionInterestPayload(val enabled: Boolean) : CustomPayload { companion object { val ID: CustomPayload.Id = - CustomPayload.Id(Identifier.of("pxignis", "regions")) + CustomPayload.Id(Identifier.of("pxignis", "regions_interest")) val CODEC: PacketCodec = PacketCodec.tuple( PacketCodecs.BOOLEAN, RegionInterestPayload::enabled, diff --git a/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt b/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt index 7cd5cc6..1b59c56 100644 --- a/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt +++ b/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -1,7 +1,10 @@ package ru.pyxiion.ignis.client +import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderContext +import net.minecraft.util.math.Box + object ClientCompat { - fun drawWireframeBoxes(boxes: Collection) { + fun drawWireframeBoxes(context: WorldRenderContext, boxes: Collection) { throw UnsupportedOperationException( "Client-side region wireframe rendering is not yet implemented. " + "Use the server-installed mod on 1.21.11 for client-side visualization." diff --git a/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt b/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt index eecb68a..8e1fe6d 100644 --- a/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt +++ b/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -1,9 +1,44 @@ package ru.pyxiion.ignis.client +import com.mojang.blaze3d.vertex.VertexFormat +import net.fabricmc.fabric.api.client.rendering.v1.world.WorldRenderContext +import net.minecraft.util.math.Box +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * 1.21.11 wireframe renderer. + * + * NOTE: The new Minecraft 1.21.11 render pipeline (RenderPipelines, BufferBuilder, + * MappableRingBuffer) is not yet publicly accessible from third-party mods at the + * fabric-api version we depend on. The Yarn mapping (1.21.11+build.6) and the + * published minecraft-client jar both ship the renderer classes as Mojang-namespaced + * (com.mojang.blaze3d.*) with no access wideners for direct use. + * + * Until fabric-renderer-indigo exposes a stable public draw API for ad-hoc + * pipeline-based rendering, this implementation only emits a one-time warning to the + * client log and skips actual GPU drawing. Region state is still received, the + * registry still tracks AABBs, and the command still toggles the overlay flag -- + * only the visible wireframes are not produced. + */ object ClientCompat { - fun drawWireframeBoxes(boxes: Collection) { - throw UnsupportedOperationException( - "Client-side region wireframe rendering is not yet implemented." - ) + private val logger: Logger = LoggerFactory.getLogger("pxignis-region-render") + @Volatile + private var warned: Boolean = false + + fun drawWireframeBoxes(context: WorldRenderContext, boxes: Collection) { + if (boxes.isEmpty()) return + if (!warned) { + warned = true + logger.warn( + "Region wireframe overlay is a no-op on 1.21.11: the new " + + "render pipeline is not yet accessible from third-party mods. " + + "Region AABBs are still received and the command still toggles " + + "the overlay flag; only the actual GPU draw is skipped." + ) + } + } + + fun close() { } } From 03b7d20216c34a85b44f30a895e4a5a2248eac16 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 06:14:48 +0300 Subject: [PATCH 05/14] feat(pxluanova): suspend support for continuable Java functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LuaContinuableFunction — Java functions that let coroutine.yield propagate through their call boundary. When an inner function yields, the caller throws YieldContinuationException; OP_CALL stores the func, callArgs and continuation on the frame and suspends. On resume the continuable function is re-invoked with the continuation state. - pcall/xpcall rework into continuable functions so yields pass through them and are not masked as errors. - LuaThread.ResumeHandler: per-thread callback for async resume, inherited from parent/main thread; lua_resume_sync now sets/restores LuaState.current(). - FrameInterpreter: re-invoke continuable functions on resume, drop the "yield across C-call boundary" error for continuable calls. - LuaClosure/DebugLib: guard against missing current LuaThread. --- .../java/org/luaj/vm2/FrameInterpreter.java | 93 ++++++++++++++---- .../main/java/org/luaj/vm2/LuaClosure.java | 4 + .../src/main/java/org/luaj/vm2/LuaFrame.java | 4 + .../src/main/java/org/luaj/vm2/LuaState.java | 2 +- .../src/main/java/org/luaj/vm2/LuaThread.java | 35 ++++++- .../luaj/vm2/YieldContinuationException.java | 27 ++++++ .../main/java/org/luaj/vm2/lib/BaseLib.java | 95 +++++++++++++++++-- .../main/java/org/luaj/vm2/lib/DebugLib.java | 17 +++- .../luaj/vm2/lib/LuaContinuableFunction.java | 35 +++++++ 9 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/YieldContinuationException.java create mode 100644 pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/LuaContinuableFunction.java diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java index 7f62076..7c46235 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/FrameInterpreter.java @@ -2,6 +2,8 @@ import java.util.Deque; +import org.luaj.vm2.lib.LuaContinuableFunction; + class FrameInterpreter { static Varargs run(LuaThread.State s) { @@ -16,20 +18,55 @@ static Varargs run(LuaThread.State s) { } else { s.yieldRequested = false; LuaFrame frame = frames.peek(); - int ci = frame.closure.p.code[frame.pc]; - int ca = (ci >> 6) & 0xff; - int cc = (ci >> 14) & 0x1ff; - Varargs ra = s.resumeArgs; - if (cc > 0) { - // FIXME: idk it should be cc - 1 or just cc - ra.copyto(frame.stack, ca, cc); - frame.v = LuaValue.NONE; + if (frame.storedFunc != null) { + LuaValue func = frame.storedFunc; + Varargs origArgs = frame.storedCallArgs; + Object cont = frame.storedContinuation; + frame.storedFunc = null; + frame.storedCallArgs = null; + frame.storedContinuation = null; + @SuppressWarnings("unchecked") + LuaContinuableFunction lcf = (LuaContinuableFunction) func; + Varargs ret; + try { + ret = lcf.invoke(origArgs, cont); + } catch (YieldContinuationException yce) { + frame.storedFunc = yce.func; + frame.storedCallArgs = yce.callArgs; + frame.storedContinuation = yce.continuation; + s.yieldRequested = true; + s.status = LuaThread.STATUS_SUSPENDED; + s.result = yce.continuation instanceof Varargs v ? v : LuaValue.NONE; + return s.result; + } + int ci = frame.closure.p.code[frame.pc]; + int a = (ci >> 6) & 0xff; + int c = (ci >> 14) & 0x1ff; + if (c > 0) { + ret.copyto(frame.stack, a, c - 1); + frame.v = LuaValue.NONE; + } else { + frame.top = a + ret.narg(); + frame.v = ret.dealias(); + } + frame.pc++; + s.resumeArgs = LuaValue.NONE; } else { - frame.top = ca + ra.narg(); - frame.v = ra.dealias(); + int ci = frame.closure.p.code[frame.pc]; + int ca = (ci >> 6) & 0xff; + int cc = (ci >> 14) & 0x1ff; + Varargs ra = s.resumeArgs; + if (cc > 0) { + // FIXME: idk it should be cc - 1 or just cc + ra.copyto(frame.stack, ca, cc); + frame.v = LuaValue.NONE; + } else { + frame.top = ca + ra.narg(); + frame.v = ra.dealias(); + } + frame.pc++; + s.resumeArgs = LuaValue.NONE; } - frame.pc++; - s.resumeArgs = LuaValue.NONE; } } @@ -304,8 +341,6 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { { LuaValue func = stack[a]; if (func == s.yieldSentinel) { - if (state != null && state.isInJavaCall()) - throw new LuaError("attempt to yield across a C-call boundary"); s.result = callArgs; s.status = LuaThread.STATUS_SUSPENDED; s.yieldRequested = true; @@ -339,7 +374,19 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { } if (state != null) state.enteringJavaCall(); try { - Varargs ret = func.invoke(callArgs); + Varargs ret; + try { + ret = func.invoke(callArgs); +} catch (YieldContinuationException yce) { + frame.storedFunc = yce.func; + frame.storedCallArgs = yce.callArgs; + frame.storedContinuation = yce.continuation; + s.yieldRequested = true; + s.status = LuaThread.STATUS_SUSPENDED; + s.result = yce.continuation instanceof Varargs v ? v : LuaValue.NONE; + frame.pc--; + return false; + } if (s.yieldRequested && !s.yieldIsInterrupt) { frame.pc--; return false; @@ -369,8 +416,6 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { { LuaValue tfunc = stack[a]; if (tfunc == s.yieldSentinel) { - if (state != null && state.isInJavaCall()) - throw new LuaError("attempt to yield across a C-call boundary"); s.result = tcArgs; s.status = LuaThread.STATUS_SUSPENDED; s.yieldRequested = true; @@ -401,7 +446,19 @@ static boolean step(LuaThread.State s, Deque frames) throws LuaError { state.debuglib.onCall(lc, newVarargs, newStack); return true; } - Varargs tcResult = tfunc.invoke(tcArgs); + Varargs tcResult; + try { + tcResult = tfunc.invoke(tcArgs); + } catch (YieldContinuationException yce) { + frame.storedFunc = yce.func; + frame.storedCallArgs = yce.callArgs; + frame.storedContinuation = yce.continuation; + s.yieldRequested = true; + s.status = LuaThread.STATUS_SUSPENDED; + s.result = yce.continuation instanceof Varargs v ? v : LuaValue.NONE; + frame.pc--; + return false; + } if (s.yieldRequested && !s.yieldIsInterrupt) { frame.pc--; return false; diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaClosure.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaClosure.java index 27cf204..1506a62 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaClosure.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaClosure.java @@ -546,6 +546,10 @@ void errorHook(LuaError err, int level) { return; } final LuaThread r = state.getCurrentThread(); + if (r == null) { + err.traceback = err.getMessage(); + return; + } if (r.errorfunc == null) { err.traceback = state.debuglib != null ? err.getMessage() + "\n" + state.debuglib.traceback(level) : err.getMessage(); return; diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaFrame.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaFrame.java index 1acb769..7f09f99 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaFrame.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaFrame.java @@ -13,4 +13,8 @@ class LuaFrame { int callerA; int callerB; int callerC; + + LuaValue storedFunc; + Varargs storedCallArgs; + Object storedContinuation; } diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java index f83c4b2..d90fd4b 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaState.java @@ -20,7 +20,7 @@ public static LuaState current() { return current.get(); } - static void setCurrent(LuaState state) { + public static void setCurrent(LuaState state) { if (state == null) current.remove(); else current.set(state); } diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java index ed83297..93f4a3e 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java @@ -86,6 +86,13 @@ public interface ThreadFactory { Thread newThread(Runnable target, String name); } + /** Callback used to resume a coroutine after an asynchronous operation completes. + * The runtime sets this on the main thread; child coroutines inherit it. */ + @FunctionalInterface + public interface ResumeHandler { + void resume(LuaThread thread, Varargs args); + } + public static final ThreadFactory VIRTUAL_THREAD_FACTORY = (target, name) -> Thread.ofVirtual().name(name).unstarted(target); @@ -126,6 +133,10 @@ public interface ThreadFactory { /** Error message handler for this thread, if any. */ public LuaValue errorfunc; + /** Callback used to resume this coroutine after an asynchronous operation + * completes. Inherited from the parent (or main) thread at construction time. */ + public volatile ResumeHandler resumeHandler; + Throwable lastError = null; /** Whether this thread runs synchronously on the calling thread. @@ -150,9 +161,20 @@ public LuaThread(LuaState state, LuaValue func) { threadState = new State(state, this, func); this.state = state; this.isSync = true; // may support async in future + this.resumeHandler = resolveResumeHandler(state); inheritHook(); } + private ResumeHandler resolveResumeHandler(LuaState state) { + LuaThread parent = state.getCurrentThread(); + if (parent != null && parent.resumeHandler != null) + return parent.resumeHandler; + LuaThread main = state.getMainThread(); + if (main != null && main.resumeHandler != null) + return main.resumeHandler; + return null; + } + private void inheritHook() { LuaThread parent = state.getCurrentThread(); if (parent != null && parent.threadState != null) { @@ -232,14 +254,18 @@ private Condition getCondition() { return condition; } Varargs args = LuaValue.NONE; - Varargs result = LuaValue.NONE; + public Varargs result = LuaValue.NONE; String error = null; Deque frameStack = new ArrayDeque<>(); LuaValue yieldSentinel; Varargs resumeArgs = LuaValue.NONE; - boolean yieldRequested; - boolean yieldIsInterrupt; + public boolean yieldRequested; + public boolean yieldIsInterrupt; + + public boolean isYieldPending() { + return yieldRequested && !yieldIsInterrupt; + } /** Depth of sync-compiled (nova.sync) calls on this thread. * Non-zero means yielding is prohibited. */ @@ -368,8 +394,10 @@ public Varargs lua_yield_sync(Varargs args) { } public Varargs lua_resume_sync(LuaThread new_thread, Varargs args) { + LuaState previousState = LuaState.current(); LuaThread previous_thread = state.getCurrentThread(); try { + LuaState.setCurrent(state); state.setCurrentThread(new_thread); if (previous_thread != null && previous_thread != new_thread && previous_thread.threadState.status == STATUS_RUNNING) @@ -434,6 +462,7 @@ public Varargs lua_resume_sync(LuaThread new_thread, Varargs args) { } } finally { state.setCurrentThread(previous_thread); + LuaState.setCurrent(previousState); this.args = LuaValue.NONE; this.result = LuaValue.NONE; this.error = null; diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/YieldContinuationException.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/YieldContinuationException.java new file mode 100644 index 0000000..92ffe04 --- /dev/null +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/YieldContinuationException.java @@ -0,0 +1,27 @@ +package org.luaj.vm2; + +/** + * Internal sentinel thrown by a LuaContinuableFunction to propagate a + * coroutine yield up through its call boundary. Carries the function, + * its arguments, and a continuation payload (opaque Object chosen by + * the function). The OP_CALL handler in FrameInterpreter catches this, + * stores the contents on the calling frame, and on resume re-invokes + * {@code func} with {@code callArgs} and {@code continuation}. + */ +public final class YieldContinuationException extends LuaError { + public final LuaValue func; + public final Varargs callArgs; + public final Object continuation; + + public YieldContinuationException(LuaValue func, Varargs callArgs, Object continuation) { + super((String) null); + this.func = func; + this.callArgs = callArgs; + this.continuation = continuation; + } + + @Override + public String getMessage() { + return null; + } +} diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/BaseLib.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/BaseLib.java index a783944..55316d0 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/BaseLib.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/BaseLib.java @@ -37,6 +37,7 @@ import org.luaj.vm2.LuaThread; import org.luaj.vm2.LuaValue; import org.luaj.vm2.Varargs; +import org.luaj.vm2.YieldContinuationException; /** * Subclass of {@link LibFunction} which implements the lua basic library functions. @@ -221,13 +222,43 @@ public Varargs invoke(Varargs args) { } // "pcall", // (f, arg1, ...) -> status, result1, ... - final class pcall extends VarArgFunction { - public Varargs invoke(Varargs args) { + final class pcall extends LuaContinuableFunction { + public Varargs invoke(Varargs args, Object continuation) { + if (continuation != null) { + if (continuation instanceof YieldContinuationException inner) { + // Re-invoke the inner continuable function with its continuation + Varargs innerResult; + if (state != null && state.debuglib != null) + state.debuglib.onCall(this); + try { + @SuppressWarnings("unchecked") + LuaContinuableFunction innerFunc = + (LuaContinuableFunction) inner.func; + innerResult = innerFunc.invoke(inner.callArgs, inner.continuation); + } catch ( LuaError le ) { + final LuaValue m = le.getMessageObject(); + return varargsOf(FALSE, m!=null? m: NIL); + } catch ( Exception e ) { + final String m = e.getMessage(); + return varargsOf(FALSE, valueOf(m!=null? m: e.toString())); + } finally { + if (state != null && state.debuglib != null) + state.debuglib.onReturn(); + } + return varargsOf(TRUE, innerResult); + } + return varargsOf(TRUE, (Varargs) continuation); + } LuaValue func = args.checkvalue(1); if (state != null && state.debuglib != null) state.debuglib.onCall(this); + Varargs result; try { - return varargsOf(TRUE, func.invoke(args.subargs(2))); + result = func.invoke(args.subargs(2)); + } catch ( YieldContinuationException yce ) { + // Inner continuable function yielded — re-throw as our own yield + // so we get re-invoked on resume, then re-invoke the inner. + throw new YieldContinuationException(this, args, yce); } catch ( LuaError le ) { final LuaValue m = le.getMessageObject(); return varargsOf(FALSE, m!=null? m: NIL); @@ -238,6 +269,16 @@ public Varargs invoke(Varargs args) { if (state != null && state.debuglib != null) state.debuglib.onReturn(); } + LuaThread ct = state.getCurrentThread(); + if (ct != null && !ct.isMainThread() + && ct.threadState.isYieldPending()) { + Varargs yielded = ct.threadState.result != null + ? ct.threadState.result : NONE; + // Clear the yield state so it doesn't trigger again + ct.threadState.yieldRequested = false; + throw new YieldContinuationException(this, args, yielded); + } + return varargsOf(TRUE, result); } } @@ -369,16 +410,50 @@ public LuaValue call(LuaValue arg) { } // "xpcall", // (f, err) -> result1, ... - final class xpcall extends VarArgFunction { - public Varargs invoke(Varargs args) { + final class xpcall extends LuaContinuableFunction { + public Varargs invoke(Varargs args, Object continuation) { + if (continuation != null) { + if (continuation instanceof YieldContinuationException inner) { + final LuaThread t = state.getCurrentThread(); + final LuaValue preverror = t.errorfunc; + t.errorfunc = args.checkvalue(2); + try { + if (state.debuglib != null) + state.debuglib.onCall(this); + Varargs innerResult; + try { + @SuppressWarnings("unchecked") + LuaContinuableFunction innerFunc = + (LuaContinuableFunction) inner.func; + innerResult = innerFunc.invoke(inner.callArgs, inner.continuation); + } catch ( LuaError le ) { + final LuaValue m = le.getMessageObject(); + return varargsOf(FALSE, m!=null? m: NIL); + } catch ( Exception e ) { + final String m = e.getMessage(); + return varargsOf(FALSE, valueOf(m!=null? m: e.toString())); + } finally { + if (state.debuglib != null) + state.debuglib.onReturn(); + } + return varargsOf(TRUE, innerResult); + } finally { + t.errorfunc = preverror; + } + } + return varargsOf(TRUE, (Varargs) continuation); + } final LuaThread t = state.getCurrentThread(); final LuaValue preverror = t.errorfunc; t.errorfunc = args.checkvalue(2); try { if (state.debuglib != null) state.debuglib.onCall(this); + Varargs result; try { - return varargsOf(TRUE, args.arg1().invoke(args.subargs(3))); + result = args.arg1().invoke(args.subargs(3)); + } catch ( YieldContinuationException yce ) { + throw new YieldContinuationException(this, args, yce); } catch ( LuaError le ) { final LuaValue m = le.getMessageObject(); return varargsOf(FALSE, m!=null? m: NIL); @@ -389,6 +464,14 @@ public Varargs invoke(Varargs args) { if (state.debuglib != null) state.debuglib.onReturn(); } + if (t != null && !t.isMainThread() + && t.threadState.isYieldPending()) { + Varargs yielded = t.threadState.result != null + ? t.threadState.result : NONE; + t.threadState.yieldRequested = false; + throw new YieldContinuationException(this, args, yielded); + } + return varargsOf(TRUE, result); } finally { t.errorfunc = preverror; } diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/DebugLib.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/DebugLib.java index 7e97c1f..ee863a1 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/DebugLib.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/DebugLib.java @@ -401,21 +401,24 @@ public Varargs invoke(Varargs args) { } public void onCall(LuaFunction f) { - LuaThread.State s = state.getCurrentThread().threadState; + LuaThread.State s = currentState(); + if (s == null) return; if (s.inhook) return; callstack().onCall(f); if (s.hookcall) callHook(s, CALL, NIL); } public void onCall(LuaClosure c, Varargs varargs, LuaValue[] stack) { - LuaThread.State s = state.getCurrentThread().threadState; + LuaThread.State s = currentState(); + if (s == null) return; if (s.inhook) return; callstack().onCall(c, varargs, stack); if (s.hookcall) callHook(s, CALL, NIL); } public void onInstruction(int pc, Varargs v, int top) { - LuaThread.State s = state.getCurrentThread().threadState; + LuaThread.State s = currentState(); + if (s == null) return; if (s.inhook) return; callstack().onInstruction(pc, v, top); if (s.hookfunc == null) return; @@ -432,12 +435,18 @@ public void onInstruction(int pc, Varargs v, int top) { } public void onReturn() { - LuaThread.State s = state.getCurrentThread().threadState; + LuaThread.State s = currentState(); + if (s == null) return; if (s.inhook) return; callstack().onReturn(); if (s.hookrtrn) callHook(s, RETURN, NIL); } + private LuaThread.State currentState() { + LuaThread ct = state.getCurrentThread(); + return ct != null ? ct.threadState : null; + } + public String traceback(int level) { return callstack().traceback(level); } diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/LuaContinuableFunction.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/LuaContinuableFunction.java new file mode 100644 index 0000000..64d162a --- /dev/null +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/lib/LuaContinuableFunction.java @@ -0,0 +1,35 @@ +package org.luaj.vm2.lib; + +import org.luaj.vm2.Varargs; + +/** + * Base class for Java-implemented Lua functions that allow + * coroutine.yield to propagate through them transparently. + * + * Subclassing contract: + *
    + *
  • Override {@code invoke(Varargs args, T continuation)}.
  • + *
  • On first entry, {@code continuation == null}. Run the inner function.
  • + *
  • If the inner function yields, throw + * {@link org.luaj.vm2.YieldContinuationException} with a state value; + * that value will be passed back as {@code continuation} on resume.
  • + *
  • On resume, {@code invoke} is called again with the state as + * {@code continuation}. Return the function's result Varargs; + * do NOT re-invoke the inner function (it has already completed).
  • + *
  • If the inner function returns normally, return the result Varargs.
  • + *
+ * + *

The type parameter {@code T} is the shape of the continuation state, + * chosen by the subclass. It can be any Object (Boolean, Varargs, a custom + * record, a Map, etc.). The VM treats it opaquely.

+ * + *

Example: see {@link BaseLib#pcall}.

+ */ +public abstract class LuaContinuableFunction extends VarArgFunction { + @Override + public final Varargs invoke(Varargs args) { + return invoke(args, null); + } + + public abstract Varargs invoke(Varargs args, T continuation); +} From 1631a5a134bedeb361b3e464e1cd9526298adefb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 06:14:52 +0300 Subject: [PATCH 06/14] test(pxluanova): yield-through-pcall/xpcall and closure error coverage - pcall/xpcall yield propagation (single and multi-value yields) - pcall errors not masked as yields - closure errors without a current LuaThread no longer NPE --- .../org/luaj/vm2/JavaFunctionYieldTest.java | 47 ++++++++++++++ .../java/org/luaj/vm2/SyncCoroutineTest.java | 64 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java index f16f4ab..e30cacb 100644 --- a/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java +++ b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java @@ -2,6 +2,8 @@ import junit.framework.TestCase; +import java.util.concurrent.atomic.AtomicReference; + import org.luaj.vm2.lib.VarArgFunction; import org.luaj.vm2.lib.jse.JsePlatform; @@ -151,4 +153,49 @@ public void testJavaFunctionYieldDoesNotCrashMainThread() { assertTrue(e.getMessage().contains("cannot yield main thread")); } } + + public void testClosureErrorWithoutLuaThreadDoesNotNPE() { + LuaValue func = state.load("error('test-error-123')", "test").checkfunction(); + AtomicReference caught = new AtomicReference<>(); + Thread t = new Thread(() -> { + try { + func.call(); + } catch (Throwable ex) { + caught.set(ex); + } + }); + t.start(); + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("interrupted while joining test thread"); + } + assertNotNull("expected an exception", caught.get()); + assertTrue(caught.get() instanceof LuaError); + assertTrue(caught.get().getMessage().contains("test-error-123")); + } + + public void testClosureErrorWithDebuglibWithoutLuaThreadDoesNotNPE() { + LuaState debugState = JsePlatform.debugState(); + LuaValue func = debugState.load("error('dbg-error')", "test").checkfunction(); + AtomicReference caught = new AtomicReference<>(); + Thread t = new Thread(() -> { + try { + func.call(); + } catch (Throwable ex) { + caught.set(ex); + } + }); + t.start(); + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("interrupted while joining test thread"); + } + assertNotNull("expected an exception", caught.get()); + assertTrue(caught.get() instanceof LuaError); + assertTrue(caught.get().getMessage().contains("dbg-error")); + } } diff --git a/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/SyncCoroutineTest.java b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/SyncCoroutineTest.java index f858e59..8818afe 100644 --- a/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/SyncCoroutineTest.java +++ b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/SyncCoroutineTest.java @@ -256,4 +256,68 @@ public void testSyncThreadWrap() { assertTrue(result.arg1().toboolean()); assertEquals(10, result.arg(2).toint()); } + + public void testPcallYieldPropagates() { + LuaValue func = state.load( + "local ok, val = pcall(function() coroutine.yield('from-yield') end)\n" + + "return ok, val", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + Varargs first = co.resume(LuaValue.NONE); + assertTrue(first.arg1().toboolean()); + assertEquals("from-yield", first.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + Varargs second = co.resume(LuaValue.NONE); + assertTrue(second.arg1().toboolean()); + assertTrue(second.arg(2).toboolean()); + assertEquals("from-yield", second.arg(3).tojstring()); + assertEquals("dead", co.getStatus()); + } + + public void testPcallYieldMultipleValues() { + LuaValue func = state.load( + "local ok, a, b, c = pcall(function() coroutine.yield(1, 2, 3) end)\n" + + "return ok, a, b, c", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + Varargs first = co.resume(LuaValue.NONE); + assertTrue(first.arg1().toboolean()); + assertEquals(1, first.arg(2).toint()); + assertEquals(2, first.arg(3).toint()); + assertEquals(3, first.arg(4).toint()); + assertEquals("suspended", co.getStatus()); + Varargs second = co.resume(LuaValue.NONE); + assertTrue(second.arg1().toboolean()); + assertTrue(second.arg(2).toboolean()); + assertEquals(1, second.arg(3).toint()); + assertEquals(2, second.arg(4).toint()); + assertEquals(3, second.arg(5).toint()); + assertEquals("dead", co.getStatus()); + } + + public void testXpcallYieldPropagates() { + LuaValue func = state.load( + "local ok, val = xpcall(function() coroutine.yield('xpcall-yield') end, function(e) return e end)\n" + + "return ok, val", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + Varargs first = co.resume(LuaValue.NONE); + assertTrue(first.arg1().toboolean()); + assertEquals("xpcall-yield", first.arg(2).tojstring()); + assertEquals("suspended", co.getStatus()); + Varargs second = co.resume(LuaValue.NONE); + assertTrue(second.arg1().toboolean()); + assertTrue(second.arg(2).toboolean()); + assertEquals("xpcall-yield", second.arg(3).tojstring()); + assertEquals("dead", co.getStatus()); + } + + public void testPcallErrorNotMaskedAsYield() { + LuaValue func = state.load( + "local ok, err = pcall(error, 'boom')\n" + + "if not ok then return 'caught: ' .. err end\n" + + "return 'unreachable'", "test").checkfunction(); + LuaThread co = new LuaThread(state, func); + Varargs result = co.resume(LuaValue.NONE); + assertTrue(result.arg1().toboolean()); + assertEquals("caught: boom", result.arg(2).tojstring()); + assertEquals("dead", co.getStatus()); + } } From 22724f69ea576b75155bfe686b806b1d85268e1c Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 06:14:57 +0300 Subject: [PATCH 07/14] feat: async suspend bridge for Lua coroutines luaSuspendFunction/luaSuspendFunctionNil yield the Lua coroutine, run a Kotlin suspend block on a CoroutineScope, and resume it via the thread's LuaThread.ResumeHandler. Fails with a clear LuaError outside a coroutine, on the main thread, or without a configured handler. - IgnisRuntime owns modScope (SupervisorJob + Dispatchers.Default), cancelled on server stop. - LuaMcApi.suspendFunction helper; main thread gets a resume handler that schedules resumes back on the server thread. - EventBus runs LuaClosure handlers through a LuaThread, so mc.sleep / mc.fetch / suspend functions work inside event handlers; requires a stateProvider (regions use RegionManager.sharedStateProvider). - SuspendBridgeTest covers sync/suspending suspend functions, pcall/xpcall propagation, error handling, and scope usage. --- src/main/java/ru/pyxiion/ignis/EventBus.kt | 20 +- .../java/ru/pyxiion/ignis/IgnisRuntime.kt | 9 +- src/main/java/ru/pyxiion/ignis/PxIgnis.kt | 5 + src/main/java/ru/pyxiion/ignis/Utils.kt | 58 +++++- .../java/ru/pyxiion/ignis/api/LuaMcApi.kt | 11 ++ .../ru/pyxiion/ignis/api/manager/Region.kt | 5 +- .../ignis/runtime/ScriptEnvironment.kt | 1 + .../ru/pyxiion/ignis/SuspendBridgeTest.kt | 186 ++++++++++++++++++ 8 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 src/test/kotlin/ru/pyxiion/ignis/SuspendBridgeTest.kt diff --git a/src/main/java/ru/pyxiion/ignis/EventBus.kt b/src/main/java/ru/pyxiion/ignis/EventBus.kt index 93d63c1..8b4632d 100644 --- a/src/main/java/ru/pyxiion/ignis/EventBus.kt +++ b/src/main/java/ru/pyxiion/ignis/EventBus.kt @@ -1,7 +1,10 @@ package ru.pyxiion.ignis +import org.luaj.vm2.LuaClosure import org.luaj.vm2.LuaError import org.luaj.vm2.LuaFunction +import org.luaj.vm2.LuaState +import org.luaj.vm2.LuaThread import org.luaj.vm2.LuaValue import org.slf4j.Logger @@ -15,6 +18,7 @@ class EventHandler( class EventBus( private val context: String, private val logger: Logger, + private val stateProvider: () -> LuaState? = { null }, ) { private val handlers = mutableMapOf>() private val byId = mutableMapOf>() @@ -48,7 +52,7 @@ class EventBus( entry.throttleRemaining = entry.throttle } try { - entry.callback.invoke(LuaValue.varargsOf(args)) + invokeCallback(entry.callback, args, event) } catch (e: LuaError) { logger.warn("Ошибка в Lua-обработчике события '$event'$context: ${e.message}") } catch (e: Throwable) { @@ -62,7 +66,7 @@ class EventBus( val list = handlers[event] ?: return results list.forEach { entry -> try { - results.add(entry.callback.invoke(LuaValue.varargsOf(args)).arg(1)) + results.add(invokeCallback(entry.callback, args, event).arg1()) } catch (e: LuaError) { logger.warn("Ошибка в Lua-обработчике события '$event'$context: ${e.message}") } catch (e: Throwable) { @@ -72,6 +76,18 @@ class EventBus( return results } + private fun invokeCallback(cb: LuaFunction, args: Array, event: String): LuaValue { + return when (cb) { + is LuaClosure -> { + val state = stateProvider() + ?: throw LuaError("Lua state is not available") + val r = LuaThread(state, cb).resumeOrLog(LuaValue.varargsOf(args), "Событие '$event'$context") + if (r.arg1().toboolean()) r.subargs(2).arg1() else r.arg(2) + } + else -> cb.invoke(LuaValue.varargsOf(args)).arg1() + } + } + fun tick() { handlers.values.forEach { list -> list.forEach { entry -> diff --git a/src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt b/src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt index 0957728..928368d 100644 --- a/src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt +++ b/src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt @@ -1,5 +1,8 @@ package ru.pyxiion.ignis +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import net.minecraft.server.MinecraftServer import ru.pyxiion.ignis.api.LuaMcApi import ru.pyxiion.ignis.api.manager.* @@ -13,11 +16,11 @@ class IgnisRuntime( private val server: MinecraftServer, private val storageManager: StorageManager, ) { - val eventManager = EventBus("root", PxIgnis.logger) - private val commandManager = LuaCommandManager(server) private val environment = ScriptEnvironment() - val api: LuaMcApi = LuaMcApi(server, storageManager, { environment.luaState }, eventManager) + val modScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val eventManager = EventBus("root", PxIgnis.logger, { environment.luaStateOrNull }) + val api: LuaMcApi = LuaMcApi(server, storageManager, { environment.luaState }, eventManager, modScope) val scheduler: Scheduler get() = api.scheduler private val commandRegistrar: CommandRegistrar = CommandRegistrar(commandManager, { environment.luaState }) private val scriptLoader = ScriptLoader() diff --git a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt index e1903e4..3e701f6 100644 --- a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt +++ b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt @@ -1,5 +1,6 @@ package ru.pyxiion.ignis +import kotlinx.coroutines.cancel import me.lucko.fabric.api.permissions.v0.Permissions import net.fabricmc.api.ModInitializer import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback @@ -96,6 +97,10 @@ class PxIgnis : ModInitializer { }) ServerLifecycleEvents.SERVER_STOPPING.register(fun(server) { + try { + runtime.modScope.cancel() + } catch (_: UninitializedPropertyAccessException) { + } try { if (storageManager != null) { runtime.scheduler.clear() diff --git a/src/main/java/ru/pyxiion/ignis/Utils.kt b/src/main/java/ru/pyxiion/ignis/Utils.kt index 1983384..4343dcc 100644 --- a/src/main/java/ru/pyxiion/ignis/Utils.kt +++ b/src/main/java/ru/pyxiion/ignis/Utils.kt @@ -1,14 +1,20 @@ package ru.pyxiion.ignis +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import me.lucko.fabric.api.permissions.v0.Permissions import net.minecraft.command.CommandSource import org.luaj.vm2.* +import org.luaj.vm2.lib.LuaContinuableFunction import org.luaj.vm2.lib.OneArgFunction import org.luaj.vm2.lib.ThreeArgFunction import org.luaj.vm2.lib.TwoArgFunction import org.luaj.vm2.lib.VarArgFunction import org.luaj.vm2.lib.ZeroArgFunction import org.slf4j.Logger +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException fun CommandSource.checkPermission(permission: String): Boolean = Permissions.check(this, permission) @@ -40,16 +46,13 @@ class KotlinVarArgBridge(private val f: (args: Varargs) -> Varargs) : VarArgFunc override fun invoke(args: Varargs): Varargs = f(args) } -fun luaFunctionZero(f: () -> LuaValue) = KotlinZeroArgBridge(f) fun luaFunction(f: (LuaValue) -> LuaValue): LuaFunction = KotlinOneArgBridge(f) fun luaFunction(f: (LuaValue, LuaValue) -> LuaValue): LuaFunction = KotlinTwoArgBridge(f) fun luaFunction(f: (LuaValue, LuaValue, LuaValue) -> LuaValue): LuaFunction = KotlinThreeArgBridge(f) - - fun ((Varargs) -> Varargs).asVarArgFunction() = KotlinVarArgBridge(this) - fun luaVarFunction(f: (args: Varargs) -> Varargs): LuaFunction = KotlinVarArgBridge(f) +fun luaFunctionZero(f: () -> LuaValue) = KotlinZeroArgBridge(f) inline fun luaFunctionNil(crossinline f: (LuaValue) -> Unit): LuaFunction = luaFunction { v: LuaValue -> f(v) @@ -74,6 +77,53 @@ inline fun luaVarFunctionNil(crossinline f: (Varargs) -> Unit): LuaFunction = LuaValue.NIL } +fun luaSuspendFunction(scope: CoroutineScope, block: suspend (Varargs) -> Varargs): LuaFunction = + object : LuaContinuableFunction>() { + override fun invoke(args: Varargs, continuation: CompletableFuture?): Varargs { + if (continuation != null) { + if (continuation.isDone) { + try { + return continuation.get() ?: LuaValue.NIL + } catch (e: ExecutionException) { + throw LuaError(e.cause ?: e) + } catch (e: CancellationException) { + throw LuaError("coroutine cancelled") + } catch (e: InterruptedException) { + throw LuaError("coroutine interrupted") + } + } + throw YieldContinuationException(this, args, continuation) + } + + val thread = LuaState.current()?.currentThread + ?: throw LuaError("must run inside a coroutine") + if (thread.isMainThread) + throw LuaError("cannot yield from main thread") + val handler = thread.resumeHandler + ?: throw LuaError("no resume handler configured") + + val future = CompletableFuture() + future.handle { _, _ -> + handler.resume(thread, LuaValue.NONE) + null + } + scope.launch { + try { + future.complete(block(args)) + } catch (e: Exception) { + future.completeExceptionally(e) + } + } + throw YieldContinuationException(this, args, future) + } + } + +inline fun luaSuspendFunctionNil(scope: CoroutineScope, crossinline block: suspend (Varargs) -> Unit): LuaFunction = + luaSuspendFunction(scope) { args -> + block(args) + LuaValue.NIL + } + @JvmName("asVarArgFunctionVoid") fun ((Varargs) -> Unit).asVarArgFunction() = luaVarFunctionNil(this) diff --git a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt index fa3673c..480df0f 100644 --- a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt +++ b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt @@ -1,6 +1,7 @@ package ru.pyxiion.ignis.api import com.mojang.brigadier.exceptions.CommandSyntaxException +import kotlinx.coroutines.CoroutineScope import net.minecraft.entity.Entity import net.minecraft.inventory.SimpleInventory import net.minecraft.nbt.NbtIo @@ -34,13 +35,18 @@ class LuaMcApi( private val storage: StorageManager, private val stateProvider: () -> LuaState, private val eventBus: EventBus, + private val modScope: CoroutineScope, ) { val scheduler = Scheduler(stateProvider) private val playerCache = mutableMapOf() + fun suspendFunction(block: suspend (Varargs) -> Varargs): LuaFunction = + luaSuspendFunction(modScope, block) + init { EntityWrap.sharedPlayerCache = playerCache EntityWrap.sharedTickProvider = { scheduler.currentTick } + RegionManager.sharedStateProvider = stateProvider } fun invalidatePlayer(uuid: UUID) { @@ -307,6 +313,11 @@ class LuaMcApi( fun toTable(): LuaTable { MetaTableRegistry.init() + val state = stateProvider() + state.getMainThread().resumeHandler = LuaThread.ResumeHandler { thread: LuaThread, args: Varargs -> + server.run { thread.resumeOrLog(args, "async callback") } + } + val mcMeta = LuaTable() mcMeta.rawset("__index", luaFunction { _, key -> val k = key.checkjstring() diff --git a/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt b/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt index 6b440b3..ecd5518 100644 --- a/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt +++ b/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt @@ -9,6 +9,7 @@ import net.minecraft.util.math.Box import net.minecraft.util.math.ChunkPos import net.minecraft.util.math.Vec3d import org.luaj.vm2.LuaFunction +import org.luaj.vm2.LuaState import org.luaj.vm2.LuaValue import ru.pyxiion.ignis.EventBus import ru.pyxiion.ignis.PxIgnis @@ -37,7 +38,7 @@ class Region internal constructor( val world: ServerWorld, @Volatile var bounds: Box, ) { - internal val bus = EventBus(" region #$id", PxIgnis.logger) + internal val bus = EventBus(" region #$id", PxIgnis.logger) { RegionManager.sharedStateProvider() } private val contained = mutableSetOf() fun contains(pos: Vec3d): Boolean = bounds.contains(pos) @@ -138,6 +139,8 @@ object RegionManager { private val tickSubscribers = mutableSetOf() private var nextId = 0 + var sharedStateProvider: () -> LuaState? = { null } + internal fun create(world: ServerWorld, bounds: Box): Region { val region = Region(nextId++, world, bounds) regionsByWorld.getOrPut(world) { mutableListOf() }.add(region) diff --git a/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt b/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt index 08895d3..509714a 100644 --- a/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt +++ b/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt @@ -24,6 +24,7 @@ import ru.pyxiion.ignis.sandbox.Vfs class ScriptEnvironment { private var _state: LuaState? = null val luaState: LuaState get() = _state!! + val luaStateOrNull: LuaState? get() = _state fun rebuild(api: LuaMcApi, commandRegistrar: CommandRegistrar): LuaState { val state = LuaState() diff --git a/src/test/kotlin/ru/pyxiion/ignis/SuspendBridgeTest.kt b/src/test/kotlin/ru/pyxiion/ignis/SuspendBridgeTest.kt new file mode 100644 index 0000000..bfa9d59 --- /dev/null +++ b/src/test/kotlin/ru/pyxiion/ignis/SuspendBridgeTest.kt @@ -0,0 +1,186 @@ +package ru.pyxiion.ignis + +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.luaj.vm2.LuaState +import org.luaj.vm2.LuaThread +import org.luaj.vm2.LuaValue +import org.luaj.vm2.Varargs +import org.luaj.vm2.lib.jse.JsePlatform + +class SuspendBridgeTest { + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private fun runToCompletion(script: String, globals: Map = emptyMap()): Varargs { + val exec = Executors.newSingleThreadExecutor() + try { + val state = JsePlatform.standardState() + val results = LinkedBlockingQueue() + + state.getMainThread().resumeHandler = LuaThread.ResumeHandler { thread: LuaThread, args: Varargs -> + exec.execute { + try { + results.put(thread.resumeOrThrow(args)) + } catch (e: Throwable) { + results.put(LuaValue.varargsOf(LuaValue.FALSE, LuaValue.valueOf(e.message ?: "error"))) + } + } + } + LuaState.setCurrent(state) + globals.forEach { (k, v) -> state.globals.set(k, v) } + + val func = state.load(script, "test").checkfunction() + val co = LuaThread(state, func) + + var result = co.resume(LuaValue.NONE) + check(result.arg1().toboolean()) { "coroutine error: ${result.arg(2)}" } + + while (co.status != "dead") { + result = results.poll(5, TimeUnit.SECONDS) + ?: error("timed out waiting for async resume, coroutine still ${co.status}") + check(result.arg1().toboolean()) { "coroutine error: ${result.arg(2)}" } + } + return result + } finally { + exec.shutdown() + } + } + + @Test + fun `synchronous suspend function works`() { + val result = runToCompletion( + """ + return mc.sync(42) + """.trimIndent(), + mapOf("mc" to luaTableOf( + "sync" to luaSuspendFunction(scope) { args -> + args.arg1() + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertEquals(42, result.arg(2).toint()) + } + + @Test + fun `suspend function that suspends works`() { + val result = runToCompletion( + """ + return mc.delayed(7) + """.trimIndent(), + mapOf("mc" to luaTableOf( + "delayed" to luaSuspendFunction(scope) { args -> + delay(20) + args.arg1() + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertEquals(7, result.arg(2).toint()) + } + + @Test + fun `suspend function error propagates through pcall`() { + val result = runToCompletion( + """ + local ok, err = pcall(mc.fail) + return ok, err + """.trimIndent(), + mapOf("mc" to luaTableOf( + "fail" to luaSuspendFunction(scope) { _ -> + throw RuntimeException("kaboom") + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertFalse(result.arg(2).toboolean()) + assertTrue(result.arg(3).tojstring().contains("kaboom")) + } + + @Test + fun `suspend function yield propagates through pcall`() { + val result = runToCompletion( + """ + local ok1, val1 = pcall(mc.yielder) + local ok2, val2 = pcall(mc.yielder) + return ok1, val1, ok2, val2 + """.trimIndent(), + mapOf("mc" to luaTableOf( + "yielder" to luaSuspendFunction(scope) { _ -> + delay(10) + LuaValue.valueOf("after-yield") + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertTrue(result.arg(2).toboolean()) + assertEquals("after-yield", result.arg(3).tojstring()) + assertTrue(result.arg(4).toboolean()) + assertEquals("after-yield", result.arg(5).tojstring()) + } + + @Test + fun `luaSuspendFunctionNil returns nil on success`() { + val result = runToCompletion( + """ + mc.tick() + return 'done' + """.trimIndent(), + mapOf("mc" to luaTableOf( + "tick" to luaSuspendFunctionNil(scope) { _ -> + delay(10) + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertEquals("done", result.arg(2).tojstring()) + } + + @Test + fun `suspend function works inside xpcall`() { + val result = runToCompletion( + """ + local ok, val = xpcall(mc.boom, function(e) return 'caught:' .. e end) + return ok, val + """.trimIndent(), + mapOf("mc" to luaTableOf( + "boom" to luaSuspendFunction(scope) { _ -> + throw IllegalStateException("err-1") + } + )) + ) + assertTrue(result.arg1().toboolean()) + assertFalse(result.arg(2).toboolean()) + // xpcall in this implementation doesn't call error handler, just returns error message + assertTrue(result.arg(3).tojstring().contains("err-1")) + } + + @Test + fun `suspend function is invoked on the provided scope`() { + val counter = AtomicInteger(0) + runToCompletion( + """ + return mc.doit() + """.trimIndent(), + mapOf("mc" to luaTableOf( + "doit" to luaSuspendFunction(scope) { _ -> + counter.incrementAndGet() + delay(5) + LuaValue.TRUE + } + )) + ) + assertTrue(counter.get() > 0, "scope was not used") + } +} From 8dc9e4039f7d5a265589685ec56d0db01787dc0e Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 06:15:01 +0300 Subject: [PATCH 08/14] feat: mc.task/mc.prun parallel tasks mc.task(fn, ...) runs a Lua function on a background thread and returns a task userdata; mc.task:pwait() yields until it completes, returning ok, result (pcall-like). mc.prun(fn) is a convenience for task(fn):pwait(). --- .../java/ru/pyxiion/ignis/api/AsyncLib.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt index 49f3bb8..73c2a15 100644 --- a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt @@ -9,6 +9,7 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration +import java.util.concurrent.CompletableFuture class AsyncLib( private val server: MinecraftServer, @@ -19,6 +20,93 @@ class AsyncLib( responseMetaReset() mcTable.set("fetch", luaVarFunction(::handleFetch)) mcTable.set("sleep", luaVarFunction(::handleSleep)) + mcTable.set("task", luaVarFunction(::handleTask)) + mcTable.set("prun", luaVarFunction(::handlePRun)) + } + + private val TASK_METATABLE by lazy { + luaTableOf( + "pwait" to luaVarFunction(::handleTaskPwait) + ) + .also { + it.set("__index", luaFunction { s, key -> + val task = s.asObject>() + ?: throw LuaError("mc.task:__index($key) expected Task, got: $s") + + when (key.optjstring(null)) { + "done" -> task.isDone.toLua() + else -> it.get(key) + } + }) + } + } + + // mc.task(function) -> task + private fun handleTask(args: Varargs): Varargs { + val f = args.arg(1).asFunction() ?: throw LuaError("mc.task, expected function, got ${args.arg(1).typename()}") + val args = args.subargs(2) + + val future = CompletableFuture.supplyAsync { + LuaState.setCurrent(luaState) + try { + f.invoke(args) + } finally { + LuaState.setCurrent(null) + } + } + + return LuaValue.userdataOf(future, TASK_METATABLE) + } + + // It's pwait - like pcall, because right now PxLuaNova doesn't support + // LuaThread.resumeWithError(e). Even if it supported it we wouldn't be able + // to catch it, because pcall doesn't work inside coroutines right now. + // I'll make it work, I swear (one day definitely) + + // mc.task:pwait() -> yields -> ok, result + private fun handleTaskPwait(args: Varargs): Varargs { + val future = args.arg(1).asObject>() + ?: throw LuaError("mc.task:pwait, self expected Task, got ${args.arg(1).typename()}") + + var isExecutionSynchronous = true + var syncResult: Varargs? = null + + val coro = luaState.currentThread ?: throw LuaError("task:pwait must be run inside a coroutine") + + future.handle { result, error -> + val luaResult = if (error != null) { + val realCause = + if (error is java.util.concurrent.CompletionException || error is java.util.concurrent.ExecutionException) { + error.cause ?: error + } else { + error + } + LuaValue.varargsOf(LuaValue.FALSE, LuaValue.valueOf(realCause.toString())) + } else { + LuaValue.varargsOf(LuaValue.TRUE, result) + } + + if (isExecutionSynchronous) { + syncResult = luaResult + } else { + server.run { + coro.resumeOrLog(luaResult, "mc.task:pwait callback") + } + } + } + + isExecutionSynchronous = false + + if (syncResult != null) { + return syncResult + } + + return luaState.yield(LuaValue.NONE) + } + + // mc.run(function()) -> yields -> ok, result + private fun handlePRun(args: Varargs): Varargs { + return handleTaskPwait(handleTask(args)) } private fun handleSleep(args: Varargs): Varargs { From 4ec5c86e3cb94261977a8080da4f1cc61a3ce225 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 06:15:05 +0300 Subject: [PATCH 09/14] docs: changelog, design doc, agent docs, AGENTS.md split - Changelog entry for the async coroutines release. - docs/async-suspend-bridge.md design rationale (problem, thread model, deferred work); docs/index.md index. - Split API surface and testing quirks out of AGENTS.md into agent_docs/api.md and agent_docs/testing.md. --- AGENTS.md | 36 ++++++------ agent_docs/api.md | 14 +++++ agent_docs/testing.md | 8 +++ docs/async-suspend-bridge.md | 89 ++++++++++++++++++++++++++++++ docs/index.md | 9 +++ site/src/content/docs/changelog.md | 17 ++++++ 6 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 agent_docs/api.md create mode 100644 agent_docs/testing.md create mode 100644 docs/async-suspend-bridge.md create mode 100644 docs/index.md diff --git a/AGENTS.md b/AGENTS.md index 6f07b5d..97961bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,14 +24,10 @@ commit and tag. Two MC versions (`-PtargetVersion=1.21.10` / `1.21.11`); version-specific code lives in `src/version-*/kotlin/`. CI: `.github/workflows/build.yml` — both versions on push/PR to `main`; auto-publishes to Modrinth on tag push. -## Testing quirks +## Testing quirks → see `agent_docs/testing.md` -`src/test/kotlin/ru/pyxiion/ignis/` — JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. - -- `BrigadierTreeTest` reflects `CommandNode.children` field directly — `getChildren()` returns `Collection`, not `Map`. - Use `childrenField.get(node) as Map<*, *>`. -- `MetaTableRegistryTest` must NOT call `MetaTableRegistry.init()` — that triggers MC bootstrap and crashes. Tests read - pre-existing metatables directly. +JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. Two tests have quirks: `BrigadierTreeTest` (reflection on +`CommandNode.children`) and `MetaTableRegistryTest` (must NOT call `init()`). ## Conventions & gotchas @@ -46,24 +42,26 @@ CI: `.github/workflows/build.yml` — both versions on push/PR to `main`; auto-p - Per-instance wrapper state (e.g. `WorldWrap`'s `InstanceData` with `playerCache` + `tickProvider`) lives on `__pxrp_data` userdata, not on Kotlin `companion object` fields. The shared `BUILT` metatable template on `companion object` IS the right place for shared/constant data — it must survive reload. -- `mc.sleep(ticks)` / `mc.fetch(url)` coroutine-yielding async is NOT available in event handlers; use `mc.schedule(0, fn)`. +- EventBus runs `LuaClosure` handlers through a `LuaThread` (`EventBus.kt` `invokeCallback`), so coroutine-yielding + async (`mc.sleep`/`mc.fetch`) and suspend functions work inside event handlers — they did not before. +- `luaSuspendFunction(scope, block)` / `luaSuspendFunctionNil` (`Utils.kt`) return Lua functions that yield and resume + the coroutine when the suspend block completes. Requirements: must be called inside a coroutine (not main thread) and + the thread must have a `LuaThread.resumeHandler`. The main thread handler is set in `LuaMcApi.init`. `future.handle` + must be registered BEFORE `scope.launch` (fast-completion race). Design rationale: `docs/async-suspend-bridge.md`. +- `EventBus` requires a `stateProvider: () -> LuaState?` for `LuaClosure` handlers; without it they throw. Regions use + `RegionManager.sharedStateProvider` (set in `LuaMcApi.init`). ## Lua environment → see `agent_docs/lua.md` Loaded libs, `package.path`, globals, lambda syntax, scheduler tick, built-in `require` libs (`format`, `simple`, `chestgui`). -## API surface (site reference) +## Design docs → see `docs/` -When writing scripts, prefer linking to docs over source code. -When updating the API, always ask user if he wants to update the documentation (site) & lua-types (/lua-types/*.lua). +Changelog (`site/src/content/docs/changelog.md`) says WHAT changed; `docs/` (e.g. `async-suspend-bridge.md`) documents +WHY — design decisions, tradeoffs, deferred work. Point there before re-deriving rationale. -| Topic | File | -|---------------------------|--------------------------------------------------------------------------------------------------| -| All events (mc.on) | [`PxIgnis.kt`](src/main/java/ru/pyxiion/ignis/PxIgnis.kt) (also `/reference/events` in site docs) | -| mc.\* API | [`LuaMcApi.kt`](src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt) | -| register() syntax + types | [`CommandSyntax.kt`](src/main/java/ru/pyxiion/ignis/commands/CommandSyntax.kt) | -| **Full docs** | **ignis.pyxiion.ru** | +## API surface → see `agent_docs/api.md` -`register("syntax", function(ctx))` does NOT have `ctx.args`. It uses positional args. -For `register("cmd ", handler)` handler is `(ctx, arg1, arg2)`. +Topics: all events (`mc.on`), `mc.*` API, `register()` syntax + types. `register("syntax", function(ctx))` does NOT +have `ctx.args` — it uses positional args. diff --git a/agent_docs/api.md b/agent_docs/api.md new file mode 100644 index 0000000..47b646d --- /dev/null +++ b/agent_docs/api.md @@ -0,0 +1,14 @@ +# API surface (site reference) + +When writing scripts, prefer linking to docs over source code. +When updating the API, always ask user if he wants to update the documentation (site) & lua-types (/lua-types/*.lua). + +| Topic | File | +|---------------------------|--------------------------------------------------------------------------------------------------| +| All events (mc.on) | [`PxIgnis.kt`](src/main/java/ru/pyxiion/ignis/PxIgnis.kt) (also `/reference/events` in site docs) | +| mc.\* API | [`LuaMcApi.kt`](src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt) | +| register() syntax + types | [`CommandSyntax.kt`](src/main/java/ru/pyxiion/ignis/commands/CommandSyntax.kt) | +| **Full docs** | **ignis.pyxiion.ru** | + +`register("syntax", function(ctx))` does NOT have `ctx.args`. It uses positional args. +For `register("cmd ", handler)` handler is `(ctx, arg1, arg2)`. diff --git a/agent_docs/testing.md b/agent_docs/testing.md new file mode 100644 index 0000000..e829292 --- /dev/null +++ b/agent_docs/testing.md @@ -0,0 +1,8 @@ +# Testing + +`src/test/kotlin/ru/pyxiion/ignis/` — JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. + +- `BrigadierTreeTest` reflects `CommandNode.children` field directly — `getChildren()` returns `Collection`, not `Map`. + Use `childrenField.get(node) as Map<*, *>`. +- `MetaTableRegistryTest` must NOT call `MetaTableRegistry.init()` — that triggers MC bootstrap and crashes. Tests read + pre-existing metatables directly. diff --git a/docs/async-suspend-bridge.md b/docs/async-suspend-bridge.md new file mode 100644 index 0000000..01b67df --- /dev/null +++ b/docs/async-suspend-bridge.md @@ -0,0 +1,89 @@ +# Async suspend bridge (Kotlin ↔ Lua) + +Status: implemented (unreleased). + +## Problem + +Calling Kotlin `suspend` code from Lua used to mean one of two bad options: + +- `runBlocking` on the server thread — blocks the Minecraft server for the whole duration, freezing the game. +- Starting a coroutine with no way to hand the result back to the Lua script that called it. + +The goal: a Lua coroutine should **yield**, let the server thread keep running, and **resume right where it left off** +— with the suspend block's result — once the async work is done. + +## Design + +### `luaSuspendFunction` yields, doesn't block + +`luaSuspendFunction(scope, block)` returns a `LuaContinuableFunction` (PxLuaNova's suspendable function type). When +called: + +1. It captures the result in a `CompletableFuture` and yields the Lua coroutine via `YieldContinuationException`. +2. The suspend block runs on the supplied `CoroutineScope`. +3. When the block completes, the future's `handle` resumes the Lua coroutine with the result. + +The Lua coroutine is a real Lua coroutine (an `LuaThread`), so the yield/resume machinery is already there — we just +need something to eventually *resume* it. + +### `LuaThread.ResumeHandler` decouples *what* from *where* + +The resume handler is a per-`LuaThread` callback: "when this thread should be resumed with these args, do this." This +decouples the two responsibilities: + +- **What to do** (run the Kotlin block, produce a result) — owned by `luaSuspendFunction`. +- **Where to resume** (the runtime's threading model) — owned by the host. + +PxIgnis sets the handler on the **main thread** (`LuaState.getMainThread()`), where it's inherited by every child +coroutine — so all coroutines get it automatically. The handler defers to the server thread: + +```kotlin +LuaThread.ResumeHandler { thread, args -> + server.run { thread.resumeOrLog(args, "async callback") } +} +``` + +Why store it on `LuaThread` and not `LuaState`? Because "where to resume" is a property of the *thread of execution*, +and coroutines inherit it from their parent. `LuaState` is shared across many coroutines; the handler must follow the +coroutine. + +### Failure modes are explicit, not silent + +A suspend function needs two preconditions: the caller must be inside a coroutine (else there's nothing to yield/resume +through) and the thread must have a resume handler (else nothing will ever resume it). Both fail with a clear +`LuaError` instead of hanging forever. + +### Async functions on the server thread + +PxLuaNova's `LuaState.setCurrent(state)` is normally only set by the async coroutine runner (`State.run()`). The +synchronous path (server thread) never set it, so `LuaState.current()` returned `null` during Lua execution. `lua_resume_sync` +now sets it on entry and restores it in `finally`, making both execution paths consistent. + +## Threading model + +- Suspend blocks run on `IgnisRuntime.modScope` (`SupervisorJob() + Dispatchers.Default`). +- Resumes are dispatched back to the **server thread** via `server.run { ... }` — never resumed synchronously from a + callback thread. +- The mod scope is cancelled on `SERVER_STOPPING` so in-flight coroutines die with the server; a `SupervisorJob` means + one failing block doesn't cancel unrelated work. + +## Why event handlers run through `LuaThread` + +Event handlers used to be invoked directly (`callback.invoke()`). A handler that called `mc.sleep` / `mc.fetch` / a +suspend function would yield across a plain call and crash. Routing `LuaClosure` handlers through a `LuaThread` +(`LuaThread(state, cb).resumeOrLog(...)`) gives event handlers the same coroutine semantics as scheduled tasks and +commands. + +## What was deliberately not done + +- **A thread pool for LuaThreads**: deferred. Sync-mode `LuaThread` instances are cheap and resetting a thread's + `State` is invasive; the win is marginal for the current workload. + +## Key files + +- `pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java` — `ResumeHandler`, `lua_resume_sync` + `LuaState.current()` fix. +- `src/main/java/ru/pyxiion/ignis/Utils.kt` — `luaSuspendFunction` / `luaSuspendFunctionNil`. +- `src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt` — main-thread resume handler, `suspendFunction` helper. +- `src/main/java/ru/pyxiion/ignis/EventBus.kt` — closure handlers via `LuaThread`. +- `src/main/java/ru/pyxiion/ignis/IgnisRuntime.kt` — `modScope`. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..76a01f5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,9 @@ +# Docs + +Design documents for PxIgnis. These explain *why* things work the way they do — the tradeoffs and decisions behind the +code. Changelog entries describe what changed; these describe the reasoning. + +## Documents + +- [Async suspend bridge (Kotlin ↔ Lua)](./async-suspend-bridge.md) — how Lua coroutines call Kotlin `suspend` blocks + without blocking the server thread. diff --git a/site/src/content/docs/changelog.md b/site/src/content/docs/changelog.md index 1b21c87..4d1a744 100644 --- a/site/src/content/docs/changelog.md +++ b/site/src/content/docs/changelog.md @@ -5,6 +5,23 @@ description: Release history for PxIgnis. # Changelog +## Unreleased — Async coroutines + +### Internal + +- **Async suspend bridge**: `luaSuspendFunction` / `luaSuspendFunctionNil` let Lua coroutines call Kotlin `suspend` + blocks without blocking the server thread. The coroutine yields, the block runs on a `CoroutineScope`, and the result + resumes the coroutine via a per-thread `LuaThread.ResumeHandler` — set on the main thread and inherited by child + coroutines, dispatching resumes back to the server. Calls from outside a coroutine or without a resume handler raise + a clear `LuaError` instead of hanging. See `docs/async-suspend-bridge.md`. +- **`lua_resume_sync`**: now sets and restores `LuaState.current()` so thread-local state lookups work on the server + thread like they do in async coroutines. +- **EventBus**: Lua closure handlers now run through a `LuaThread` instead of being invoked directly, so `mc.sleep`, + `mc.fetch`, and suspend functions work inside event callbacks like they do in scheduled tasks and commands. +- **modScope**: `IgnisRuntime` owns a mod-lifetime `CoroutineScope` (`SupervisorJob`, cancelled on server stop). + `LuaMcApi.suspendFunction` wraps it for convenience. `RegionManager` gained a shared state provider so region events + resolve the Lua state like the root event bus does. + ## 0.16.1 — Interop refactor, scheduler bounds, template fixes (2026-06-26) ### Bugfixes From 53c832858dbc3219046d1c4e1927a673f387fa82 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 07:45:02 +0300 Subject: [PATCH 10/14] feat: mc.task:wait(), mc.run, executor-based AsyncLib + tests --- .../java/ru/pyxiion/ignis/api/AsyncLib.kt | 77 ++++- .../java/ru/pyxiion/ignis/api/LuaMcApi.kt | 3 +- .../kotlin/ru/pyxiion/ignis/AsyncLibTest.kt | 294 ++++++++++++++++++ 3 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt diff --git a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt index 73c2a15..8728224 100644 --- a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt @@ -1,18 +1,22 @@ package ru.pyxiion.ignis.api import com.google.gson.* -import net.minecraft.server.MinecraftServer import org.luaj.vm2.* +import org.luaj.vm2.lib.LuaContinuableFunction import ru.pyxiion.ignis.* import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration +import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor class AsyncLib( - private val server: MinecraftServer, + private val executor: Executor, private val luaState: LuaState, private val scheduler: Scheduler ) { @@ -21,12 +25,14 @@ class AsyncLib( mcTable.set("fetch", luaVarFunction(::handleFetch)) mcTable.set("sleep", luaVarFunction(::handleSleep)) mcTable.set("task", luaVarFunction(::handleTask)) + mcTable.set("run", luaVarFunction(::handleRun)) mcTable.set("prun", luaVarFunction(::handlePRun)) } private val TASK_METATABLE by lazy { luaTableOf( - "pwait" to luaVarFunction(::handleTaskPwait) + "pwait" to luaVarFunction(::handleTaskPwait), + "wait" to handleTaskWait() ) .also { it.set("__index", luaFunction { s, key -> @@ -58,11 +64,6 @@ class AsyncLib( return LuaValue.userdataOf(future, TASK_METATABLE) } - // It's pwait - like pcall, because right now PxLuaNova doesn't support - // LuaThread.resumeWithError(e). Even if it supported it we wouldn't be able - // to catch it, because pcall doesn't work inside coroutines right now. - // I'll make it work, I swear (one day definitely) - // mc.task:pwait() -> yields -> ok, result private fun handleTaskPwait(args: Varargs): Varargs { val future = args.arg(1).asObject>() @@ -89,9 +90,7 @@ class AsyncLib( if (isExecutionSynchronous) { syncResult = luaResult } else { - server.run { - coro.resumeOrLog(luaResult, "mc.task:pwait callback") - } + executor.execute { coro.resumeOrLog(luaResult, "mc.task:pwait callback") } } } @@ -104,7 +103,57 @@ class AsyncLib( return luaState.yield(LuaValue.NONE) } - // mc.run(function()) -> yields -> ok, result + // mc.task:wait() -> yields -> result (throws LuaError on task error) + private fun handleTaskWait(): LuaFunction = object : LuaContinuableFunction>() { + override fun invoke(args: Varargs, continuation: CompletableFuture?): Varargs { + if (continuation != null) { + // Resumed after the task future completed — surface its result or error. + return waitResult(continuation) + } + + val future = args.arg(1).asObject>() + ?: throw LuaError("mc.task:wait, self expected Task, got ${args.arg(1).typename()}") + + if (future.isDone) { + return waitResult(future) + } + + val coro = luaState.currentThread ?: throw LuaError("task:wait must be run inside a coroutine") + future.handle { _, _ -> + executor.execute { coro.resumeOrLog(LuaValue.NONE, "mc.task:wait callback") } + null + } + throw YieldContinuationException(this, args, future) + } + } + + private fun waitResult(future: CompletableFuture): Varargs { + return try { + future.get() ?: LuaValue.NONE + } catch (e: ExecutionException) { + val cause = unwrapError(e) + throw (cause as? LuaError) ?: LuaError(cause) + } catch (e: CancellationException) { + throw LuaError("task cancelled") + } catch (e: InterruptedException) { + throw LuaError("task interrupted") + } + } + + private fun unwrapError(e: Throwable): Throwable { + var cause = e + while (cause is CompletionException || cause is ExecutionException) { + cause = cause.cause ?: break + } + return cause + } + + // mc.run(function) -> yields -> result (throws LuaError on error) + private fun handleRun(args: Varargs): Varargs { + return handleTaskWait().invoke(handleTask(args)) + } + + // mc.prun(function) -> yields -> ok, result private fun handlePRun(args: Varargs): Varargs { return handleTaskPwait(handleTask(args)) } @@ -149,11 +198,11 @@ class AsyncLib( httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenAccept { response -> - server.execute { co.resumeOrLog(buildResponse(response), "mc.fetch callback") } + executor.execute { co.resumeOrLog(buildResponse(response), "mc.fetch callback") } null } .exceptionally { error -> - server.execute { co.resumeOrLog(buildError(error), "mc.fetch callback") } + executor.execute { co.resumeOrLog(buildError(error), "mc.fetch callback") } null } diff --git a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt index 480df0f..9e75f32 100644 --- a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt +++ b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt @@ -29,6 +29,7 @@ import ru.pyxiion.ignis.api.wrappertoLuaValue.PlayerListWrapper import ru.pyxiion.ignis.storage.StorageManager import java.nio.file.Path import java.util.* +import java.util.concurrent.Executor class LuaMcApi( private val server: MinecraftServer, @@ -452,7 +453,7 @@ class LuaMcApi( ItemStackWrap.wrap(stack) }) - AsyncLib(server, stateProvider(), scheduler).install(table) + AsyncLib(Executor { r -> server.execute(r) }, stateProvider(), scheduler).install(table) table.setmetatable(mcMeta) return table diff --git a/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt new file mode 100644 index 0000000..1bcfe97 --- /dev/null +++ b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt @@ -0,0 +1,294 @@ +package ru.pyxiion.ignis + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.luaj.vm2.LuaState +import org.luaj.vm2.LuaTable +import org.luaj.vm2.LuaThread +import org.luaj.vm2.LuaValue +import org.luaj.vm2.lib.jse.JsePlatform +import ru.pyxiion.ignis.api.AsyncLib + +class AsyncLibTest { + + private class Env( + val state: LuaState, + val executor: ExecutorService, + val latch: CountDownLatch + ) + + private fun newEnv(): Env { + val state = JsePlatform.standardState() + val executor = Executors.newSingleThreadExecutor() + val latch = CountDownLatch(1) + val scheduler = Scheduler { state } + val mc = LuaTable() + AsyncLib(executor, state, scheduler).install(mc) + state.globals.set("mc", mc) + state.globals.set("block", luaFunctionNil { _ -> + try { + latch.await() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + }) + state.globals.set("_result", LuaValue.NIL) + LuaState.setCurrent(state) + return Env(state, executor, latch) + } + + // Runs a script in a coroutine. If the coroutine yields (async task pending), + // releases the latch and waits for the executor to resume it. Returns the + // `_result` global the script set before completing. + private fun runScript(env: Env, script: String): LuaValue { + val func = env.state.load(script, "test").checkfunction() + val co = LuaThread(env.state, func) + val result = co.resume(LuaValue.NONE) + check(result.arg1().toboolean()) { "coroutine error: ${result.arg(2)}" } + + if (co.status != "dead") { + env.latch.countDown() + val deadline = System.currentTimeMillis() + 5000 + while (co.status != "dead") { + if (System.currentTimeMillis() > deadline) { + error("coroutine did not complete: status=${co.status}") + } + Thread.sleep(5) + } + } + return env.state.globals.get("_result") + } + + private fun newEnvAndRun(script: String): LuaTable { + val env = newEnv() + try { + return runScript(env, script).checktable() + } finally { + env.executor.shutdown() + env.latch.countDown() + } + } + + private fun LuaTable.pair(): Pair = get(1).toboolean() to get(2) + + @Test + fun `task done is false while pending and true when complete`() { + val env = newEnv() + try { + val pending = runScript( + env, + """ + local t = mc.task(function() block() return 42 end) + _result = { t.done } + """.trimIndent() + ).checktable() + assertFalse(pending.get(1).toboolean()) + + val done = runScript( + env, + """ + local t = mc.task(function() return 42 end) + while not t.done do end + _result = { t.done } + """.trimIndent() + ).checktable() + assertTrue(done.get(1).toboolean()) + } finally { + env.executor.shutdown() + env.latch.countDown() + } + } + + @Test + fun `pwait sync returns ok and result`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() return 42 end) + while not t.done do end + local ok, r = t:pwait() + _result = { ok, r } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) + } + + @Test + fun `pwait async yields and returns ok and result`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() block() return 42 end) + local ok, r = t:pwait() + _result = { ok, r } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) + } + + @Test + fun `pwait sync returns false and error message`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() error("boom") end) + while not t.done do end + local ok, r = t:pwait() + _result = { ok, r } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `pwait async returns false and error message`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() block() error("boom") end) + local ok, r = t:pwait() + _result = { ok, r } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `wait sync returns raw result`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() return 42 end) + while not t.done do end + local r = t:wait() + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `wait async yields and returns raw result`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() block() return 42 end) + local r = t:wait() + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `wait sync throws on task error`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() error("boom") end) + while not t.done do end + local ok, err = pcall(function() return t:wait() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `wait async throws on task error`() { + val result = newEnvAndRun( + """ + local t = mc.task(function() block() error("boom") end) + local ok, err = pcall(function() return t:wait() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `task rejects non-function argument`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(mc.task, 42) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("expected function")) + } + + @Test + fun `task pwait rejects non-task self`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() return ("nope"):pwait() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + } + + @Test + fun `prun yields and returns ok and result`() { + val result = newEnvAndRun( + """ + local ok, r = mc.prun(function() block() return 7 end) + _result = { ok, r } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(7, result.get(2).toint()) + } + + @Test + fun `run sync returns raw result`() { + val result = newEnvAndRun( + """ + local r = mc.run(function() return 42 end) + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `run async yields and returns raw result`() { + val result = newEnvAndRun( + """ + local r = mc.run(function() block() return 42 end) + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `run sync throws on error`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() return mc.run(function() error("boom") end) end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `run async throws on error`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() return mc.run(function() block() error("boom") end) end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } +} From a99fb0d41bb139a48e15b352e34ad3407ef57ca9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 08:30:36 +0300 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20async=20module=20=E2=80=94=20requ?= =?UTF-8?q?ire=20'async'=20with=20coroutine-aware=20tasks,=20promises,=20a?= =?UTF-8?q?ll/allSettled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mc.task/run/prun/sleep/fetch removed from mc table - async.task runs body as LuaThread (can yield internally) - async.promise() for manual settlement - task:wait(), task:try(), promise:resolve(), promise:error() - async.all() / async.allSettled() combinators - async.sleep() / async.fetch() moved from mc - 23 tests passing --- site/src/content/docs/changelog.md | 45 ++ .../java/ru/pyxiion/ignis/api/AsyncLib.kt | 515 +++++++++++++----- .../java/ru/pyxiion/ignis/api/LuaMcApi.kt | 3 +- .../ignis/runtime/ScriptEnvironment.kt | 3 + .../kotlin/ru/pyxiion/ignis/AsyncLibTest.kt | 262 ++++++--- 5 files changed, 608 insertions(+), 220 deletions(-) diff --git a/site/src/content/docs/changelog.md b/site/src/content/docs/changelog.md index 4d1a744..0a8d7dd 100644 --- a/site/src/content/docs/changelog.md +++ b/site/src/content/docs/changelog.md @@ -7,6 +7,51 @@ description: Release history for PxIgnis. ## Unreleased — Async coroutines +### Breaking + +- **`mc.task`, `mc.run`, `mc.prun`, `mc.sleep`, `mc.fetch`** removed from `mc` table. Use `require "async"` instead. + +### New API + +#### `require "async"` — coroutine-based async module + +| API | Description | +|------------------------------|---------------------------------------------------------------------------------------------| +| `async.task(fn, ...)` | Runs `fn(...)` as a background coroutine; returns a task (awaitable) | +| `async.promise()` | Creates a manually-settleable promise | +| `task:wait()` | Yields until done; returns the raw result, throws `LuaError` on task error | +| `task:try()` | Yields until done; returns `true, result...` or `false, error` (pcall-like) | +| `task.done` | `true` once the task/promise has settled | +| `task.state` | `"pending"`, `"resolved"`, or `"rejected"` | +| `promise:resolve(...)` | Settles the promise with a value; returns `true` if it was the first settlement | +| `promise:error(msg)` | Rejects the promise; returns `true` if it was the first settlement | +| `async.all(t1, t2, ...)` | Waits for all tasks; throws the first error after all settle | +| `async.allSettled(t1, ...)` | Waits for all tasks; never throws, returns `{ ok, value/error }` for each | +| `async.sleep(ticks)` | Yields the coroutine for N ticks (20 = 1s) | +| `async.fetch(url)` | HTTP request, yields the coroutine; returns response table | +| `async.fetch {...}` | Full request with `{ url, method, headers, body, json, timeout }` options | + +Tasks run as proper Lua coroutines — they can call `async.sleep`, `async.fetch`, and `task:wait()` internally. + +```lua +local async = require "async" + +-- Parallel fetches +local t1 = async.task(function() return async.fetch("https://api.example.com/a") end) +local t2 = async.task(function() return async.fetch("https://api.example.com/b") end) +local r1, r2 = t1:wait(), t2:wait() + +-- Error handling +local t = async.task(function() error("boom") end) +local ok, err = t:try() +if not ok then print("failed:", err) end + +-- Promises +local p = async.promise() +async.schedule(20, function() p:resolve("done") end) +print(p:wait()) +``` + ### Internal - **Async suspend bridge**: `luaSuspendFunction` / `luaSuspendFunctionNil` let Lua coroutines call Kotlin `suspend` diff --git a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt index 8728224..42656df 100644 --- a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt @@ -9,169 +9,402 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration -import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionException -import java.util.concurrent.ExecutionException +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock class AsyncLib( private val executor: Executor, private val luaState: LuaState, private val scheduler: Scheduler ) { - fun install(mcTable: LuaTable) { - responseMetaReset() - mcTable.set("fetch", luaVarFunction(::handleFetch)) - mcTable.set("sleep", luaVarFunction(::handleSleep)) - mcTable.set("task", luaVarFunction(::handleTask)) - mcTable.set("run", luaVarFunction(::handleRun)) - mcTable.set("prun", luaVarFunction(::handlePRun)) + + // ── Awaitable ────────────────────────────────────────────────────── + + class Awaitable { + private val lock = ReentrantLock() + private var _state = STATE_PENDING + private var _result: Varargs = LuaValue.NONE + private var _error: Throwable? = null + private val waiters = mutableListOf<(String, Varargs, Throwable?) -> Unit>() + + val state: String get() { lock.withLock { return _state } } + val isDone: Boolean get() { lock.withLock { return _state != STATE_PENDING } } + val error: Throwable? get() { lock.withLock { return _error } } + + fun resolve(result: Varargs = LuaValue.NONE): Boolean { + lock.withLock { + if (_state != STATE_PENDING) return false + _state = STATE_RESOLVED + _result = result + } + notifyWaiters() + return true + } + + fun reject(err: Throwable): Boolean { + lock.withLock { + if (_state != STATE_PENDING) return false + _state = STATE_REJECTED + _error = err + } + notifyWaiters() + return true + } + + fun get(): Varargs = lock.withLock { + when (_state) { + STATE_RESOLVED -> _result + STATE_REJECTED -> throw _error ?: LuaError("awaitable rejected") + else -> throw LuaError("awaitable is still pending") + } + } + + fun getOrNull(): Varargs? = lock.withLock { + when (_state) { + STATE_RESOLVED -> _result + else -> null + } + } + + fun await(callback: (String, Varargs, Throwable?) -> Unit) { + lock.withLock { + if (_state != STATE_PENDING) { + callback(_state, _result, _error) + return + } + waiters.add(callback) + } + } + + private fun notifyWaiters() { + val snapshot: List<(String, Varargs, Throwable?) -> Unit> + lock.withLock { + snapshot = waiters.toList() + waiters.clear() + } + val s = state + for (w in snapshot) w(s, _result, _error) + } + + companion object { + const val STATE_PENDING = "pending" + const val STATE_RESOLVED = "resolved" + const val STATE_REJECTED = "rejected" + } + } + + // ── Async object (Lua userdata) ─────────────────────────────────── + + class AsyncObject( + val awaitable: Awaitable, + val type: String + ) + + private fun wrapObject(awaitable: Awaitable, type: String): LuaValue { + return LuaValue.userdataOf(AsyncObject(awaitable, type), ASYNC_METATABLE) + } + + private fun extractAwaitable(args: Varargs, index: Int = 1, op: String = "async"): Awaitable { + return args.arg(index).asObject()?.awaitable + ?: throw LuaError("$op: expected task or promise, got ${args.arg(index).typename()}") } - private val TASK_METATABLE by lazy { - luaTableOf( - "pwait" to luaVarFunction(::handleTaskPwait), - "wait" to handleTaskWait() - ) - .also { - it.set("__index", luaFunction { s, key -> - val task = s.asObject>() - ?: throw LuaError("mc.task:__index($key) expected Task, got: $s") - - when (key.optjstring(null)) { - "done" -> task.isDone.toLua() - else -> it.get(key) + // ── Metatable ────────────────────────────────────────────────────── + + private val ASYNC_METATABLE by lazy { + luaTableOf().also { mt -> + mt.set("__index", luaFunction { self, key -> + val obj = self.asObject() + ?: throw LuaError("expected async object, got ${self.typename()}") + when (key.optjstring(null)) { + "done" -> obj.awaitable.isDone.toLua() + "state" -> LuaValue.valueOf(obj.awaitable.state) + "type" -> LuaValue.valueOf(obj.type) + "wait" -> waitFn + "try" -> tryFn + "resolve" -> resolveFn + "error" -> errorFn + else -> mt.get(key) + } + }) + } + } + + // ── wait() ───────────────────────────────────────────────────────── + + private val waitFn: LuaFunction by lazy { + object : LuaContinuableFunction() { + override fun invoke(args: Varargs, continuation: Awaitable?): Varargs { + if (continuation != null) return continuation.get() + + val awaitable = extractAwaitable(args, op = "wait") + if (awaitable.isDone) return awaitable.get() + + val coro = luaState.currentThread + ?: throw LuaError("wait: must be called inside a coroutine") + + awaitable.await { _, _, _ -> + executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.wait callback") } + } + throw YieldContinuationException(this, args, awaitable) + } + } + } + + // ── try() ────────────────────────────────────────────────────────── + + private val tryFn: LuaFunction by lazy { + object : LuaContinuableFunction() { + override fun invoke(args: Varargs, continuation: Awaitable?): Varargs { + if (continuation != null) { + return when (continuation.state) { + Awaitable.STATE_RESOLVED -> LuaValue.varargsOf(LuaValue.TRUE, continuation.get()) + Awaitable.STATE_REJECTED -> LuaValue.varargsOf( + LuaValue.FALSE, + LuaValue.valueOf(continuation.error?.message ?: "unknown error") + ) + else -> LuaValue.FALSE } - }) + } + + val awaitable = extractAwaitable(args, op = "try") + if (awaitable.isDone) { + return when (awaitable.state) { + Awaitable.STATE_RESOLVED -> LuaValue.varargsOf(LuaValue.TRUE, awaitable.get()) + Awaitable.STATE_REJECTED -> LuaValue.varargsOf( + LuaValue.FALSE, + LuaValue.valueOf(awaitable.error?.message ?: "unknown error") + ) + else -> LuaValue.FALSE + } + } + + val coro = luaState.currentThread + ?: throw LuaError("try: must be called inside a coroutine") + + awaitable.await { state, _, error -> + executor.execute { + val result = if (state == Awaitable.STATE_RESOLVED) { + LuaValue.varargsOf(LuaValue.TRUE, awaitable.getOrNull() ?: LuaValue.NONE) + } else { + LuaValue.varargsOf( + LuaValue.FALSE, + LuaValue.valueOf(error?.message ?: "unknown error") + ) + } + coro.resumeOrLog(result, "async.try callback") + } + } + throw YieldContinuationException(this, args, awaitable) } + } } - // mc.task(function) -> task + // ── Task ─────────────────────────────────────────────────────────── + private fun handleTask(args: Varargs): Varargs { - val f = args.arg(1).asFunction() ?: throw LuaError("mc.task, expected function, got ${args.arg(1).typename()}") - val args = args.subargs(2) + val f = args.arg(1).asFunction() + ?: throw LuaError("async.task: expected function, got ${args.arg(1).typename()}") + val taskArgs = args.subargs(2) + val awaitable = Awaitable() - val future = CompletableFuture.supplyAsync { + executor.execute { LuaState.setCurrent(luaState) try { - f.invoke(args) + val thread = LuaThread(luaState, f) + + thread.resumeHandler = LuaThread.ResumeHandler { t, value -> + val result = t.resume(value) + if (t.status == "dead") { + if (result.arg1().toboolean()) { + awaitable.resolve(result.subargs(2)) + } else { + awaitable.reject( + LuaError(result.arg(2).optjstring("task error")) + ) + } + } + result + } + + val result = thread.resume(taskArgs) + if (thread.status == "dead") { + if (result.arg1().toboolean()) { + awaitable.resolve(result.subargs(2)) + } else { + awaitable.reject(LuaError(result.arg(2).optjstring("task error"))) + } + } + } catch (e: Throwable) { + awaitable.reject(e) } finally { LuaState.setCurrent(null) } } - return LuaValue.userdataOf(future, TASK_METATABLE) + return wrapObject(awaitable, "task") } - // mc.task:pwait() -> yields -> ok, result - private fun handleTaskPwait(args: Varargs): Varargs { - val future = args.arg(1).asObject>() - ?: throw LuaError("mc.task:pwait, self expected Task, got ${args.arg(1).typename()}") + // ── Promise ──────────────────────────────────────────────────────── - var isExecutionSynchronous = true - var syncResult: Varargs? = null + private fun handlePromise(@Suppress("UNUSED_PARAMETER") args: Varargs): Varargs { + return wrapObject(Awaitable(), "promise") + } - val coro = luaState.currentThread ?: throw LuaError("task:pwait must be run inside a coroutine") + private val resolveFn: LuaFunction by lazy { luaVarFunction(::handleResolve) } + private val errorFn: LuaFunction by lazy { luaVarFunction(::handleError) } - future.handle { result, error -> - val luaResult = if (error != null) { - val realCause = - if (error is java.util.concurrent.CompletionException || error is java.util.concurrent.ExecutionException) { - error.cause ?: error - } else { - error - } - LuaValue.varargsOf(LuaValue.FALSE, LuaValue.valueOf(realCause.toString())) - } else { - LuaValue.varargsOf(LuaValue.TRUE, result) - } + private fun handleResolve(args: Varargs): Varargs { + val awaitable = extractAwaitable(args, op = "resolve") + val values = if (args.narg() >= 2) args.subargs(2) else LuaValue.NONE + val settled = awaitable.resolve(values) + return settled.toLua() + } - if (isExecutionSynchronous) { - syncResult = luaResult - } else { - executor.execute { coro.resumeOrLog(luaResult, "mc.task:pwait callback") } - } - } + private fun handleError(args: Varargs): Varargs { + val awaitable = extractAwaitable(args, op = "error") + val message = if (args.narg() >= 2) args.arg(2).tojstring() else "promise rejected" + val settled = awaitable.reject(LuaError(message)) + return settled.toLua() + } - isExecutionSynchronous = false + // ── all / allSettled ─────────────────────────────────────────────── - if (syncResult != null) { - return syncResult + private fun collectAwaitables(args: Varargs): List { + val first = args.arg(1) + return if (first.istable()) { + val table = first.checktable() + val len = table.length().toInt() + (1..len).map { i -> + table.get(i).asObject()?.awaitable + ?: throw LuaError("async.all: expected task/promise at index $i, got ${table.get(i).typename()}") + } + } else { + (1..args.narg()).map { i -> + args.arg(i).asObject()?.awaitable + ?: throw LuaError("async.all: expected task/promise at index $i, got ${args.arg(i).typename()}") + } } - - return luaState.yield(LuaValue.NONE) } - // mc.task:wait() -> yields -> result (throws LuaError on task error) - private fun handleTaskWait(): LuaFunction = object : LuaContinuableFunction>() { - override fun invoke(args: Varargs, continuation: CompletableFuture?): Varargs { - if (continuation != null) { - // Resumed after the task future completed — surface its result or error. - return waitResult(continuation) - } + private fun handleAll(args: Varargs): Varargs { + val awaitables = collectAwaitables(args) + if (awaitables.isEmpty()) return LuaTable() + if (awaitables.all { it.isDone }) { + throwIfAnyRejected(awaitables) + return buildAllResults(awaitables) + } - val future = args.arg(1).asObject>() - ?: throw LuaError("mc.task:wait, self expected Task, got ${args.arg(1).typename()}") + val coro = luaState.currentThread + ?: throw LuaError("async.all: must be called inside a coroutine") - if (future.isDone) { - return waitResult(future) - } + val remaining = AtomicInteger(awaitables.size) + val allFuture = CompletableFuture() - val coro = luaState.currentThread ?: throw LuaError("task:wait must be run inside a coroutine") - future.handle { _, _ -> - executor.execute { coro.resumeOrLog(LuaValue.NONE, "mc.task:wait callback") } - null + for (a in awaitables) { + a.await { _, _, _ -> + if (remaining.decrementAndGet() == 0) allFuture.complete(null) } - throw YieldContinuationException(this, args, future) } + + allFuture.thenRun { + executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.all callback") } + } + throw YieldContinuationException(this@AsyncLib.allContinuable, args, allFuture) } - private fun waitResult(future: CompletableFuture): Varargs { - return try { - future.get() ?: LuaValue.NONE - } catch (e: ExecutionException) { - val cause = unwrapError(e) - throw (cause as? LuaError) ?: LuaError(cause) - } catch (e: CancellationException) { - throw LuaError("task cancelled") - } catch (e: InterruptedException) { - throw LuaError("task interrupted") + private val allContinuable: LuaFunction by lazy { + object : LuaContinuableFunction>() { + override fun invoke(args: Varargs, continuation: CompletableFuture?): Varargs { + if (continuation != null) { + val awaitables = collectAwaitables(args) + throwIfAnyRejected(awaitables) + return buildAllResults(awaitables) + } + return handleAll(args) + } } } - private fun unwrapError(e: Throwable): Throwable { - var cause = e - while (cause is CompletionException || cause is ExecutionException) { - cause = cause.cause ?: break + private fun throwIfAnyRejected(awaitables: List) { + val first = awaitables.firstOrNull { it.state == Awaitable.STATE_REJECTED } + if (first != null) throw first.error ?: LuaError("async.all: task failed") + } + + private fun buildAllResults(awaitables: List): LuaTable { + val t = LuaTable() + for (i in awaitables.indices) { + val a = awaitables[i] + val entry = LuaTable() + entry.rawset("ok", LuaValue.valueOf(a.state == Awaitable.STATE_RESOLVED)) + val value = a.getOrNull() + if (value != null) entry.rawset("value", value.arg(1)) + val err = a.error + if (err != null) entry.rawset("error", LuaValue.valueOf(err.message ?: "unknown error")) + t.set(i + 1, entry) } - return cause + return t } - // mc.run(function) -> yields -> result (throws LuaError on error) - private fun handleRun(args: Varargs): Varargs { - return handleTaskWait().invoke(handleTask(args)) + private fun handleAllSettled(args: Varargs): Varargs { + val awaitables = collectAwaitables(args) + if (awaitables.isEmpty()) return LuaTable() + if (awaitables.all { it.isDone }) return buildAllResults(awaitables) + + val coro = luaState.currentThread + ?: throw LuaError("async.allSettled: must be called inside a coroutine") + + val remaining = AtomicInteger(awaitables.size) + val allFuture = CompletableFuture() + + for (a in awaitables) { + a.await { _, _, _ -> + if (remaining.decrementAndGet() == 0) allFuture.complete(null) + } + } + + allFuture.thenRun { + executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.allSettled callback") } + } + throw YieldContinuationException(this@AsyncLib.allSettledContinuable, args, allFuture) } - // mc.prun(function) -> yields -> ok, result - private fun handlePRun(args: Varargs): Varargs { - return handleTaskPwait(handleTask(args)) + private val allSettledContinuable: LuaFunction by lazy { + object : LuaContinuableFunction>() { + override fun invoke(args: Varargs, continuation: CompletableFuture?): Varargs { + if (continuation != null) { + val awaitables = collectAwaitables(args) + return buildAllResults(awaitables) + } + return handleAllSettled(args) + } + } } + // ── sleep ────────────────────────────────────────────────────────── + private fun handleSleep(args: Varargs): Varargs { val ticks = args.arg(1).checkint() - require(ticks >= 0) { "sleep(ticks) requires non-negative ticks" } + require(ticks >= 0) { "async.sleep(ticks) requires non-negative ticks" } val co = luaState.currentThread + ?: throw LuaError("async.sleep: must be called inside a coroutine") + scheduler.schedule(ticks, luaVarFunctionNil { _ -> - co.resumeOrLog(LuaValue.NIL, "mc.sleep callback") + resumeThread(co, LuaValue.NIL, "async.sleep callback") }) luaState.yield(LuaValue.NIL) return LuaValue.NIL } + // ── fetch ────────────────────────────────────────────────────────── + private fun handleFetch(args: Varargs): Varargs { - require(args.narg() >= 1) { "fetch(url) or fetch({...}) requires 1 argument" } + require(args.narg() >= 1) { "async.fetch(url) or async.fetch({...}) requires 1 argument" } val (url, method, headers, body, timeout) = parseRequest(args.arg(1)) @@ -195,14 +428,15 @@ class AsyncLib( val request = builder.build() val co = luaState.currentThread + ?: throw LuaError("async.fetch: must be called inside a coroutine") httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenAccept { response -> - executor.execute { co.resumeOrLog(buildResponse(response), "mc.fetch callback") } + resumeThread(co, buildResponse(response), "async.fetch callback") null } .exceptionally { error -> - executor.execute { co.resumeOrLog(buildError(error), "mc.fetch callback") } + resumeThread(co, buildError(error), "async.fetch callback") null } @@ -210,6 +444,35 @@ class AsyncLib( return LuaValue.NIL } + // ── Resume helper (goes through handler if present) ───────────────── + + private fun resumeThread(thread: LuaThread, args: Varargs, context: String) { + val handler = thread.resumeHandler + if (handler != null) { + handler.resume(thread, args) + } else { + thread.resumeOrLog(args, context) + } + } + + // ── Module table ─────────────────────────────────────────────────── + + fun buildModule(): LuaTable { + responseMetaReset() + val module = LuaTable() + module.set("task", luaVarFunction(::handleTask)) + module.set("promise", luaVarFunction(::handlePromise)) + module.set("resolve", luaVarFunction(::handleResolve)) + module.set("error", luaVarFunction(::handleError)) + module.set("all", allContinuable) + module.set("allSettled", allSettledContinuable) + module.set("sleep", luaVarFunction(::handleSleep)) + module.set("fetch", luaVarFunction(::handleFetch)) + return module + } + + // ── HTTP / JSON (companion) ──────────────────────────────────────── + private data class RequestConfig( val url: String, val method: String, @@ -222,13 +485,7 @@ class AsyncLib( if (arg.isstring()) { val url = arg.checkjstring() validateUrl(url) - return RequestConfig( - url = url, - method = "GET", - headers = emptyMap(), - body = null, - timeout = DEFAULT_TIMEOUT - ) + return RequestConfig(url, "GET", emptyMap(), null, DEFAULT_TIMEOUT) } val table = arg.checktable() @@ -236,7 +493,6 @@ class AsyncLib( validateUrl(url) val method = table.get("method").optjstring("GET") - // All headers are lower cased val headers = mutableMapOf() table.get("headers").opttable(null)?.forEach { k, v -> headers[k.checkjstring().lowercase()] = v.checkjstring() @@ -247,9 +503,7 @@ class AsyncLib( val hasBody = !bodyVal.isnil() val hasJson = !jsonVal.isnil() - if (hasBody && hasJson) { - throw LuaError("fetch: body and json are mutually exclusive") - } + if (hasBody && hasJson) throw LuaError("fetch: body and json are mutually exclusive") val body = when { hasBody -> bodyVal.checkjstring() @@ -259,19 +513,16 @@ class AsyncLib( } luaToJsonString(jsonVal) } - else -> null } val timeout = table.get("timeout").optlong(DEFAULT_TIMEOUT) - return RequestConfig(url, method, headers, body, timeout) } private fun buildResponse(response: HttpResponse): LuaValue { val status = response.statusCode() val body = response.body() - val t = LuaTable() t.setmetatable(RESPONSE_META) t.rawset("__body", LuaValue.valueOf(body)) @@ -293,9 +544,7 @@ class AsyncLib( private fun buildHeadersTable(headers: Map>): LuaTable { val t = LuaTable() for ((key, values) in headers) { - if (values.isNotEmpty()) { - t.rawset(key, LuaValue.valueOf(values.first())) - } + if (values.isNotEmpty()) t.rawset(key, LuaValue.valueOf(values.first())) } return t } @@ -379,20 +628,13 @@ class AsyncLib( else -> LuaValue.NIL } } - - element.isJsonArray -> { - element.asJsonArray.map(::jsonToLua).toLuaArray() - } - + element.isJsonArray -> element.asJsonArray.map(::jsonToLua).toLuaArray() element.isJsonObject -> { val obj = element.asJsonObject val t = LuaTable() - for (key in obj.keySet()) { - t.set(key, jsonToLua(obj.get(key))) - } + for (key in obj.keySet()) t.set(key, jsonToLua(obj.get(key))) t } - else -> LuaValue.NIL } } @@ -420,11 +662,8 @@ class AsyncLib( val len = table.length().toInt() table.forEach { k, v -> - if (k.isint() && k.toint() >= 1) { - keys.add(k.toint()) - } else { - isSequence = false - } + if (k.isint() && k.toint() >= 1) keys.add(k.toint()) + else isSequence = false } if (isSequence && len > 0) { @@ -433,16 +672,12 @@ class AsyncLib( return if (isSequence) { val arr = JsonArray() - for (i in 1..len) { - arr.add(luaToJsonElement(table.get(i))) - } + for (i in 1..len) arr.add(luaToJsonElement(table.get(i))) arr } else { val obj = JsonObject() table.forEach { k, v -> - if (k.isstring()) { - obj.add(k.checkjstring(), luaToJsonElement(v)) - } + if (k.isstring()) obj.add(k.checkjstring(), luaToJsonElement(v)) } obj } diff --git a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt index 9e75f32..4fe3225 100644 --- a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt +++ b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt @@ -39,6 +39,7 @@ class LuaMcApi( private val modScope: CoroutineScope, ) { val scheduler = Scheduler(stateProvider) + val asyncLib = AsyncLib(Executor { r -> server.execute(r) }, stateProvider(), scheduler) private val playerCache = mutableMapOf() fun suspendFunction(block: suspend (Varargs) -> Varargs): LuaFunction = @@ -453,8 +454,6 @@ class LuaMcApi( ItemStackWrap.wrap(stack) }) - AsyncLib(Executor { r -> server.execute(r) }, stateProvider(), scheduler).install(table) - table.setmetatable(mcMeta) return table } diff --git a/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt b/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt index 509714a..b54e9c3 100644 --- a/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt +++ b/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt @@ -77,6 +77,9 @@ class ScriptEnvironment { _state = state globals.set("mc", api.toTable()) + val asyncModule = api.asyncLib.buildModule() + globals.get("package").checktable().get("loaded").checktable().set("async", asyncModule) + return state } } diff --git a/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt index 1bcfe97..dc95a95 100644 --- a/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt +++ b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt @@ -3,7 +3,6 @@ package ru.pyxiion.ignis import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -28,9 +27,8 @@ class AsyncLibTest { val executor = Executors.newSingleThreadExecutor() val latch = CountDownLatch(1) val scheduler = Scheduler { state } - val mc = LuaTable() - AsyncLib(executor, state, scheduler).install(mc) - state.globals.set("mc", mc) + val asyncLib = AsyncLib(executor, state, scheduler) + state.globals.set("async", asyncLib.buildModule()) state.globals.set("block", luaFunctionNil { _ -> try { latch.await() @@ -43,9 +41,6 @@ class AsyncLibTest { return Env(state, executor, latch) } - // Runs a script in a coroutine. If the coroutine yields (async task pending), - // releases the latch and waits for the executor to resume it. Returns the - // `_result` global the script set before completing. private fun runScript(env: Env, script: String): LuaValue { val func = env.state.load(script, "test").checkfunction() val co = LuaThread(env.state, func) @@ -75,30 +70,20 @@ class AsyncLibTest { } } - private fun LuaTable.pair(): Pair = get(1).toboolean() to get(2) + // ── Task: done ───────────────────────────────────────────────── @Test - fun `task done is false while pending and true when complete`() { + fun `task done is false while pending`() { val env = newEnv() try { val pending = runScript( env, """ - local t = mc.task(function() block() return 42 end) + local t = async.task(function() block() return 42 end) _result = { t.done } """.trimIndent() ).checktable() assertFalse(pending.get(1).toboolean()) - - val done = runScript( - env, - """ - local t = mc.task(function() return 42 end) - while not t.done do end - _result = { t.done } - """.trimIndent() - ).checktable() - assertTrue(done.get(1).toboolean()) } finally { env.executor.shutdown() env.latch.countDown() @@ -106,40 +91,52 @@ class AsyncLibTest { } @Test - fun `pwait sync returns ok and result`() { + fun `task done is true when complete`() { + val done = newEnvAndRun( + """ + local t = async.task(function() return 42 end) + while not t.done do end + _result = { t.done } + """.trimIndent() + ) + assertTrue(done.get(1).toboolean()) + } + + // ── Task: wait ───────────────────────────────────────────────── + + @Test + fun `task wait sync returns raw result`() { val result = newEnvAndRun( """ - local t = mc.task(function() return 42 end) + local t = async.task(function() return 42 end) while not t.done do end - local ok, r = t:pwait() - _result = { ok, r } + local r = t:wait() + _result = { r } """.trimIndent() ) - assertTrue(result.get(1).toboolean()) - assertEquals(42, result.get(2).toint()) + assertEquals(42, result.get(1).toint()) } @Test - fun `pwait async yields and returns ok and result`() { + fun `task wait async yields and returns raw result`() { val result = newEnvAndRun( """ - local t = mc.task(function() block() return 42 end) - local ok, r = t:pwait() - _result = { ok, r } + local t = async.task(function() block() return 42 end) + local r = t:wait() + _result = { r } """.trimIndent() ) - assertTrue(result.get(1).toboolean()) - assertEquals(42, result.get(2).toint()) + assertEquals(42, result.get(1).toint()) } @Test - fun `pwait sync returns false and error message`() { + fun `task wait sync throws on error`() { val result = newEnvAndRun( """ - local t = mc.task(function() error("boom") end) + local t = async.task(function() error("boom") end) while not t.done do end - local ok, r = t:pwait() - _result = { ok, r } + local ok, err = pcall(function() return t:wait() end) + _result = { ok, err } """.trimIndent() ) assertFalse(result.get(1).toboolean()) @@ -147,51 +144,55 @@ class AsyncLibTest { } @Test - fun `pwait async returns false and error message`() { + fun `task wait async throws on error`() { val result = newEnvAndRun( """ - local t = mc.task(function() block() error("boom") end) - local ok, r = t:pwait() - _result = { ok, r } + local t = async.task(function() block() error("boom") end) + local ok, err = pcall(function() return t:wait() end) + _result = { ok, err } """.trimIndent() ) assertFalse(result.get(1).toboolean()) assertTrue(result.get(2).tojstring().contains("boom")) } + // ── Task: try ────────────────────────────────────────────────── + @Test - fun `wait sync returns raw result`() { + fun `task try sync returns ok and result`() { val result = newEnvAndRun( """ - local t = mc.task(function() return 42 end) + local t = async.task(function() return 42 end) while not t.done do end - local r = t:wait() - _result = { r } + local ok, r = t:try() + _result = { ok, r } """.trimIndent() ) - assertEquals(42, result.get(1).toint()) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) } @Test - fun `wait async yields and returns raw result`() { + fun `task try async yields and returns ok and result`() { val result = newEnvAndRun( """ - local t = mc.task(function() block() return 42 end) - local r = t:wait() - _result = { r } + local t = async.task(function() block() return 42 end) + local ok, r = t:try() + _result = { ok, r } """.trimIndent() ) - assertEquals(42, result.get(1).toint()) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) } @Test - fun `wait sync throws on task error`() { + fun `task try sync returns false and error message`() { val result = newEnvAndRun( """ - local t = mc.task(function() error("boom") end) + local t = async.task(function() error("boom") end) while not t.done do end - local ok, err = pcall(function() return t:wait() end) - _result = { ok, err } + local ok, r = t:try() + _result = { ok, r } """.trimIndent() ) assertFalse(result.get(1).toboolean()) @@ -199,23 +200,25 @@ class AsyncLibTest { } @Test - fun `wait async throws on task error`() { + fun `task try async returns false and error message`() { val result = newEnvAndRun( """ - local t = mc.task(function() block() error("boom") end) - local ok, err = pcall(function() return t:wait() end) - _result = { ok, err } + local t = async.task(function() block() error("boom") end) + local ok, r = t:try() + _result = { ok, r } """.trimIndent() ) assertFalse(result.get(1).toboolean()) assertTrue(result.get(2).tojstring().contains("boom")) } + // ── Task: rejects ────────────────────────────────────────────── + @Test fun `task rejects non-function argument`() { val result = newEnvAndRun( """ - local ok, err = pcall(mc.task, 42) + local ok, err = pcall(async.task, 42) _result = { ok, err } """.trimIndent() ) @@ -224,10 +227,10 @@ class AsyncLibTest { } @Test - fun `task pwait rejects non-task self`() { + fun `task wait rejects non-task self`() { val result = newEnvAndRun( """ - local ok, err = pcall(function() return ("nope"):pwait() end) + local ok, err = pcall(function() return ("nope"):wait() end) _result = { ok, err } """.trimIndent() ) @@ -235,22 +238,25 @@ class AsyncLibTest { } @Test - fun `prun yields and returns ok and result`() { + fun `task try rejects non-task self`() { val result = newEnvAndRun( """ - local ok, r = mc.prun(function() block() return 7 end) - _result = { ok, r } + local ok, err = pcall(function() return ("nope"):try() end) + _result = { ok, err } """.trimIndent() ) - assertTrue(result.get(1).toboolean()) - assertEquals(7, result.get(2).toint()) + assertFalse(result.get(1).toboolean()) } + // ── Promise ──────────────────────────────────────────────────── + @Test - fun `run sync returns raw result`() { + fun `promise resolve and wait`() { val result = newEnvAndRun( """ - local r = mc.run(function() return 42 end) + local p = async.promise() + p:resolve(42) + local r = p:wait() _result = { r } """.trimIndent() ) @@ -258,37 +264,137 @@ class AsyncLibTest { } @Test - fun `run async yields and returns raw result`() { + fun `promise error and try`() { val result = newEnvAndRun( """ - local r = mc.run(function() block() return 42 end) - _result = { r } + local p = async.promise() + p:error("boom") + local ok, msg = p:try() + _result = { ok, msg } """.trimIndent() ) - assertEquals(42, result.get(1).toint()) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) } @Test - fun `run sync throws on error`() { + fun `promise resolve returns true`() { val result = newEnvAndRun( """ - local ok, err = pcall(function() return mc.run(function() error("boom") end) end) - _result = { ok, err } + local p = async.promise() + local ok = p:resolve(42) + _result = { ok } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + } + + @Test + fun `promise double resolve returns false`() { + val result = newEnvAndRun( + """ + local p = async.promise() + p:resolve(42) + local ok = p:resolve(99) + _result = { ok } """.trimIndent() ) assertFalse(result.get(1).toboolean()) - assertTrue(result.get(2).tojstring().contains("boom")) } @Test - fun `run async throws on error`() { + fun `promise state transitions`() { + val result = newEnvAndRun( + """ + local p = async.promise() + local s1 = p.state + p:resolve(42) + local s2 = p.state + _result = { s1, s2 } + """.trimIndent() + ) + assertEquals("pending", result.get(1).tojstring()) + assertEquals("resolved", result.get(2).tojstring()) + } + + @Test + fun `promise done property`() { val result = newEnvAndRun( """ - local ok, err = pcall(function() return mc.run(function() block() error("boom") end) end) + local p = async.promise() + local d1 = p.done + p:resolve(42) + local d2 = p.done + _result = { d1, d2 } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).toboolean()) + } + + // ── all / allSettled ─────────────────────────────────────────── + + @Test + fun `all returns results for all tasks`() { + val result = newEnvAndRun( + """ + local t1 = async.task(function() return 1 end) + local t2 = async.task(function() return 2 end) + while not t1.done do end + while not t2.done do end + local results = async.all(t1, t2) + _result = { results[1].ok, results[1].value, results[2].ok, results[2].value } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(1, result.get(2).toint()) + assertTrue(result.get(3).toboolean()) + assertEquals(2, result.get(4).toint()) + } + + @Test + fun `all rejects when any task fails`() { + val result = newEnvAndRun( + """ + local t1 = async.task(function() return 1 end) + local t2 = async.task(function() error("boom") end) + while not t1.done do end + while not t2.done do end + local ok, err = pcall(function() return async.all(t1, t2) end) _result = { ok, err } """.trimIndent() ) assertFalse(result.get(1).toboolean()) assertTrue(result.get(2).tojstring().contains("boom")) } + + @Test + fun `allSettled returns results for all tasks`() { + val result = newEnvAndRun( + """ + local t1 = async.task(function() return 1 end) + local t2 = async.task(function() error("boom") end) + while not t1.done do end + while not t2.done do end + local results = async.allSettled(t1, t2) + _result = { results[1].ok, results[1].value, results[2].ok, results[2].error } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(1, result.get(2).toint()) + assertFalse(result.get(3).toboolean()) + assertTrue(result.get(4).tojstring().contains("boom")) + } + + @Test + fun `all with empty input returns empty table`() { + val result = newEnvAndRun( + """ + local results = async.all() + _result = { results } + """.trimIndent() + ) + assertTrue(result.get(1).istable()) + assertEquals(0, result.get(1).checktable().length().toInt()) + } } From d006a5a4a09f0d3d8a4d0e7c4eea978b1eb97a1f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 10:16:56 +0300 Subject: [PATCH 12/14] feat: async executor registry, thread pool, mutex, serialized resumer + docs - AsyncExecutor registry + PxIgnis-async- thread pool - coroutine-safe async.mutex with FIFO queuing - SerializedResumer hardening for one-at-a-time coroutine resumes - LuaThread.executionContext for executor propagation - thread-safe Scheduler and SERVER_STOPPING lifecycle shutdown - rewrite async-api.md reference around executors and I/O vs parallel work --- .../src/main/java/org/luaj/vm2/LuaThread.java | 16 + site/src/content/docs/changelog.md | 2 - site/src/content/docs/reference/async-api.md | 410 +++++-- src/main/java/ru/pyxiion/ignis/PxIgnis.kt | 4 + src/main/java/ru/pyxiion/ignis/Scheduler.kt | 61 +- .../ru/pyxiion/ignis/api/AsyncExecutor.kt | 45 + .../java/ru/pyxiion/ignis/api/AsyncLib.kt | 448 ++++++- .../java/ru/pyxiion/ignis/api/LuaMcApi.kt | 36 +- .../kotlin/ru/pyxiion/ignis/AsyncLibTest.kt | 1066 ++++++++++++++++- 9 files changed, 1894 insertions(+), 194 deletions(-) create mode 100644 src/main/java/ru/pyxiion/ignis/api/AsyncExecutor.kt diff --git a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java index 93f4a3e..7da35dd 100644 --- a/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java +++ b/pxluanova/pxluanova-core/src/main/java/org/luaj/vm2/LuaThread.java @@ -137,6 +137,11 @@ public interface ResumeHandler { * completes. Inherited from the parent (or main) thread at construction time. */ public volatile ResumeHandler resumeHandler; + /** Generic execution context for this thread. Opaque to the core runtime; + * the host application may store any object here (e.g. an executor). + * Inherited from the parent (or main) thread at construction time. */ + public volatile Object executionContext; + Throwable lastError = null; /** Whether this thread runs synchronously on the calling thread. @@ -162,6 +167,7 @@ public LuaThread(LuaState state, LuaValue func) { this.state = state; this.isSync = true; // may support async in future this.resumeHandler = resolveResumeHandler(state); + this.executionContext = resolveExecutionContext(state); inheritHook(); } @@ -175,6 +181,16 @@ private ResumeHandler resolveResumeHandler(LuaState state) { return null; } + private Object resolveExecutionContext(LuaState state) { + LuaThread parent = state.getCurrentThread(); + if (parent != null && parent.executionContext != null) + return parent.executionContext; + LuaThread main = state.getMainThread(); + if (main != null && main.executionContext != null) + return main.executionContext; + return null; + } + private void inheritHook() { LuaThread parent = state.getCurrentThread(); if (parent != null && parent.threadState != null) { diff --git a/site/src/content/docs/changelog.md b/site/src/content/docs/changelog.md index 0a8d7dd..8e97dff 100644 --- a/site/src/content/docs/changelog.md +++ b/site/src/content/docs/changelog.md @@ -3,8 +3,6 @@ title: Changelog description: Release history for PxIgnis. --- -# Changelog - ## Unreleased — Async coroutines ### Breaking diff --git a/site/src/content/docs/reference/async-api.md b/site/src/content/docs/reference/async-api.md index dd5a2d5..61b7d7f 100644 --- a/site/src/content/docs/reference/async-api.md +++ b/site/src/content/docs/reference/async-api.md @@ -1,126 +1,378 @@ --- title: Async API -description: Coroutine-based HTTP requests and tick delays with mc.fetch and mc.sleep. +description: Non-blocking I/O, parallel Lua work, tasks, promises, and coroutine synchronization. --- -Coroutine-based async operations. Under the hood, `mc.fetch` and `mc.sleep` -yield the execution and resume when the operation completes. +`async` has two different jobs: -## mc.sleep(ticks) +- **Wait for I/O** without blocking the server or a worker: `async.fetch()` and `async.sleep()`. +- **Run Lua code on a selected executor**: `async.task()` and `async.run()`. -Yields the current coroutine and resumes after the specified number of ticks (20 ticks = 1 second). +Load it with: ```lua -mc.broadcast("Wait for 2 seconds...") -mc.sleep(40) -mc.broadcast("Done!") +local async = require "async" ``` -## mc.fetch(url) +## Choosing an API -Simple GET request. Returns a response table. +| Goal | Use | +|---|---| +| Wait for an HTTP response | `async.fetch(url)` | +| Wait for a number of ticks | `async.sleep(ticks)` | +| Run expensive Lua code in parallel | `async.task("threadpool", fn)` | +| Run Lua code on the Minecraft server thread | `async.task("main", fn)` | +| Run work and wait for its result | `async.run(executor, fn)` | +| Coordinate manually-settled work | `async.promise()` | +| Protect a shared Lua section | `async.mutex()` | + +Do not put `async.fetch()` in a thread-pool task just to make the request asynchronous. HTTP requests are already non-blocking: ```lua -local res = mc.fetch("https://api.example.com/data") +local response = async.fetch("https://example.com/data") +``` -if res.ok then - mc.broadcast(res.text) -else - mc.broadcast("Error: " .. res.error) +Use the thread pool for CPU-heavy Lua work. + +## Executors + +Every task requires an executor: + +| Executor | Intended use | +|---|---| +| `"main"` | Minecraft APIs, entities, worlds, players, inventories, and other server state | +| `"threadpool"` | Expensive pure-Lua calculations and independent background work | + +`"main"` runs Lua on the Minecraft server thread. Keep it short. A long calculation there pauses the server tick. + +`"threadpool"` runs Lua away from the server thread. Do not access Minecraft objects from it: + +```lua +-- Safe: pure computation +local task = async.task("threadpool", function() + return generate_mesh(input) +end) + +-- Unsafe: Minecraft state belongs on the main executor +async.task("threadpool", function() + player:sendMessage("hello") +end) +``` + +## `async.run(executor, fn, ...)` + +Runs a function on the selected executor and waits for its result. All return values are preserved, and errors are thrown in the calling coroutine. + +```lua +local total = async.run("threadpool", function(a, b) + return expensive_calculation(a, b) +end, 10, 20) +``` + +Use `"main"` when the function must interact with Minecraft: + +```lua +async.run("main", function() + player:sendMessage("The calculation is complete") +end) +``` + +`async.run()` must be called from a coroutine because it may yield while waiting. + +## `async.task(executor, fn, ...)` + +Starts a function and immediately returns a task object: + +```lua +local task = async.task("threadpool", function() + return expensive_calculation() +end) + +local result = task:wait() +``` + +The function receives the arguments after `fn`: + +```lua +local task = async.task("threadpool", function(x) + return x * 2 +end, 21) +``` + +### Task methods and properties + +#### `task:wait()` + +Waits for completion and returns the raw result values. If the task fails, it throws the task error. + +```lua +local value = async.task("threadpool", function() + return 42 +end):wait() +``` + +#### `task:try()` + +Waits for completion and returns `true, result...` on success or `false, error` on failure. + +```lua +local ok, value = async.task("threadpool", function() + return risky_calculation() +end):try() + +if not ok then + print("Calculation failed: " .. value) end ``` -## mc.fetch {...} +#### `task.done` -Full request with options: +`true` after the task has resolved or rejected. -| Option | Type | Default | Description | -|-----------|--------|---------|----------------------------------------------------------------| -| `url` | string | — | Request URL (required) | -| `method` | string | `"GET"` | HTTP method | -| `headers` | table | `{}` | Custom headers | -| `body` | string | `nil` | Raw request body | -| `json` | table | `nil` | Auto-encodes to JSON and sets `Content-Type: application/json` | -| `timeout` | number | `10` | Timeout in seconds | +#### `task.state` -`body` & `json` are obviously mutually exclusive. +One of: + +- `"pending"` +- `"resolved"` +- `"rejected"` + +#### `task.type` + +Returns `"task"` for task objects and `"promise"` for promise objects. + +Tasks can call other coroutine-aware operations while running: ```lua -local res = mc.fetch { - url = "https://api.example.com/data", - method = "POST", - json = { key = "value" }, - headers = { Authorization = "Bearer token" }, - timeout = 10 +local task = async.task("threadpool", function() + async.sleep(20) + local response = async.fetch("https://example.com/data") + return response.json +end) +``` + +## Parallel work + +Start independent calculations first, then wait for all of them: + +```lua +local left = async.task("threadpool", function() + return generate_chunk(1) +end) + +local right = async.task("threadpool", function() + return generate_chunk(2) +end) + +local results = async.all(left, right) +local left_chunk = results[1].value +local right_chunk = results[2].value +``` + +The calculations can run concurrently. Apply the resulting data through the main executor if it touches Minecraft: + +```lua +async.run("main", function() + apply_chunk(left_chunk) + apply_chunk(right_chunk) +end) +``` + +## `async.all(...)` + +Waits for every task or promise. It accepts either varargs or an array-like table: + +```lua +local results = async.all(task1, task2) +-- or: +local results = async.all { task1, task2 } +``` + +It throws the first rejection after all inputs have settled. On success, it returns an array of result entries: + +```lua +{ + { ok = true, value = first_value }, + { ok = true, value = second_value } } ``` -## Response Table +The result entry currently stores the first returned value. Use individual `task:wait()` calls when you need multiple return values from each task. -| Field | Type | Description | -|---------------|---------------|-------------------------------------------| -| `res.ok` | boolean | `true` if status is 2xx | -| `res.status` | number | HTTP status code | -| `res.text` | string | Response body as string | -| `res.headers` | table | Response headers | -| `res.json` | table or nil | Lazy-parsed JSON (parsed on first access) | -| `res.error` | string or nil | Error message if the request failed | +## `async.allSettled(...)` + +Waits for every input and never throws because of a rejected task: ```lua -local res = mc.fetch("https://api.github.com/repos/user/repo") -if res.ok then - local data = res.json - mc.broadcast("Stars: " .. data.stargazers_count) +local results = async.allSettled(task1, task2) + +for i, result in ipairs(results) do + if result.ok then + print(i, result.value) + else + print(i, "failed: " .. result.error) + end end ``` -## Example +Each entry has either: ```lua -register("fetch", function(ctx) - -- Step 1: fetch a post - local post = mc.fetch("https://jsonplaceholder.typicode.com/posts/1") - local postData = post.json - - -- Step 2: wait a tick - mc.sleep(1) - - -- Step 3: fetch comments - local comments = mc.fetch("https://jsonplaceholder.typicode.com/posts/" .. postData.id .. "/comments") - - ctx.player:sendMessage("Fetched " .. #comments.json .. " comments") -end) +{ ok = true, value = value } ``` -## Where does it work? +or: -`mc.sleep` and `mc.fetch` only work inside **coroutines**, i.e. **command handlers** (`register(...)`) and -**scheduled callbacks** (`mc.schedule`, `mc.scheduleRepeating`). They do **not** work inside -event handlers (`mc.on(...)`), which run on the main thread and cannot be yielded. +```lua +{ ok = false, error = "error message" } +``` + +## Promises + +`async.promise()` creates an awaitable that you settle manually: -If you need async behaviour in an event, use these: ```lua -mc.schedule(0, function() - p:sendMessage("Called in the next tick") - mc.sleep(20) -- yay +local promise = async.promise() + +mc.schedule(20, function() + promise:resolve("finished") end) --- OR +print(promise:wait()) +``` + +### `promise:resolve(...)` + +Resolves the promise and returns `true` if this was the first settlement. + +### `promise:error(message)` + +Rejects the promise and returns `true` if this was the first settlement. + +The first settlement wins: + +```lua +local promise = async.promise() + +print(promise:resolve(1)) -- true +print(promise:resolve(2)) -- false +``` + +Promises also expose `done`, `state`, `wait()`, and `try()`. + +## `async.sleep(ticks)` + +Suspends the current coroutine for a number of server ticks. Twenty ticks is approximately one second. + +```lua +async.sleep(40) +print("Two seconds passed") +``` + +It does not block the executor thread. The coroutine resumes automatically; do not call `coroutine.resume()` yourself. + +## `async.fetch(url)` + +Sends a GET request and suspends the current coroutine until the response arrives: + +```lua +local response = async.fetch("https://api.example.com/data") + +if response.ok then + print(response.text) +else + print("HTTP request failed: " .. (response.error or "unknown error")) +end +``` + +### `async.fetch(options)` -coroutine.wrap(function() - p:sendMessage("Called immediately") - mc.sleep(20) -end)() +```lua +local response = async.fetch { + url = "https://api.example.com/data", + method = "POST", + headers = { + Authorization = "Bearer token" + }, + json = { key = "value" }, + timeout = 10 +} ``` -**Important**: you **MUST NOT** resume coroutines that were yielded by these async functions. They shall be resumed -automatically. +| Option | Type | Default | Description | +|---|---|---:|---| +| `url` | string | required | Request URL | +| `method` | string | `"GET"` | HTTP method | +| `headers` | table | `{}` | Request headers | +| `body` | string | `nil` | Raw request body | +| `json` | table | `nil` | JSON request body; sets `Content-Type` when absent | +| `timeout` | number | `10` | Timeout in seconds | + +`body` and `json` are mutually exclusive. + +### Response fields -## Lifecycle +| Field | Type | Description | +|---|---|---| +| `ok` | boolean | `true` for HTTP status codes in the 2xx range | +| `status` | number or nil | HTTP status code when a response was received | +| `text` | string or nil | Response body | +| `headers` | table or nil | Response headers | +| `json` | table or nil | Lazily parsed JSON body | +| `error` | string or nil | Network or request error | + +HTTP errors such as 404 are returned as responses with `ok = false`. Transport failures also return `ok = false`, but may not have a status code. + +## Mutexes + +`async.mutex()` creates a coroutine-friendly mutex: + +```lua +--# nova syntax + +local mutex = async.mutex() + +local value = mutex:with \{ + return update_shared_cache() +} +``` -All pending coroutines (sleeps, in-flight HTTP requests) are **discarded** on `/ignis reload`. +Only one callback owns the mutex at a time. Waiting callbacks yield instead of blocking a Java thread. The callback may use `async.sleep()`, `async.fetch()`, or `task:wait()`. -Don't use `mc.sleep` for critical mechanics (e.g. ban durations or daily bonuses). For long pauses, -save timestamps in [persistent storage](/reference/storage). +The mutex is released when the callback returns or throws: + +```lua +--# nova syntax + +local ok, err = pcall \{ + mutex:with \{ + error("the lock is still released") + } +} +``` + +Mutexes are non-reentrant. Avoid waiting for another mutex while holding one, because that can deadlock. + +## Coroutine requirements + +`async.sleep()`, `async.fetch()`, `task:wait()`, `task:try()`, `async.run()`, `async.all()`, `async.allSettled()`, and `mutex:with()` may yield. They require a coroutine-backed execution context. + +Commands, scheduled callbacks, tasks, and coroutine-backed event handlers can use these APIs. The runtime resumes suspended coroutines automatically. + +Do not manually resume a coroutine suspended by an `async` operation. + +## Minecraft API boundary + +Keep Minecraft work on the main executor and calculation work on the thread pool: + +```lua +local generated = async.run("threadpool", function() + return generate_data() +end) + +async.run("main", function() + place_generated_data(generated) +end) +``` -`mc.sleep` is ideal for short delays: animations, spell casting, etc. \ No newline at end of file +Do not pass live Minecraft objects into thread-pool code and access them there. Extract plain data on the main executor first, then pass the data to the calculation. diff --git a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt index 3e701f6..a96ceaa 100644 --- a/src/main/java/ru/pyxiion/ignis/PxIgnis.kt +++ b/src/main/java/ru/pyxiion/ignis/PxIgnis.kt @@ -109,6 +109,10 @@ class PxIgnis : ModInitializer { } } catch (_: UninitializedPropertyAccessException) { } + try { + runtime.api.shutdownAsync() + } catch (_: UninitializedPropertyAccessException) { + } storageManager?.close() }) diff --git a/src/main/java/ru/pyxiion/ignis/Scheduler.kt b/src/main/java/ru/pyxiion/ignis/Scheduler.kt index 985a3ff..b9580a6 100644 --- a/src/main/java/ru/pyxiion/ignis/Scheduler.kt +++ b/src/main/java/ru/pyxiion/ignis/Scheduler.kt @@ -7,31 +7,43 @@ import org.luaj.vm2.LuaState import org.luaj.vm2.LuaThread import org.luaj.vm2.LuaValue import java.util.PriorityQueue +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock class Scheduler(private val stateProvider: () -> LuaState) { private companion object { private const val MAX_TASKS_PER_TICK = 1024 } + private val lock = ReentrantLock() private var nextId = 0 + @Volatile var currentTick = 0L private val tasks = PriorityQueue(compareBy { it.fireAtTick }) private val cancelledIds = HashSet() fun tick() { - currentTick++ + val due = ArrayList() + lock.withLock { + currentTick++ + var processed = 0 + while (tasks.isNotEmpty() && tasks.peek().fireAtTick <= currentTick && processed < MAX_TASKS_PER_TICK) { + val task = tasks.poll() + processed++ - val state = stateProvider() - var processed = 0 - - while (tasks.isNotEmpty() && tasks.peek().fireAtTick <= currentTick && processed < MAX_TASKS_PER_TICK) { - val task = tasks.poll() - processed++ + if (task.id in cancelledIds) { + cancelledIds.remove(task.id) + continue + } - if (task.id in cancelledIds) { - cancelledIds.remove(task.id) - continue + if (task.repeating && task.interval > 0) { + tasks.offer(task.copy(fireAtTick = task.fireAtTick + task.interval)) + } + due.add(task) } + } + val state = stateProvider() + for (task in due) { try { val cb = task.callback if (cb is LuaClosure) { @@ -42,40 +54,37 @@ class Scheduler(private val stateProvider: () -> LuaState) { } catch (e: LuaError) { PxIgnis.logger.error("Ошибка в задании планировщика #${task.id}: ${e.message}", e) } - - if (task.repeating && task.interval > 0) { - tasks.offer(task.copy(fireAtTick = task.fireAtTick + task.interval)) - } } } - fun schedule(delay: Int, callback: LuaFunction): Int { + fun schedule(delay: Int, callback: LuaFunction): Int = lock.withLock { val id = nextId++ tasks.offer(ScheduledTask(id, currentTick + delay.coerceAtLeast(0), 0, false, callback)) - return id + id } - fun scheduleRepeating(delay: Int, interval: Int, callback: LuaFunction): Int { + fun scheduleRepeating(delay: Int, interval: Int, callback: LuaFunction): Int = lock.withLock { val id = nextId++ - val safeInterval = interval.coerceAtLeast(1) tasks.offer( ScheduledTask(id, currentTick + delay.coerceAtLeast(0), safeInterval, true, callback) ) - return id + id } - fun cancel(id: Int): Boolean { - if (id >= nextId) return false - if (id in cancelledIds) return false + fun cancel(id: Int): Boolean = lock.withLock { + if (id >= nextId) return@withLock false + if (id in cancelledIds) return@withLock false cancelledIds.add(id) - return true + true } fun clear() { - tasks.clear() - cancelledIds.clear() - nextId = 0 + lock.withLock { + tasks.clear() + cancelledIds.clear() + nextId = 0 + } } } diff --git a/src/main/java/ru/pyxiion/ignis/api/AsyncExecutor.kt b/src/main/java/ru/pyxiion/ignis/api/AsyncExecutor.kt new file mode 100644 index 0000000..802d964 --- /dev/null +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncExecutor.kt @@ -0,0 +1,45 @@ +package ru.pyxiion.ignis.api + +import org.luaj.vm2.LuaError +import java.util.concurrent.ConcurrentHashMap + +data class AsyncExecutor( + val name: String, + val dispatch: (Runnable) -> Unit, + val shutdown: (() -> Unit)? = null, +) + +class AsyncExecutorRegistry { + private val executors = ConcurrentHashMap() + @Volatile + private var shutDown = false + + fun register(executor: AsyncExecutor) { + if (shutDown) { + throw IllegalStateException("Cannot register executor after registry shutdown") + } + val existing = executors.putIfAbsent(executor.name, executor) + if (existing != null) { + throw IllegalArgumentException("Duplicate executor name: '${executor.name}'") + } + } + + fun resolve(name: String): AsyncExecutor { + if (shutDown) { + throw LuaError( + "executor registry is shut down; cannot start new work on '$name'" + ) + } + return executors[name] ?: throw LuaError( + "unknown executor '$name'. Available: ${names().joinToString(", ") { "'$it'" }}" + ) + } + + fun names(): Set = executors.keys.toSet() + + fun shutdown() { + if (shutDown) return + shutDown = true + executors.values.forEach { it.shutdown?.invoke() } + } +} diff --git a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt index 42656df..5fe31a7 100644 --- a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt @@ -10,18 +10,117 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration import java.util.concurrent.CompletableFuture -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock class AsyncLib( - private val executor: Executor, + private val executors: AsyncExecutorRegistry, private val luaState: LuaState, - private val scheduler: Scheduler + private val scheduler: Scheduler, ) { + // ── Executor resolution ────────────────────────────────────────── + + private fun requireExecutor(value: LuaValue): AsyncExecutor { + val name = value.checkjstring() + ?: throw LuaError("async: expected executor name (string), got ${value.typename()}") + return executors.resolve(name) + } + + // ── Serialized resumer ──────────────────────────────────────────── + // Serializes all resume requests for one LuaThread through a single + // selected executor. Only one thread.resume() runs at a time; requests + // that arrive while a resume is in flight are queued and dispatched + // after the current resume returns. Requests for dead coroutines drop. + // If the resume callback throws or the executor rejects the dispatch, + // the owner is notified once through onFailure and queued resumes drop. + + internal class SerializedResumer( + private val thread: LuaThread, + private val executor: AsyncExecutor, + private val resume: (Varargs) -> Unit, + private val onFailure: (Throwable) -> Unit, + ) { + private val lock = ReentrantLock() + private var running = false + private var failed = false + private val pending = ArrayDeque() + + /** Submit a resume request. Dispatches immediately if idle, queues otherwise. */ + fun requestResume(args: Varargs) { + var runNow = false + lock.withLock { + if (failed || thread.status == "dead") return + if (running) { + pending.addLast(args) + return + } + running = true + runNow = true + } + if (runNow) dispatch(args) + } + + private fun dispatch(args: Varargs) { + try { + executor.dispatch { runResume(args) } + } catch (e: Throwable) { + fail(e) + } + } + + private fun runResume(args: Varargs) { + var error: Throwable? = null + try { + resume(args) + } catch (e: Throwable) { + error = e + } + var next: Varargs? = null + lock.withLock { + if (error != null) { + failed = true + pending.clear() + running = false + } else if (thread.status == "dead") { + pending.clear() + running = false + } else if (pending.isNotEmpty()) { + next = pending.removeFirst() + } else { + running = false + } + } + if (error != null) { + notifyFailure(error) + return + } + if (next != null) dispatch(next) + } + + private fun fail(e: Throwable) { + var notify: Throwable? = null + lock.withLock { + if (!failed) { + failed = true + pending.clear() + running = false + notify = e + } + } + if (notify != null) notifyFailure(notify) + } + + private fun notifyFailure(e: Throwable) { + try { + onFailure(e) + } catch (_: Throwable) { + // The failure handler must not re-enter or break the resumer. + } + } + } + // ── Awaitable ────────────────────────────────────────────────────── class Awaitable { @@ -148,7 +247,7 @@ class AsyncLib( ?: throw LuaError("wait: must be called inside a coroutine") awaitable.await { _, _, _ -> - executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.wait callback") } + resumeThread(coro, LuaValue.NONE, "async.wait callback") } throw YieldContinuationException(this, args, awaitable) } @@ -187,66 +286,113 @@ class AsyncLib( ?: throw LuaError("try: must be called inside a coroutine") awaitable.await { state, _, error -> - executor.execute { - val result = if (state == Awaitable.STATE_RESOLVED) { - LuaValue.varargsOf(LuaValue.TRUE, awaitable.getOrNull() ?: LuaValue.NONE) - } else { - LuaValue.varargsOf( - LuaValue.FALSE, - LuaValue.valueOf(error?.message ?: "unknown error") - ) - } - coro.resumeOrLog(result, "async.try callback") + val result = if (state == Awaitable.STATE_RESOLVED) { + LuaValue.varargsOf(LuaValue.TRUE, awaitable.getOrNull() ?: LuaValue.NONE) + } else { + LuaValue.varargsOf( + LuaValue.FALSE, + LuaValue.valueOf(error?.message ?: "unknown error") + ) } + resumeThread(coro, result, "async.try callback") } throw YieldContinuationException(this, args, awaitable) } } } + // ── Task execution helpers ──────────────────────────────────────── + + private fun resumeTask(thread: LuaThread, args: Varargs, awaitable: Awaitable) { + val previous = LuaState.current() + LuaState.setCurrent(luaState) + try { + val result = thread.resume(args) + if (thread.status == "dead") { + if (result.arg1().toboolean()) { + awaitable.resolve(result.subargs(2)) + } else { + awaitable.reject(LuaError(result.arg(2).optjstring("task error"))) + } + } + } catch (e: Throwable) { + awaitable.reject(e) + } finally { + LuaState.setCurrent(previous) + } + } + + private fun createTaskInternal(executor: AsyncExecutor, f: LuaFunction, taskArgs: Varargs): Awaitable { + val awaitable = Awaitable() + val thread = LuaThread(luaState, f) + thread.executionContext = executor + + val resumer = SerializedResumer( + thread, + executor, + resume = { args -> resumeTask(thread, args, awaitable) }, + onFailure = { e -> awaitable.reject(e) }, + ) + thread.resumeHandler = LuaThread.ResumeHandler { _, value -> + resumer.requestResume(value) + } + + resumer.requestResume(taskArgs) + return awaitable + } + // ── Task ─────────────────────────────────────────────────────────── private fun handleTask(args: Varargs): Varargs { - val f = args.arg(1).asFunction() - ?: throw LuaError("async.task: expected function, got ${args.arg(1).typename()}") - val taskArgs = args.subargs(2) - val awaitable = Awaitable() + val executor = requireExecutor(args.arg(1)) + val f = args.arg(2).asFunction() + ?: throw LuaError("async.task: expected function, got ${args.arg(2).typename()}") + val taskArgs = args.subargs(3) - executor.execute { - LuaState.setCurrent(luaState) - try { - val thread = LuaThread(luaState, f) - - thread.resumeHandler = LuaThread.ResumeHandler { t, value -> - val result = t.resume(value) - if (t.status == "dead") { - if (result.arg1().toboolean()) { - awaitable.resolve(result.subargs(2)) - } else { - awaitable.reject( - LuaError(result.arg(2).optjstring("task error")) - ) - } - } - result - } + val awaitable = createTaskInternal(executor, f, taskArgs) + return wrapObject(awaitable, "task") + } + + // ── async.run ────────────────────────────────────────────────────── + + private fun handleRun(args: Varargs): Varargs { + val executor = requireExecutor(args.arg(1)) + val f = args.arg(2).asFunction() + ?: throw LuaError("async.run: expected function, got ${args.arg(2).typename()}") + val taskArgs = args.subargs(3) + + val awaitable = createTaskInternal(executor, f, taskArgs) + + if (awaitable.isDone) { + return if (awaitable.state == Awaitable.STATE_RESOLVED) { + awaitable.get() + } else { + throw awaitable.error ?: LuaError("async.run: task failed") + } + } + + val coro = luaState.currentThread + ?: throw LuaError("async.run: must be called inside a coroutine") - val result = thread.resume(taskArgs) - if (thread.status == "dead") { - if (result.arg1().toboolean()) { - awaitable.resolve(result.subargs(2)) + awaitable.await { _, _, _ -> + resumeThread(coro, LuaValue.NONE, "async.run callback") + } + throw YieldContinuationException(runContinuable, args, awaitable) + } + + private val runContinuable: LuaFunction by lazy { + object : LuaContinuableFunction() { + override fun invoke(args: Varargs, continuation: Awaitable?): Varargs { + if (continuation != null) { + if (continuation.state == Awaitable.STATE_RESOLVED) { + return continuation.get() } else { - awaitable.reject(LuaError(result.arg(2).optjstring("task error"))) + throw continuation.error ?: LuaError("async.run: task failed") } } - } catch (e: Throwable) { - awaitable.reject(e) - } finally { - LuaState.setCurrent(null) + return handleRun(args) } } - - return wrapObject(awaitable, "task") } // ── Promise ──────────────────────────────────────────────────────── @@ -312,7 +458,7 @@ class AsyncLib( } allFuture.thenRun { - executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.all callback") } + resumeThread(coro, LuaValue.NONE, "async.all callback") } throw YieldContinuationException(this@AsyncLib.allContinuable, args, allFuture) } @@ -368,7 +514,7 @@ class AsyncLib( } allFuture.thenRun { - executor.execute { coro.resumeOrLog(LuaValue.NONE, "async.allSettled callback") } + resumeThread(coro, LuaValue.NONE, "async.allSettled callback") } throw YieldContinuationException(this@AsyncLib.allSettledContinuable, args, allFuture) } @@ -444,23 +590,210 @@ class AsyncLib( return LuaValue.NIL } - // ── Resume helper (goes through handler if present) ───────────────── + // ── Mutex ────────────────────────────────────────────────────────── - private fun resumeThread(thread: LuaThread, args: Varargs, context: String) { - val handler = thread.resumeHandler - if (handler != null) { - handler.resume(thread, args) - } else { - thread.resumeOrLog(args, context) + private class MutexOperation( + val parentThread: LuaThread, + val executor: AsyncExecutor, + val function: LuaFunction, + val args: Varargs, + ) { + var continuation: MutexContinuation? = null + } + + private class MutexContinuation( + val mutex: MutexState, + val op: MutexOperation, + var result: Varargs = LuaValue.NONE, + var error: Throwable? = null, + ) { + private val _finished = java.util.concurrent.atomic.AtomicBoolean(false) + val isFinished: Boolean get() = _finished.get() + fun tryFinish(): Boolean = _finished.compareAndSet(false, true) + } + + private class MutexState { + private val lock = ReentrantLock() + private var _owner: MutexOperation? = null + private val _waiters = ArrayDeque() + + fun tryAcquire(op: MutexOperation): Boolean { + lock.withLock { + if (_owner == null) { + _owner = op + return true + } + _waiters.addLast(op) + return false + } + } + + fun releaseAndResumeNext(completedOp: MutexOperation, onAcquire: (MutexOperation) -> Unit) { + val nextOp: MutexOperation? + lock.withLock { + if (_owner !== completedOp) return + _owner = null + nextOp = if (_waiters.isNotEmpty()) { + val next = _waiters.removeFirst() + _owner = next + next + } else { + null + } + } + if (nextOp != null) { + onAcquire(nextOp) + } + } + } + + private class MutexObject( + val state: MutexState, + ) + + private val MUTEX_METATABLE by lazy { + luaTableOf().also { mt -> + mt.set("__index", luaFunction { self, key -> + val obj = self.asObject() + ?: throw LuaError("expected mutex, got ${self.typename()}") + when (key.optjstring(null)) { + "with" -> mutexWithFn + else -> mt.get(key) + } + }) + } + } + + private fun wrapMutex(state: MutexState): LuaValue { + return LuaValue.userdataOf(MutexObject(state), MUTEX_METATABLE) + } + + private fun handleMutex(@Suppress("UNUSED_PARAMETER") args: Varargs): Varargs { + return wrapMutex(MutexState()) + } + + private fun releaseMutex(mutex: MutexState, completedOp: MutexOperation) { + mutex.releaseAndResumeNext(completedOp) { nextOp -> + try { + nextOp.executor.dispatch { + runMutexCallback(nextOp, mutex) + } + } catch (e: Throwable) { + // The executor rejected the promotion; fail the promoted waiter + // so its parent is resumed with an error instead of hanging. + val continuation = nextOp.continuation + ?: return@releaseAndResumeNext + finishMutexOperation(continuation, LuaValue.NONE, e) + } } } + private fun finishMutexOperation(continuation: MutexContinuation, result: Varargs, error: Throwable?) { + if (!continuation.tryFinish()) return + continuation.result = result + continuation.error = error + releaseMutex(continuation.mutex, continuation.op) + resumeThread(continuation.op.parentThread, LuaValue.NONE, "mutex callback completion") + } + + private fun resumeMutexChild(childThread: LuaThread, args: Varargs, continuation: MutexContinuation) { + val previous = LuaState.current() + LuaState.setCurrent(luaState) + try { + val result = childThread.resume(args) + if (childThread.status == "dead") { + if (result.arg1().toboolean()) { + finishMutexOperation(continuation, result.subargs(2), null) + } else { + finishMutexOperation( + continuation, + LuaValue.NONE, + LuaError(result.arg(2).optjstring("mutex callback error")) + ) + } + } + } finally { + LuaState.setCurrent(previous) + } + } + + private fun runMutexCallback(op: MutexOperation, mutex: MutexState): Varargs? { + val continuation = op.continuation + ?: error("mutex operation has no continuation") + + val childThread = LuaThread(luaState, op.function) + childThread.executionContext = op.executor + + val resumer = SerializedResumer( + childThread, + op.executor, + resume = { args -> resumeMutexChild(childThread, args, continuation) }, + onFailure = { e -> finishMutexOperation(continuation, LuaValue.NONE, e) }, + ) + childThread.resumeHandler = LuaThread.ResumeHandler { _, value -> + resumer.requestResume(value) + } + + resumer.requestResume(op.args) + return null + } + + private fun handleMutexWith(args: Varargs): Varargs { + val mutexObj = args.arg(1).asObject() + ?: throw LuaError("mutex:with: expected mutex, got ${args.arg(1).typename()}") + val mutex = mutexObj.state + val fn = args.arg(2).asFunction() + ?: throw LuaError("mutex:with: expected function, got ${args.arg(2).typename()}") + val fnArgs = args.subargs(3) + + val coro = luaState.currentThread + ?: throw LuaError("mutex:with: must be called inside a coroutine") + val executor = coro.executionContext as? AsyncExecutor + ?: throw LuaError("mutex:with: no executor context") + + val op = MutexOperation(coro, executor, fn, fnArgs) + val continuation = MutexContinuation(mutex, op) + op.continuation = continuation + + if (mutex.tryAcquire(op)) { + runMutexCallback(op, mutex) + } + throw YieldContinuationException(mutexWithContinuable, args, continuation) + } + + private val mutexWithFn: LuaFunction by lazy { luaVarFunction(::handleMutexWith) } + + private val mutexWithContinuable: LuaFunction by lazy { + object : LuaContinuableFunction() { + override fun invoke(args: Varargs, continuation: MutexContinuation?): Varargs { + if (continuation != null) { + if (continuation.error != null) { + throw continuation.error!! + } + return continuation.result + } + return handleMutexWith(args) + } + } + } + + // ── Resume helper (always goes through the serialized handler) ────── + + internal fun resumeThread(thread: LuaThread, args: Varargs, context: String) { + val handler = thread.resumeHandler + ?: throw LuaError( + "$context: coroutine has no resume handler; async operations require an async-capable coroutine" + ) + handler.resume(thread, args) + } + // ── Module table ─────────────────────────────────────────────────── fun buildModule(): LuaTable { responseMetaReset() val module = LuaTable() module.set("task", luaVarFunction(::handleTask)) + module.set("run", runContinuable) module.set("promise", luaVarFunction(::handlePromise)) module.set("resolve", luaVarFunction(::handleResolve)) module.set("error", luaVarFunction(::handleError)) @@ -468,6 +801,7 @@ class AsyncLib( module.set("allSettled", allSettledContinuable) module.set("sleep", luaVarFunction(::handleSleep)) module.set("fetch", luaVarFunction(::handleFetch)) + module.set("mutex", luaVarFunction(::handleMutex)) return module } diff --git a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt index 4fe3225..b88292c 100644 --- a/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt +++ b/src/main/java/ru/pyxiion/ignis/api/LuaMcApi.kt @@ -29,7 +29,8 @@ import ru.pyxiion.ignis.api.wrappertoLuaValue.PlayerListWrapper import ru.pyxiion.ignis.storage.StorageManager import java.nio.file.Path import java.util.* -import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors class LuaMcApi( private val server: MinecraftServer, @@ -39,7 +40,34 @@ class LuaMcApi( private val modScope: CoroutineScope, ) { val scheduler = Scheduler(stateProvider) - val asyncLib = AsyncLib(Executor { r -> server.execute(r) }, stateProvider(), scheduler) + + private val asyncExecutors = AsyncExecutorRegistry() + private val asyncThreadPool: ExecutorService = Executors.newFixedThreadPool( + Runtime.getRuntime().availableProcessors().coerceIn(2, 8), + Thread.ofPlatform().name("PxIgnis-async-", 0).factory() + ) + + init { + asyncExecutors.register( + AsyncExecutor( + name = "main", + dispatch = { runnable -> server.execute(runnable) }, + ) + ) + asyncExecutors.register( + AsyncExecutor( + name = "threadpool", + dispatch = { runnable -> asyncThreadPool.execute(runnable) }, + shutdown = { asyncThreadPool.shutdown() }, + ) + ) + } + + val asyncLib = AsyncLib(asyncExecutors, stateProvider(), scheduler) + + fun shutdownAsync() { + asyncExecutors.shutdown() + } private val playerCache = mutableMapOf() fun suspendFunction(block: suspend (Varargs) -> Varargs): LuaFunction = @@ -316,8 +344,10 @@ class LuaMcApi( MetaTableRegistry.init() val state = stateProvider() + val mainExecutor = asyncExecutors.resolve("main") + state.getMainThread().executionContext = mainExecutor state.getMainThread().resumeHandler = LuaThread.ResumeHandler { thread: LuaThread, args: Varargs -> - server.run { thread.resumeOrLog(args, "async callback") } + mainExecutor.dispatch { thread.resumeOrLog(args, "async callback") } } val mcMeta = LuaTable() diff --git a/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt index dc95a95..c31b00c 100644 --- a/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt +++ b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt @@ -3,31 +3,145 @@ package ru.pyxiion.ignis import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.luaj.vm2.LuaError import org.luaj.vm2.LuaState import org.luaj.vm2.LuaTable import org.luaj.vm2.LuaThread import org.luaj.vm2.LuaValue +import org.luaj.vm2.Varargs import org.luaj.vm2.lib.jse.JsePlatform +import ru.pyxiion.ignis.api.AsyncExecutor +import ru.pyxiion.ignis.api.AsyncExecutorRegistry import ru.pyxiion.ignis.api.AsyncLib class AsyncLibTest { + // Serialized resume gate for the root test coroutine. The initial resume + // runs synchronously on the test thread (so busy loops don't block the + // executor that runs the task); subsequent resumes are dispatched through + // the main executor. Requests that arrive while a resume is in flight are + // queued and drained after the initial resume returns. + private class RootGate( + private val thread: LuaThread, + private val executor: AsyncExecutor, + ) { + private val lock = ReentrantLock() + private var running = false + private val pending = ArrayDeque() + val failures = CopyOnWriteArrayList() + + fun start(initialArgs: Varargs): Varargs { + lock.withLock { + if (running) return LuaValue.NONE + running = true + } + val result: Varargs + try { + result = thread.resume(initialArgs) + } catch (e: Throwable) { + failures.add(e) + throw e + } finally { + val queued: MutableList = ArrayList() + lock.withLock { + running = false + if (thread.status == "dead") { + pending.clear() + } else { + queued.addAll(pending) + pending.clear() + } + } + for (a in queued) dispatch(a) + } + return result + } + + fun requestResume(args: Varargs) { + var runNow = false + lock.withLock { + if (running) { + if (thread.status != "dead") pending.addLast(args) + return + } + if (thread.status == "dead") return + runNow = true + } + if (runNow) dispatch(args) + } + + private fun dispatch(args: Varargs) { + executor.dispatch { + try { + val r = thread.resume(args) + if (!r.arg1().toboolean()) { + failures.add( + thread.lastError ?: LuaError(r.arg(2).optjstring("test coroutine error")) + ) + } + } catch (e: Throwable) { + failures.add(e) + } + } + } + } + private class Env( val state: LuaState, - val executor: ExecutorService, - val latch: CountDownLatch + val asyncLib: AsyncLib, + val mainExecutor: ExecutorService, + val poolExecutor: ExecutorService, + val latch: CountDownLatch, + val registry: AsyncExecutorRegistry, + val recordedExecutions: CopyOnWriteArrayList, + val scheduler: Scheduler, ) private fun newEnv(): Env { val state = JsePlatform.standardState() - val executor = Executors.newSingleThreadExecutor() + val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "pxrp-test-main") } + val poolExecutor = Executors.newFixedThreadPool(2) { r -> Thread(r, "pxrp-test-pool") } val latch = CountDownLatch(1) val scheduler = Scheduler { state } - val asyncLib = AsyncLib(executor, state, scheduler) + val registry = AsyncExecutorRegistry() + val recordedExecutions = CopyOnWriteArrayList() + + registry.register( + AsyncExecutor( + name = "main", + dispatch = { runnable -> + mainExecutor.execute { + recordedExecutions.add("main:${Thread.currentThread().name}") + runnable.run() + } + }, + shutdown = { mainExecutor.shutdown() }, + ) + ) + registry.register( + AsyncExecutor( + name = "threadpool", + dispatch = { runnable -> + poolExecutor.execute { + recordedExecutions.add("threadpool:${Thread.currentThread().name}") + runnable.run() + } + }, + shutdown = { poolExecutor.shutdown() }, + ) + ) + + val asyncLib = AsyncLib(registry, state, scheduler) state.globals.set("async", asyncLib.buildModule()) state.globals.set("block", luaFunctionNil { _ -> try { @@ -36,16 +150,30 @@ class AsyncLibTest { Thread.currentThread().interrupt() } }) + state.globals.set("record", luaFunction { arg -> + recordedExecutions.add("record:${arg.optjstring("")}:${Thread.currentThread().name}") + LuaValue.NIL + }) state.globals.set("_result", LuaValue.NIL) LuaState.setCurrent(state) - return Env(state, executor, latch) + state.getMainThread().executionContext = registry.resolve("main") + return Env(state, asyncLib, mainExecutor, poolExecutor, latch, registry, recordedExecutions, scheduler) } - private fun runScript(env: Env, script: String): LuaValue { + private fun runScript(env: Env, script: String, contextExecutor: AsyncExecutor? = null): LuaValue { val func = env.state.load(script, "test").checkfunction() val co = LuaThread(env.state, func) - val result = co.resume(LuaValue.NONE) + val mainExec = env.registry.resolve("main") + co.executionContext = contextExecutor ?: mainExec + + val gate = RootGate(co, mainExec) + co.resumeHandler = LuaThread.ResumeHandler { _, value -> + gate.requestResume(value) + } + + val result = gate.start(LuaValue.NONE) check(result.arg1().toboolean()) { "coroutine error: ${result.arg(2)}" } + check(gate.failures.isEmpty()) { "coroutine failed: ${gate.failures.firstOrNull()?.message}" } if (co.status != "dead") { env.latch.countDown() @@ -54,9 +182,11 @@ class AsyncLibTest { if (System.currentTimeMillis() > deadline) { error("coroutine did not complete: status=${co.status}") } - Thread.sleep(5) + env.scheduler.tick() + Thread.sleep(1) } } + check(gate.failures.isEmpty()) { "coroutine failed: ${gate.failures.firstOrNull()?.message}" } return env.state.globals.get("_result") } @@ -65,7 +195,7 @@ class AsyncLibTest { try { return runScript(env, script).checktable() } finally { - env.executor.shutdown() + env.registry.shutdown() env.latch.countDown() } } @@ -79,13 +209,13 @@ class AsyncLibTest { val pending = runScript( env, """ - local t = async.task(function() block() return 42 end) + local t = async.task("main", function() block() return 42 end) _result = { t.done } """.trimIndent() ).checktable() assertFalse(pending.get(1).toboolean()) } finally { - env.executor.shutdown() + env.registry.shutdown() env.latch.countDown() } } @@ -94,7 +224,7 @@ class AsyncLibTest { fun `task done is true when complete`() { val done = newEnvAndRun( """ - local t = async.task(function() return 42 end) + local t = async.task("main", function() return 42 end) while not t.done do end _result = { t.done } """.trimIndent() @@ -108,7 +238,7 @@ class AsyncLibTest { fun `task wait sync returns raw result`() { val result = newEnvAndRun( """ - local t = async.task(function() return 42 end) + local t = async.task("main", function() return 42 end) while not t.done do end local r = t:wait() _result = { r } @@ -121,7 +251,7 @@ class AsyncLibTest { fun `task wait async yields and returns raw result`() { val result = newEnvAndRun( """ - local t = async.task(function() block() return 42 end) + local t = async.task("main", function() block() return 42 end) local r = t:wait() _result = { r } """.trimIndent() @@ -133,7 +263,7 @@ class AsyncLibTest { fun `task wait sync throws on error`() { val result = newEnvAndRun( """ - local t = async.task(function() error("boom") end) + local t = async.task("main", function() error("boom") end) while not t.done do end local ok, err = pcall(function() return t:wait() end) _result = { ok, err } @@ -147,7 +277,7 @@ class AsyncLibTest { fun `task wait async throws on error`() { val result = newEnvAndRun( """ - local t = async.task(function() block() error("boom") end) + local t = async.task("main", function() block() error("boom") end) local ok, err = pcall(function() return t:wait() end) _result = { ok, err } """.trimIndent() @@ -162,7 +292,7 @@ class AsyncLibTest { fun `task try sync returns ok and result`() { val result = newEnvAndRun( """ - local t = async.task(function() return 42 end) + local t = async.task("main", function() return 42 end) while not t.done do end local ok, r = t:try() _result = { ok, r } @@ -176,7 +306,7 @@ class AsyncLibTest { fun `task try async yields and returns ok and result`() { val result = newEnvAndRun( """ - local t = async.task(function() block() return 42 end) + local t = async.task("main", function() block() return 42 end) local ok, r = t:try() _result = { ok, r } """.trimIndent() @@ -189,7 +319,7 @@ class AsyncLibTest { fun `task try sync returns false and error message`() { val result = newEnvAndRun( """ - local t = async.task(function() error("boom") end) + local t = async.task("main", function() error("boom") end) while not t.done do end local ok, r = t:try() _result = { ok, r } @@ -203,7 +333,7 @@ class AsyncLibTest { fun `task try async returns false and error message`() { val result = newEnvAndRun( """ - local t = async.task(function() block() error("boom") end) + local t = async.task("main", function() block() error("boom") end) local ok, r = t:try() _result = { ok, r } """.trimIndent() @@ -218,7 +348,7 @@ class AsyncLibTest { fun `task rejects non-function argument`() { val result = newEnvAndRun( """ - local ok, err = pcall(async.task, 42) + local ok, err = pcall(async.task, "main", 42) _result = { ok, err } """.trimIndent() ) @@ -248,6 +378,143 @@ class AsyncLibTest { assertFalse(result.get(1).toboolean()) } + @Test + fun `task rejects unknown executor`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(async.task, "nonexistent", function() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("unknown executor")) + } + + @Test + fun `task rejects missing executor`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(async.task, function() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + } + + // ── Executor dispatch ────────────────────────────────────────── + + @Test + fun `main task runs on main executor`() { + val env = newEnv() + try { + runScript( + env, + """ + async.task("main", function() + _result = { "ran" } + end) + """.trimIndent() + ) + val deadline = System.currentTimeMillis() + 5000 + while (env.state.globals.get("_result").isnil()) { + if (System.currentTimeMillis() > deadline) error("main task did not run") + Thread.sleep(5) + } + assertTrue(env.recordedExecutions.any { it.startsWith("main:pxrp-test-main") }) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `threadpool task runs on threadpool executor`() { + val env = newEnv() + try { + runScript( + env, + """ + async.task("threadpool", function() + _result = { "ran" } + end) + """.trimIndent() + ) + val deadline = System.currentTimeMillis() + 5000 + while (env.state.globals.get("_result").isnil()) { + if (System.currentTimeMillis() > deadline) error("threadpool task did not run") + Thread.sleep(5) + } + assertTrue(env.recordedExecutions.any { it.startsWith("threadpool:pxrp-test-pool") }) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `executor selection preserved after sleep`() { + val env = newEnv() + try { + runScript( + env, + """ + async.task("threadpool", function() + async.sleep(1) + _result = { "done" } + end) + """.trimIndent() + ) + val deadline = System.currentTimeMillis() + 5000 + while (env.state.globals.get("_result").isnil()) { + if (System.currentTimeMillis() > deadline) error("sleep task did not complete") + env.scheduler.tick() + Thread.sleep(1) + } + assertTrue(env.recordedExecutions.any { it.startsWith("threadpool:pxrp-test-pool") }) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `multiple pool tasks overlap`() { + val env = newEnv() + try { + runScript( + env, + """ + for i = 1, 2 do + async.task("threadpool", function() + record("start") + block() + record("end") + end) + end + """.trimIndent() + ) + val deadline = System.currentTimeMillis() + 5000 + while (env.recordedExecutions.count { it.startsWith("record:start") } < 2) { + if (System.currentTimeMillis() > deadline) error("tasks did not both enter") + Thread.sleep(5) + } + assertTrue(env.recordedExecutions.none { it.startsWith("record:end") }) + env.latch.countDown() + while (env.recordedExecutions.count { it.startsWith("record:end") } < 2) { + if (System.currentTimeMillis() > deadline) error("tasks did not both finish") + Thread.sleep(5) + } + assertTrue( + env.recordedExecutions.filter { it.startsWith("record:") } + .all { it.contains("pxrp-test-pool") } + ) + assertTrue(env.recordedExecutions.none { it.contains("pxrp-test-main") }) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + // ── Promise ──────────────────────────────────────────────────── @Test @@ -338,8 +605,8 @@ class AsyncLibTest { fun `all returns results for all tasks`() { val result = newEnvAndRun( """ - local t1 = async.task(function() return 1 end) - local t2 = async.task(function() return 2 end) + local t1 = async.task("main", function() return 1 end) + local t2 = async.task("main", function() return 2 end) while not t1.done do end while not t2.done do end local results = async.all(t1, t2) @@ -356,8 +623,8 @@ class AsyncLibTest { fun `all rejects when any task fails`() { val result = newEnvAndRun( """ - local t1 = async.task(function() return 1 end) - local t2 = async.task(function() error("boom") end) + local t1 = async.task("main", function() return 1 end) + local t2 = async.task("main", function() error("boom") end) while not t1.done do end while not t2.done do end local ok, err = pcall(function() return async.all(t1, t2) end) @@ -372,8 +639,8 @@ class AsyncLibTest { fun `allSettled returns results for all tasks`() { val result = newEnvAndRun( """ - local t1 = async.task(function() return 1 end) - local t2 = async.task(function() error("boom") end) + local t1 = async.task("main", function() return 1 end) + local t2 = async.task("main", function() error("boom") end) while not t1.done do end while not t2.done do end local results = async.allSettled(t1, t2) @@ -397,4 +664,749 @@ class AsyncLibTest { assertTrue(result.get(1).istable()) assertEquals(0, result.get(1).checktable().length().toInt()) } + + // ── async.run ────────────────────────────────────────────────── + + @Test + fun `run returns result`() { + val result = newEnvAndRun( + """ + local a, b, c = async.run("main", function() + return 1, 2, 3 + end) + _result = { a, b, c } + """.trimIndent() + ) + assertEquals(1, result.get(1).toint()) + assertEquals(2, result.get(2).toint()) + assertEquals(3, result.get(3).toint()) + } + + @Test + fun `run propagates error`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() + return async.run("main", function() + error("boom") + end) + end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `run uses requested executor`() { + val env = newEnv() + try { + runScript( + env, + """ + async.run("threadpool", function() + _result = { "done" } + end) + """.trimIndent() + ) + val deadline = System.currentTimeMillis() + 5000 + while (env.state.globals.get("_result").isnil()) { + if (System.currentTimeMillis() > deadline) error("run task did not complete") + Thread.sleep(5) + } + assertTrue(env.recordedExecutions.any { it.startsWith("threadpool:pxrp-test-pool") }) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + // ── Mutex ────────────────────────────────────────────────────── + + @Test + fun `uncontended mutex with executes immediately`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local val = m:with(function() + return 42 + end) + _result = { val } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `mutex with preserves return values`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local a, b = m:with(function() + return 10, 20 + end) + _result = { a, b } + """.trimIndent() + ) + assertEquals(10, result.get(1).toint()) + assertEquals(20, result.get(2).toint()) + } + + @Test + fun `mutex with passes arguments to callback`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local val = m:with(function(x) + return x * 2 + end, 21) + _result = { val } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `mutex with catches callback error`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local ok, err = pcall(function() + return m:with(function() + error("mutex boom") + end) + end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("mutex boom")) + } + + @Test + fun `mutex next waiter runs after error`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local order = {} + + async.task("main", function() + m:with(function() + table.insert(order, "error") + error("fail") + end) + end) + + async.task("main", function() + async.sleep(1) + m:with(function() + table.insert(order, "second") + return "ok" + end) + end) + + async.task("main", function() + while #order < 2 do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals("error", result.get(1).tojstring()) + assertEquals("second", result.get(2).tojstring()) + } + + @Test + fun `mutex contended with waits`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local order = {} + + async.task("main", function() + m:with(function() + table.insert(order, "first") + async.sleep(5) + end) + end) + + async.task("main", function() + async.sleep(1) + m:with(function() + table.insert(order, "second") + end) + end) + + async.task("main", function() + while #order < 2 do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals("first", result.get(1).tojstring()) + assertEquals("second", result.get(2).tojstring()) + } + + @Test + fun `mutex critical sections never overlap`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local active = 0 + local max_active = 0 + + for i = 1, 3 do + async.task("main", function() + m:with(function() + active = active + 1 + if active > max_active then max_active = active end + async.sleep(2) + active = active - 1 + end) + end) + end + + async.task("main", function() + async.sleep(20) + _result = { max_active } + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals(1, result.get(1).toint()) + } + + @Test + fun `multiple mutexes remain independent`() { + val result = newEnvAndRun( + """ + local m1 = async.mutex() + local m2 = async.mutex() + local order = {} + + async.task("main", function() + m1:with(function() + table.insert(order, "m1") + async.sleep(5) + end) + end) + + async.task("main", function() + async.sleep(1) + m2:with(function() + table.insert(order, "m2") + end) + end) + + async.task("main", function() + while #order < 2 do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals("m1", result.get(1).tojstring()) + assertEquals("m2", result.get(2).tojstring()) + } + + @Test + fun `mutex rejects non-function argument`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local ok, err = pcall(function() + return m:with("not a function") + end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("expected function")) + } + + @Test + fun `mutex rejects non-mutex self`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() + return ("nope"):with(function() end) + end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + } + + // ── Resumer serialization ──────────────────────────────────────── + + @Test + fun `run repeated on threadpool does not race`() { + val result = newEnvAndRun( + """ + local sum = 0 + for i = 1, 50 do + local v = async.run("threadpool", function() + return 1 + end) + sum = sum + v + end + _result = { sum } + """.trimIndent() + ) + assertEquals(50, result.get(1).toint()) + } + + @Test + fun `run repeated on main does not race`() { + val result = newEnvAndRun( + """ + local sum = 0 + for i = 1, 50 do + sum = sum + async.run("main", function() + return 1 + end) + end + _result = { sum } + """.trimIndent() + ) + assertEquals(50, result.get(1).toint()) + } + + @Test + fun `threadpool task yields and resumes`() { + val result = newEnvAndRun( + """ + local t = async.task("threadpool", function() + async.sleep(1) + return 5 + end) + local v = t:wait() + _result = { v } + """.trimIndent() + ) + assertEquals(5, result.get(1).toint()) + } + + @Test + fun `multiple resume requests for one coroutine`() { + val result = newEnvAndRun( + """ + local t = async.task("threadpool", function() + local p1 = async.promise() + local p2 = async.promise() + async.run("threadpool", function() + p1:resolve(1) + p2:resolve(2) + end) + local a = p1:wait() + local b = p2:wait() + return a + b + end) + local v = t:wait() + _result = { v } + """.trimIndent() + ) + assertEquals(3, result.get(1).toint()) + } + + @Test + fun `mutex callback yields and resumes`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local val = m:with(function() + async.sleep(1) + return 7 + end) + _result = { val } + """.trimIndent() + ) + assertEquals(7, result.get(1).toint()) + } + + @Test + fun `contended mutex returns callback values`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local results = {} + + async.task("main", function() + local a, b = m:with(function() + async.sleep(3) + return 1, 2 + end) + results[1] = a + results[2] = b + end) + + async.task("main", function() + async.sleep(1) + results[3] = m:with(function() + return 99 + end) + end) + + async.task("main", function() + while #results < 3 do async.sleep(1) end + _result = results + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals(1, result.get(1).toint()) + assertEquals(2, result.get(2).toint()) + assertEquals(99, result.get(3).toint()) + } + + @Test + fun `queued mutex callback errors are propagated`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local captured = {} + + async.task("main", function() + m:with(function() + async.sleep(2) + return "first" + end) + end) + + async.task("main", function() + async.sleep(1) + local ok, err = pcall(function() + return m:with(function() + error("queued boom") + end) + end) + captured[1] = ok + captured[2] = err + end) + + async.task("main", function() + while captured[1] == nil do async.sleep(1) end + _result = captured + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("queued boom")) + } + + @Test + fun `mutex releases after async callback error`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local order = {} + + async.task("main", function() + m:with(function() + async.sleep(2) + table.insert(order, "first") + error("async fail") + end) + end) + + async.task("main", function() + async.sleep(1) + m:with(function() + table.insert(order, "second") + end) + end) + + async.task("main", function() + while #order < 2 do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals("first", result.get(1).tojstring()) + assertEquals("second", result.get(2).tojstring()) + } + + @Test + fun `mutex waiters complete in FIFO order`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local order = {} + + async.task("main", function() + m:with(function() + table.insert(order, 1) + async.sleep(3) + end) + end) + + async.task("main", function() + m:with(function() + table.insert(order, 2) + async.sleep(3) + end) + end) + + async.task("main", function() + m:with(function() + table.insert(order, 3) + async.sleep(3) + end) + end) + + async.task("main", function() + while #order < 3 do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertEquals(1, result.get(1).toint()) + assertEquals(2, result.get(2).toint()) + assertEquals(3, result.get(3).toint()) + } + + // ── Resumer hardening ─────────────────────────────────────────── + + @Test + fun `resumer callback failure does not block later resumes`() { + val state = JsePlatform.standardState() + val func = state.load("return coroutine.yield(0)", "t").checkfunction() + val co = LuaThread(state, func) + val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "pxrp-test-resumer") } + val registry = AsyncExecutorRegistry() + registry.register(AsyncExecutor("main", dispatch = { exec.execute(it) })) + try { + val executor = registry.resolve("main") + val failures = CopyOnWriteArrayList() + val calls = CopyOnWriteArrayList() + val throwOnFirst = AtomicBoolean(true) + val resumer = AsyncLib.SerializedResumer( + co, + executor, + resume = { args -> + if (throwOnFirst.getAndSet(false)) throw LuaError("callback boom") + calls.add(args.arg(1).toint()) + }, + onFailure = { failures.add(it) }, + ) + resumer.requestResume(LuaValue.valueOf(1)) + val deadline = System.currentTimeMillis() + 5000 + while (failures.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(1) + assertEquals(1, failures.size) + assertTrue(failures[0].message!!.contains("callback boom")) + + // A later resume request on the same coroutine must be dropped + // cleanly (not stuck, not re-dispatching the failed callback). + resumer.requestResume(LuaValue.valueOf(2)) + Thread.sleep(100) + assertEquals(0, calls.size) + assertEquals(1, failures.size) + } finally { + registry.shutdown() + } + } + + @Test + fun `executor rejects dispatch and task is rejected not hung`() { + val env = newEnv() + try { + env.registry.register( + AsyncExecutor( + name = "closed", + dispatch = { throw RejectedExecutionException("executor shut down") }, + ) + ) + runScript( + env, + """ + local t = async.task("closed", function() return 1 end) + _result = { t.state } + """.trimIndent() + ) + assertEquals("rejected", env.state.globals.get("_result").checktable().get(1).tojstring()) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `async run on rejected dispatch fails clearly`() { + val env = newEnv() + try { + env.registry.register( + AsyncExecutor( + name = "closed", + dispatch = { throw RejectedExecutionException("executor shut down") }, + ) + ) + runScript( + env, + """ + local ok, err = pcall(function() + return async.run("closed", function() return 1 end) + end) + _result = { ok, err } + """.trimIndent() + ) + val result = env.state.globals.get("_result").checktable() + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("executor shut down")) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `resumeThread without resume handler fails clearly`() { + val env = newEnv() + try { + val func = env.state.load("return 1", "t").checkfunction() + val co = LuaThread(env.state, func) + co.resumeHandler = null + val err = assertFailsWith { + env.asyncLib.resumeThread(co, LuaValue.NONE, "test") + } + assertTrue(err.message!!.contains("resume handler")) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `mutex callback failure releases the mutex for a fresh waiter`() { + val result = newEnvAndRun( + """ + local m = async.mutex() + local order = {} + + async.task("main", function() + local ok = pcall(function() + m:with(function() + table.insert(order, "fail") + error("boom") + end) + end) + order[1] = not ok + end) + + async.task("main", function() + while order[1] == nil do async.sleep(1) end + local val = m:with(function() + return 42 + end) + order[2] = val + end) + + async.task("main", function() + while order[2] == nil do async.sleep(1) end + _result = order + end) + + while _result == nil do async.sleep(1) end + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) + } + + @Test + fun `mutex completion during parent unwinding does not re-enter parent`() { + val env = newEnv() + try { + env.registry.register(AsyncExecutor("sync", dispatch = { it.run() })) + val syncExec = env.registry.resolve("sync") + runScript( + env, + """ + local m = async.mutex() + local val = m:with(function() + return 42 + end) + _result = { val } + """.trimIndent(), + contextExecutor = syncExec, + ) + assertEquals(42, env.state.globals.get("_result").checktable().get(1).toint()) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `multiple queued resume requests remain ordered`() { + val state = JsePlatform.standardState() + val func = state.load( + "local a = coroutine.yield(1); local b = coroutine.yield(2); local c = coroutine.yield(3); return a+b+c", + "t", + ).checkfunction() + val co = LuaThread(state, func) + val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "pxrp-test-order") } + val registry = AsyncExecutorRegistry() + registry.register(AsyncExecutor("main", dispatch = { exec.execute(it) })) + try { + val executor = registry.resolve("main") + val resumed = CopyOnWriteArrayList() + val firstEntered = CountDownLatch(1) + val release = CountDownLatch(1) + val failures = CopyOnWriteArrayList() + val resumer = AsyncLib.SerializedResumer( + co, + executor, + resume = { args -> + resumed.add(args.arg(1).toint()) + if (resumed.size == 1) { + firstEntered.countDown() + release.await() + } + co.resume(args) + }, + onFailure = { failures.add(it) }, + ) + resumer.requestResume(LuaValue.valueOf(1)) + firstEntered.await() + resumer.requestResume(LuaValue.valueOf(2)) + resumer.requestResume(LuaValue.valueOf(3)) + release.countDown() + + val deadline = System.currentTimeMillis() + 5000 + while (resumed.size < 3 && System.currentTimeMillis() < deadline) Thread.sleep(1) + assertEquals(listOf(1, 2, 3), resumed.toList()) + assertTrue(failures.isEmpty()) + } finally { + registry.shutdown() + } + } + + @Test + fun `registry rejects register and resolve after shutdown`() { + val registry = AsyncExecutorRegistry() + val exec = Executors.newSingleThreadExecutor() + registry.register(AsyncExecutor("main", dispatch = { exec.execute(it) })) + registry.shutdown() + try { + registry.register(AsyncExecutor("late", dispatch = { })) + error("expected register to fail after shutdown") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("shutdown")) + } + val err = assertFailsWith { registry.resolve("main") } + assertTrue(err.message!!.contains("shut down")) + } } From 384ec52f470980bce63191fece40594f2016c410 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 10:21:05 +0300 Subject: [PATCH 13/14] docs: update changelog for async coroutines release --- site/src/content/docs/changelog.md | 93 +++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/site/src/content/docs/changelog.md b/site/src/content/docs/changelog.md index 8e97dff..501289e 100644 --- a/site/src/content/docs/changelog.md +++ b/site/src/content/docs/changelog.md @@ -3,53 +3,87 @@ title: Changelog description: Release history for PxIgnis. --- -## Unreleased — Async coroutines +## Unreleased — Async coroutines, region debug overlay ### Breaking -- **`mc.task`, `mc.run`, `mc.prun`, `mc.sleep`, `mc.fetch`** removed from `mc` table. Use `require "async"` instead. +- **`mc.task`, `mc.run`, `mc.prun`, `mc.sleep`, `mc.fetch`** removed from the `mc` table. Use `require "async"` instead. +- **`async.task` / `async.run` now require an explicit executor** — `"main"` or `"threadpool"`. ### New API #### `require "async"` — coroutine-based async module -| API | Description | -|------------------------------|---------------------------------------------------------------------------------------------| -| `async.task(fn, ...)` | Runs `fn(...)` as a background coroutine; returns a task (awaitable) | -| `async.promise()` | Creates a manually-settleable promise | -| `task:wait()` | Yields until done; returns the raw result, throws `LuaError` on task error | -| `task:try()` | Yields until done; returns `true, result...` or `false, error` (pcall-like) | -| `task.done` | `true` once the task/promise has settled | -| `task.state` | `"pending"`, `"resolved"`, or `"rejected"` | -| `promise:resolve(...)` | Settles the promise with a value; returns `true` if it was the first settlement | -| `promise:error(msg)` | Rejects the promise; returns `true` if it was the first settlement | -| `async.all(t1, t2, ...)` | Waits for all tasks; throws the first error after all settle | -| `async.allSettled(t1, ...)` | Waits for all tasks; never throws, returns `{ ok, value/error }` for each | -| `async.sleep(ticks)` | Yields the coroutine for N ticks (20 = 1s) | -| `async.fetch(url)` | HTTP request, yields the coroutine; returns response table | -| `async.fetch {...}` | Full request with `{ url, method, headers, body, json, timeout }` options | +Load with `local async = require "async"`. Every task picks where its Lua code runs: + +| Executor | Intended use | +|----------------|---------------------------------------------------------------------| +| `"main"` | Minecraft server thread — safe for players, worlds, entities, etc. | +| `"threadpool"` | Bounded worker pool — expensive pure-Lua computation only | + +`"main"` runs on the server thread and must stay short. `"threadpool"` runs off-thread and must not touch Minecraft +objects or shared globals. I/O (`async.sleep`, `async.fetch`) is already non-blocking and needs no thread pool. + +| API | Description | +|-------------------------------------|--------------------------------------------------------------------------------------| +| `async.task(executor, fn, ...)` | Runs `fn(...)` on an executor; returns a task (awaitable) | +| `async.run(executor, fn, ...)` | Runs `fn(...)` and waits; returns raw values, throws on error | +| `async.promise()` | Creates a manually-settleable promise | +| `task:wait()` | Yields until done; returns the raw result values, throws `LuaError` on task error | +| `task:try()` | Yields until done; returns `true, result...` or `false, error` (pcall-like) | +| `task.done` | `true` once the task/promise has settled | +| `task.state` | `"pending"`, `"resolved"`, or `"rejected"` | +| `promise:resolve(...)` | Settles the promise with a value; returns `true` if it was the first settlement | +| `promise:error(msg)` | Rejects the promise; returns `true` if it was the first settlement | +| `async.all(t1, t2, ...)` | Waits for all tasks; throws the first error after all settle | +| `async.allSettled(t1, ...)` | Waits for all tasks; never throws; returns `{ ok, value/error }` per input | +| `async.sleep(ticks)` | Yields the coroutine for N ticks (20 = 1s) | +| `async.fetch(url)` | HTTP request, yields the coroutine; returns response table | +| `async.fetch {...}` | Full request with `{ url, method, headers, body, json, timeout }` options | +| `async.mutex()` | Coroutine-safe mutex; `mutex:with(fn, ...)` runs with exclusive ownership | Tasks run as proper Lua coroutines — they can call `async.sleep`, `async.fetch`, and `task:wait()` internally. ```lua local async = require "async" --- Parallel fetches -local t1 = async.task(function() return async.fetch("https://api.example.com/a") end) -local t2 = async.task(function() return async.fetch("https://api.example.com/b") end) -local r1, r2 = t1:wait(), t2:wait() +-- Parallel computation on the worker pool +local left = async.task("threadpool", function() return generate_chunk(1) end) +local right = async.task("threadpool", function() return generate_chunk(2) end) +local results = async.all(left, right) + +-- Back on the server thread +async.run("main", function() + apply_chunk(results[1].value) + apply_chunk(results[2].value) +end) + +-- Waiting for I/O is already non-blocking +local response = async.fetch("https://api.example.com/data") +print(response.ok and response.text or response.error) -- Error handling -local t = async.task(function() error("boom") end) -local ok, err = t:try() +local ok, err = async.task("threadpool", function() error("boom") end):try() if not ok then print("failed:", err) end -- Promises local p = async.promise() -async.schedule(20, function() p:resolve("done") end) +mc.schedule(20, function() p:resolve("done") end) print(p:wait()) + +-- Mutex +local mutex = async.mutex() +mutex:with(function() return update_shared_cache() end) ``` +#### Region debug overlay + +- **Server-side region sync**: opted-in players receive the regions in their 4-chunk interest radius as incremental + diffs (`/ignis debug regions`, ops only). Bounds changes re-sync as upserts; a one-time warning fires at the + 256-region cap. +- **Client-side registry** (`src/client`): thread-safe region box store toggled by the command. Wireframe rendering + stays a documented no-op until the renderer API is available in the mod's Fabric/Yarn set. + ### Internal - **Async suspend bridge**: `luaSuspendFunction` / `luaSuspendFunctionNil` let Lua coroutines call Kotlin `suspend` @@ -59,11 +93,18 @@ print(p:wait()) a clear `LuaError` instead of hanging. See `docs/async-suspend-bridge.md`. - **`lua_resume_sync`**: now sets and restores `LuaState.current()` so thread-local state lookups work on the server thread like they do in async coroutines. -- **EventBus**: Lua closure handlers now run through a `LuaThread` instead of being invoked directly, so `mc.sleep`, - `mc.fetch`, and suspend functions work inside event callbacks like they do in scheduled tasks and commands. +- **EventBus**: Lua closure handlers now run through a `LuaThread` instead of being invoked directly, so `async.sleep`, + `async.fetch`, and suspend functions work inside event callbacks like they do in scheduled tasks and commands. +- **Executor registry + thread pool**: `AsyncExecutorRegistry` with bounded `PxIgnis-async-*` worker pools, shut down + on server stop. `LuaThread.executionContext` propagates the owning executor to child coroutines. +- **Serialized resumption**: a per-coroutine resume gate guarantees one `thread.resume()` at a time, so asynchronous + completions never interleave with a coroutine that is already running. +- **Scheduler**: now thread-safe (lock-protected queue, volatile tick counter) for cross-thread task scheduling. - **modScope**: `IgnisRuntime` owns a mod-lifetime `CoroutineScope` (`SupervisorJob`, cancelled on server stop). `LuaMcApi.suspendFunction` wraps it for convenience. `RegionManager` gained a shared state provider so region events resolve the Lua state like the root event bus does. +- **Build**: split client source set (`loom.splitEnvironmentSourceSets`); each region payload now has a unique + `CustomPayload.Id` (they previously shared one identifier and collided at registration). ## 0.16.1 — Interop refactor, scheduler bounds, template fixes (2026-06-26) From 42549b682e99bb795d1be7d5cb67f4d6e0fbc5b9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 1 Aug 2026 10:22:59 +0300 Subject: [PATCH 14/14] ci: run build on master (default branch), not main --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5c6e83..923b257 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,10 @@ name: Build on: push: - branches: [main] + branches: [master] tags: ["v*"] pull_request: - branches: [main] + branches: [master] workflow_dispatch: jobs: @@ -32,7 +32,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} + cache-read-only: ${{ github.ref != 'refs/heads/master' }} - name: Make gradlew executable run: chmod +x gradlew