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 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/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/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/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/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..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,19 +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) { - ra.copyto(frame.stack, ca, cc - 1); - 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; } } @@ -303,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; @@ -338,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; @@ -367,6 +415,13 @@ 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) { + 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); @@ -391,7 +446,23 @@ 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; + } if (tcResult.isTailcall()) { TailcallVarargs tv = (TailcallVarargs) tcResult; tcResult = tv.eval(); 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 3dd2c2d..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); } @@ -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..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 @@ -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,15 @@ 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; + + /** 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. @@ -150,9 +166,31 @@ 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); + this.executionContext = resolveExecutionContext(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 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) { @@ -232,14 +270,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. */ @@ -294,9 +336,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 +350,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 +364,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 +410,13 @@ public Varargs lua_yield_sync(Varargs args) { } public Varargs lua_resume_sync(LuaThread new_thread, Varargs args) { - LuaThread previous_thread = state.currentThread; + LuaState previousState = LuaState.current(); + LuaThread previous_thread = state.getCurrentThread(); try { - state.currentThread = new_thread; - if (previous_thread != null) + LuaState.setCurrent(state); + 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 +477,8 @@ 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); + 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); +} 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..e30cacb --- /dev/null +++ b/pxluanova/pxluanova-test/src/test/java/org/luaj/vm2/JavaFunctionYieldTest.java @@ -0,0 +1,201 @@ +package org.luaj.vm2; + +import junit.framework.TestCase; + +import java.util.concurrent.atomic.AtomicReference; + +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")); + } + } + + 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()); + } } diff --git a/site/src/content/docs/changelog.md b/site/src/content/docs/changelog.md index 1b21c87..501289e 100644 --- a/site/src/content/docs/changelog.md +++ b/site/src/content/docs/changelog.md @@ -3,7 +3,108 @@ title: Changelog description: Release history for PxIgnis. --- -# Changelog +## Unreleased — Async coroutines, region debug overlay + +### Breaking + +- **`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 + +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 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 ok, err = async.task("threadpool", function() error("boom") end):try() +if not ok then print("failed:", err) end + +-- Promises +local p = async.promise() +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` + 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 `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) 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/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 new file mode 100644 index 0000000..0afd0ba --- /dev/null +++ b/src/client/java/ru/pyxiion/ignis/client/PxIgnisClient.kt @@ -0,0 +1,48 @@ +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 { + private val logger: Logger = LoggerFactory.getLogger("pxignis-client") + } + + 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 { ctx -> + if (!ClientRegionRegistry.enabled) return@register + runCatching { + ClientCompat.drawWireframeBoxes(ctx, ClientRegionRegistry.values()) + }.onFailure { logger.warn("region wireframe render failed: ${it.message}") } + } + + ClientCommands.register() + } +} 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 160426d..a96ceaa 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 @@ -11,7 +12,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 +35,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 +58,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") @@ -73,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() @@ -81,9 +109,17 @@ class PxIgnis : ModInitializer { } } catch (_: UninitializedPropertyAccessException) { } + try { + runtime.api.shutdownAsync() + } catch (_: UninitializedPropertyAccessException) { + } 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 +129,7 @@ class PxIgnis : ModInitializer { em.tick() } RegionManager.tick() + RegionManager.tickClientSync(server) } }) @@ -144,6 +181,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/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/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/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 49f3bb8..5fe31a7 100644 --- a/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt +++ b/src/main/java/ru/pyxiion/ignis/api/AsyncLib.kt @@ -1,40 +1,556 @@ 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.CompletableFuture +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock class AsyncLib( - private val server: MinecraftServer, + private val executors: AsyncExecutorRegistry, private val luaState: LuaState, - private val scheduler: Scheduler + private val scheduler: Scheduler, ) { - fun install(mcTable: LuaTable) { - responseMetaReset() - mcTable.set("fetch", luaVarFunction(::handleFetch)) - mcTable.set("sleep", luaVarFunction(::handleSleep)) + + // ── 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 { + 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()}") } + // ── 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 { _, _, _ -> + resumeThread(coro, 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 -> + 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 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) + + 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") + + 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 { + throw continuation.error ?: LuaError("async.run: task failed") + } + } + return handleRun(args) + } + } + } + + // ── Promise ──────────────────────────────────────────────────────── + + private fun handlePromise(@Suppress("UNUSED_PARAMETER") args: Varargs): Varargs { + return wrapObject(Awaitable(), "promise") + } + + private val resolveFn: LuaFunction by lazy { luaVarFunction(::handleResolve) } + private val errorFn: LuaFunction by lazy { luaVarFunction(::handleError) } + + 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() + } + + 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() + } + + // ── all / allSettled ─────────────────────────────────────────────── + + 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()}") + } + } + } + + 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 coro = luaState.currentThread + ?: throw LuaError("async.all: 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 { + resumeThread(coro, LuaValue.NONE, "async.all callback") + } + throw YieldContinuationException(this@AsyncLib.allContinuable, args, allFuture) + } + + 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 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 t + } + + 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 { + resumeThread(coro, LuaValue.NONE, "async.allSettled callback") + } + throw YieldContinuationException(this@AsyncLib.allSettledContinuable, args, allFuture) + } + + 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)) @@ -58,14 +574,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 -> - server.execute { co.resumeOrLog(buildResponse(response), "mc.fetch callback") } + resumeThread(co, buildResponse(response), "async.fetch callback") null } .exceptionally { error -> - server.execute { co.resumeOrLog(buildError(error), "mc.fetch callback") } + resumeThread(co, buildError(error), "async.fetch callback") null } @@ -73,6 +590,223 @@ class AsyncLib( return LuaValue.NIL } + // ── Mutex ────────────────────────────────────────────────────────── + + 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)) + module.set("all", allContinuable) + module.set("allSettled", allSettledContinuable) + module.set("sleep", luaVarFunction(::handleSleep)) + module.set("fetch", luaVarFunction(::handleFetch)) + module.set("mutex", luaVarFunction(::handleMutex)) + return module + } + + // ── HTTP / JSON (companion) ──────────────────────────────────────── + private data class RequestConfig( val url: String, val method: String, @@ -85,13 +819,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() @@ -99,7 +827,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() @@ -110,9 +837,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() @@ -122,19 +847,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)) @@ -156,9 +878,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 } @@ -242,20 +962,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 } } @@ -283,11 +996,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) { @@ -296,16 +1006,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 fa3673c..b88292c 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 @@ -28,19 +29,54 @@ 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.ExecutorService +import java.util.concurrent.Executors class LuaMcApi( private val server: MinecraftServer, private val storage: StorageManager, private val stateProvider: () -> LuaState, private val eventBus: EventBus, + private val modScope: CoroutineScope, ) { val scheduler = Scheduler(stateProvider) + + 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 = + luaSuspendFunction(modScope, block) + init { EntityWrap.sharedPlayerCache = playerCache EntityWrap.sharedTickProvider = { scheduler.currentTick } + RegionManager.sharedStateProvider = stateProvider } fun invalidatePlayer(uuid: UUID) { @@ -307,6 +343,13 @@ class LuaMcApi( fun toTable(): LuaTable { MetaTableRegistry.init() + val state = stateProvider() + val mainExecutor = asyncExecutors.resolve("main") + state.getMainThread().executionContext = mainExecutor + state.getMainThread().resumeHandler = LuaThread.ResumeHandler { thread: LuaThread, args: Varargs -> + mainExecutor.dispatch { thread.resumeOrLog(args, "async callback") } + } + val mcMeta = LuaTable() mcMeta.rawset("__index", luaFunction { _, key -> val k = key.checkjstring() @@ -441,8 +484,6 @@ class LuaMcApi( ItemStackWrap.wrap(stack) }) - AsyncLib(server, stateProvider(), scheduler).install(table) - table.setmetatable(mcMeta) return table } 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..ecd5518 100644 --- a/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt +++ b/src/main/java/ru/pyxiion/ignis/api/manager/Region.kt @@ -1,25 +1,44 @@ 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 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 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, 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) @@ -120,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) @@ -169,6 +190,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/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!" + } + } +} 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..4311b3a --- /dev/null +++ b/src/main/java/ru/pyxiion/ignis/network/RegionPackets.kt @@ -0,0 +1,108 @@ +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 +) + +data class RegionSyncPayload(val entries: List) : CustomPayload { + companion object { + val ID: CustomPayload.Id = + CustomPayload.Id(Identifier.of("pxignis", "regions_sync")) + 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_upsert")) + 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_remove")) + 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_cap_warning")) + 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_interest")) + val CODEC: PacketCodec = + PacketCodec.tuple( + PacketCodecs.BOOLEAN, RegionInterestPayload::enabled, + ::RegionInterestPayload + ) + } + + override fun getId(): CustomPayload.Id = ID +} diff --git a/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt b/src/main/java/ru/pyxiion/ignis/runtime/ScriptEnvironment.kt index 08895d3..b54e9c3 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() @@ -76,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/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/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt new file mode 100644 index 0000000..c31b00c --- /dev/null +++ b/src/test/kotlin/ru/pyxiion/ignis/AsyncLibTest.kt @@ -0,0 +1,1412 @@ +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 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 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 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 { + latch.await() + } catch (e: InterruptedException) { + 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) + state.getMainThread().executionContext = registry.resolve("main") + return Env(state, asyncLib, mainExecutor, poolExecutor, latch, registry, recordedExecutions, scheduler) + } + + 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 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() + val deadline = System.currentTimeMillis() + 5000 + while (co.status != "dead") { + if (System.currentTimeMillis() > deadline) { + error("coroutine did not complete: status=${co.status}") + } + env.scheduler.tick() + Thread.sleep(1) + } + } + check(gate.failures.isEmpty()) { "coroutine failed: ${gate.failures.firstOrNull()?.message}" } + return env.state.globals.get("_result") + } + + private fun newEnvAndRun(script: String): LuaTable { + val env = newEnv() + try { + return runScript(env, script).checktable() + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + // ── Task: done ───────────────────────────────────────────────── + + @Test + fun `task done is false while pending`() { + val env = newEnv() + try { + val pending = runScript( + env, + """ + local t = async.task("main", function() block() return 42 end) + _result = { t.done } + """.trimIndent() + ).checktable() + assertFalse(pending.get(1).toboolean()) + } finally { + env.registry.shutdown() + env.latch.countDown() + } + } + + @Test + fun `task done is true when complete`() { + val done = newEnvAndRun( + """ + local t = async.task("main", 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 = async.task("main", 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 `task wait async yields and returns raw result`() { + val result = newEnvAndRun( + """ + local t = async.task("main", function() block() return 42 end) + local r = t:wait() + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `task wait sync throws on error`() { + val result = newEnvAndRun( + """ + 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 } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `task wait async throws on error`() { + val result = newEnvAndRun( + """ + local t = async.task("main", 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 `task try sync returns ok and result`() { + val result = newEnvAndRun( + """ + local t = async.task("main", function() return 42 end) + while not t.done do end + local ok, r = t:try() + _result = { ok, r } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) + } + + @Test + fun `task try async yields and returns ok and result`() { + val result = newEnvAndRun( + """ + local t = async.task("main", function() block() return 42 end) + local ok, r = t:try() + _result = { ok, r } + """.trimIndent() + ) + assertTrue(result.get(1).toboolean()) + assertEquals(42, result.get(2).toint()) + } + + @Test + fun `task try sync returns false and error message`() { + val result = newEnvAndRun( + """ + local t = async.task("main", function() error("boom") end) + while not t.done do end + local ok, r = t:try() + _result = { ok, r } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `task try async returns false and error message`() { + val result = newEnvAndRun( + """ + local t = async.task("main", 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(async.task, "main", 42) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("expected function")) + } + + @Test + fun `task wait rejects non-task self`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() return ("nope"):wait() end) + _result = { ok, err } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + } + + @Test + fun `task try rejects non-task self`() { + val result = newEnvAndRun( + """ + local ok, err = pcall(function() return ("nope"):try() end) + _result = { ok, err } + """.trimIndent() + ) + 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 + fun `promise resolve and wait`() { + val result = newEnvAndRun( + """ + local p = async.promise() + p:resolve(42) + local r = p:wait() + _result = { r } + """.trimIndent() + ) + assertEquals(42, result.get(1).toint()) + } + + @Test + fun `promise error and try`() { + val result = newEnvAndRun( + """ + local p = async.promise() + p:error("boom") + local ok, msg = p:try() + _result = { ok, msg } + """.trimIndent() + ) + assertFalse(result.get(1).toboolean()) + assertTrue(result.get(2).tojstring().contains("boom")) + } + + @Test + fun `promise resolve returns true`() { + val result = newEnvAndRun( + """ + 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()) + } + + @Test + 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 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("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) + _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("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) + _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("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) + _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()) + } + + // ── 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")) + } +} 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) + } +} 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") + } +} 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..1b59c56 --- /dev/null +++ b/src/version-1.21.10/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -0,0 +1,13 @@ +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(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 new file mode 100644 index 0000000..8e1fe6d --- /dev/null +++ b/src/version-1.21.11/client-kotlin/ru/pyxiion/ignis/client/ClientCompat.kt @@ -0,0 +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 { + 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() { + } +}