Skip to content

Script runtime hardening, isolated threadpool states, new event model, dev tools - #24

Merged
PyXiion merged 10 commits into
masterfrom
claude/sleepy-bardeen-br7spn
Sep 28, 2026
Merged

PyXiion merged 10 commits into
masterfrom
claude/sleepy-bardeen-br7spn

Conversation

@PyXiion

@PyXiion PyXiion commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Summary

Runtime safety

  • Instruction checkpoints in PxLuaNova: the interpreter polls a checkpoint handler every N instructions (a counter, not a per-instruction callback). LuaJC / nova.sync code polls on backward jumps and tail-call loops.
  • ScriptWatchdog: Lua running on the server thread longer than 5 s (-Dpxignis.scriptTimeoutMs) is stopped, and pcall cannot swallow the error.
  • Atomic /ignis reload: all scripts are compiled first; a syntax error cancels the reload and the old scripts keep running.

PxLuaNova coroutines

  • pcall/xpcall run as protected frames, so yielding inside pcall works.
  • The old thread-based coroutine model is removed; one resume path remains.
  • The test suite is green and runs in CI (pxluanova job; release depends on it).

Isolated threadpool tasks

  • async tasks on threadpool run in their own LuaState (sandbox + async, no mc).
  • The function, upvalues, arguments and results are copied with the new LuaTransfer; userdata follows StateTransferPolicy.

New event model

  • Built-in events are renamed (player.join, block.break, entity.hurt, …) and pass one event table e; handlers cancel with e:cancel(reason?).
  • Numeric priorities, higher runs first; lowest…highest are aliases for -200…200.
  • Cancelled events skip later handlers unless the handler sets receiveCancelled = true.
  • Fail-closed: a throwing handler cancels a built-in cancellable event (a broken whitelist no longer lets everyone in).
  • Options (priority, receiveCancelled, throttle) work on mc.on and region:on, and may also come before the handler for Nova trailing blocks.
  • Old names keep working as legacy views: positional args, return false, and a deprecation warning.
  • Unknown names warn with a "did you mean" hint.
  • Region events are now enter/leave/move/death with e.region.
  • Fixes: region death handlers never fired, and throttle only counted down while a tick handler existed.

Dev tools

  • Script errors in chat: errors from handlers, commands and scheduled tasks go to players with px.ignis.prompt_errors, rate-limited.
  • LuaLS types: bundled in the jar and unpacked to config/ignis/.types, plus a .luarc.json created once. e is typed per event.
  • /ignis watch on|off: auto-reload when a script is saved.
  • /ignis eval <lua>: run Lua in the scripts' state; me is the calling player.
  • /ignis status: loaded scripts, handlers, commands, scheduled tasks and regions.

Refactoring and docs

  • HTTP/JSON extracted from AsyncLib.
  • Docs, examples, bundled demo.lua/items.lua, README and MODRINTH updated for the new API.
  • Design doc added: docs/events.md.

Breaking changes

  • Handlers registered under old event names are also skipped after cancellation, and also cancel the event when they throw.
  • mc.emit refuses built-in event names; it now returns false when a handler cancelled the event.

Testing

  • PxLuaNova: 712 tests pass.
  • PxIgnis unit tests (kotlinc + JUnit console, since Gradle cannot reach maven.fabricmc.net from the dev environment): 151 pass. New suites: EventModelTest, DevToolsTest, AsyncIsolationTest, ScriptWatchdogTest, LuaTransferTest, LuaStateCheckpointTest.
  • Not compiled locally: code that depends on Minecraft (GameEvents, IgnisCommand, IgnisRuntime, PxIgnis.kt, Region.kt, ScriptEnvironment, LuaMcApi, mixins). CI needs to build it, and it should be checked on a server:
    • events and legacy names
    • /ignis watch, /ignis eval, /ignis status
    • .types extraction
    • chat errors

Notes

  • The changelog entry is drafted but not part of this PR (AGENTS.md: changelog/version changes need review before commit).

🤖 Generated with Claude Code

https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac


Generated by Claude Code

…ckage

- Move async.fetch request/response handling into LuaHttp and JSON<->Lua
  conversion into api/util/LuaJson; AsyncLib keeps only the yield/resume glue.
- Sparse tables like {[2] = x} now encode as a JSON object instead of []
  (previously silently dropped their contents). Empty response bodies decode
  to nil instead of throwing an NPE.
- Fix the typo'd package ru.pyxiion.ignis.api.wrappertoLuaValue -> api.wrapper.
- Add LuaJsonTest and LuaHttpTest (the HTTP/JSON code had no coverage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
Both interpreters now decrement a per-state countdown per instruction and call
LuaState.checkpoint() every checkpointInterval instructions (default 100)
instead of reading the interrupt flag on each one. checkpoint() services
interrupt() requests and polls an optional Builder.checkpointHandler, which
hosts can use for time limits. After the handler throws it is polled again on
the next instruction, so code that catches the error with pcall is rejected
too until it unwinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
- ScriptWatchdog: Lua entered from the server thread (events, commands,
  scheduler, async resumes, chest GUI and mob AI callbacks, script loading)
  is stopped after 5 s (-Dpxignis.scriptTimeoutMs, 0 disables). It hooks
  into PxLuaNova's checkpoint handler, so it costs nothing per instruction.
- reload() compiles every script before tearing anything down. A syntax
  error cancels the reload and the old scripts keep running; a script that
  fails at runtime is reported and the rest still load (previously one
  error stopped later scripts and command registration). /ignis reload
  now reports per-file errors.
- Server start no longer fires "init" twice (reload() already fires it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
mc.sleep/mc.fetch were removed in 0.17.0 but README, MODRINTH, the guides,
references and lua-types still used them. Also document that event handlers
can await async calls directly and the new script time limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
Coroutines:
- pcall/xpcall inside a coroutine run as protected frames of the frame
  interpreter, so code inside them can yield and resume correctly. Before,
  the callee ran in the recursive interpreter: a yield did not suspend it,
  the code after it ran immediately, and resume values were lost.
- Yielding from Lua entered from Java (metamethods, table.sort comparators,
  load readers) now raises "attempt to yield across a C-call boundary"
  instead of silently corrupting the coroutine.
- Resume values were copied one register too far after coroutine.yield,
  overwriting the next local.
- Java functions can be coroutine bodies; yields from generic-for Java
  iterators work; the resumer's status goes back to "running".
- Remove the unused virtual-thread coroutine model (lua_resume/lua_yield,
  thread factories, OrphanedThread).

Other fixes found by the previously failing tests:
- Arithmetic metamethods from the right operand got swapped arguments
  (5 - v called __sub(v, 5)); __le is looked up on both operands.
- Unary minus on a non-numeric string returned 0 instead of an error.
- Weak tables lost integer keys set while the array part grew.
- load() with a reader did not stop at an empty string.
- An enclosing xpcall handler ran for errors caught by an inner pcall.
- print() re-encoded non-UTF-8 bytes.

Tests: 698 pass. The remaining reference-output differences are excluded
one by one, with reasons, in pxluanova-test/build.gradle, and CI now runs
the PxLuaNova suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
nova.sync functions are compiled to JVM bytecode and never went through the
interpreters' instruction countdown, so `nova.sync(function() while true do
end end)()` could not be stopped by a checkpoint handler (PxIgnis'
ScriptWatchdog). LuaJC now emits a call to LuaState.compiledBackwardJump()
before every backward branch, and TailcallVarargs.eval calls it per tail
call; it polls checkpoint() on the current state every 100 calls, with the
same pcall-proof re-check after a rejection. No measurable slowdown on a
50M-iteration numeric loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
snapshot() turns values into a state-independent form on the source
state's thread; materialize() rebuilds them in the target on its own
thread. Tables are deep-copied (cycles, sharing and metatables kept),
Lua functions are rebuilt from their prototype with copied upvalues
(source globals become target globals, shared upvalues stay shared),
library functions and tables are relinked by path, userdata goes through
a pluggable policy, and anything else is an error naming the value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
Lua states are not thread-safe, but async.task("threadpool", ...) ran the
task's closure in the scripts' state on a pool thread, next to the server
thread: any global or shared table access could race.

- Tasks on an isolated executor (threadpool) now run in a fresh worker
  state: the same sandbox and pure libraries plus its own `async`, but no
  mc, register or script globals. The function, its upvalues and the
  arguments are copied in, results are copied back (LuaTransfer); work a
  worker sends to "main" runs in the scripts' state the same way.
- StateTransferPolicy: vectors are copied; players/worlds/entities and
  tasks/promises/mutexes are refused with an error naming the value.
- One AsyncLib per state, created in ScriptEnvironment.rebuild. LuaMcApi
  used to build it eagerly with stateProvider(), i.e. before the first
  state existed, and would have kept using the first state after
  /ignis reload.
- task:try() no longer reads results on the resolving thread.

Main-to-main tasks are unchanged (same state, no copies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
Events:
- Built-in events get new names (player.join, block.break, entity.hurt, ...)
  and pass one event table e; e:cancel(reason?) / e.cancelled.
- Handlers run by numeric priority (higher first, aliases lowest..highest),
  cancelled events skip handlers without receiveCancelled, and a throwing
  handler cancels a built-in cancellable event.
- Options: priority, receiveCancelled, throttle on mc.on and region:on,
  also accepted before the handler (Nova trailing blocks).
- Old names keep working as LegacyEvent views (positional args, return
  false) with a deprecation warning; unknown names warn with a suggestion.
- Region events enter/leave/move/death with e.region; region death now
  fires; throttle uses a tick clock instead of tick-handler countdowns.
- Minecraft callbacks go through events/GameEvents.

Dev tools:
- ScriptErrors: errors and warnings go to the log and, rate-limited, to
  players with px.ignis.prompt_errors.
- LuaLS types bundled in the jar and unpacked to config/ignis/.types,
  plus a .luarc.json created once.
- /ignis watch on|off (auto reload on save), /ignis eval, /ignis status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7gxWF7FdViJa9dKAZJiac
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 288 complexity · 104 duplication

Metric Results
Complexity 288
Duplication 104

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@PyXiion
PyXiion added this pull request to stack #27 September 28, 2026 12:22
@PyXiion
PyXiion merged commit a25b82b into master Sep 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants