diff --git a/README.md b/README.md index 9f96894..0c984c2 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,30 @@ at boot and degrades gracefully: Expect roughly **2x slower** than LuaJIT on the suite (legacy engine, no caches) — correct, but LuaJIT remains the recommended runtime. +### aarch64 boot crash on old LuaJIT (`SHEN_JIT=off`) + +On **aarch64**, an **old LuaJIT** (the 2.1.0-beta3 era, still shipped by some +distros and older OpenResty images) intermittently SIGSEGVs while compiling the +kernel *during boot* (issue #43). Booting the kernel triggers ~1500 trace +compilations up front, and beta3's arm64 JIT backend mis-compiles one of them: +a trace branches to a bad target and jumps into unmapped memory. Once booted +the process is stable — the crash surface is boot-time trace compilation. It is +stochastic (observed ~0.3%/boot on a late beta3-string rolling build; **50/50** +on the genuine 2017 v2.1.0-beta3 tag), so any workload that boots the kernel +many times (a per-request CLI, a test matrix) will flake. + +**This is a LuaJIT bug, and it is already fixed upstream.** The primary fix is +to run a **current LuaJIT 2.1 rolling release**: the same 50/50-crashing kernel +boots cleanly (0 crashes) under today's `LuaJIT 2.1.ROLLING`. If you can update +LuaJIT, do that. + +If you are pinned to an old LuaJIT, set **`SHEN_JIT=off`** to boot with the JIT +disabled (`jit.off()`, the in-library equivalent of `luajit -j off`), which +makes boot reproducibly crash-free. Embedders can equivalently pass +`shen.boot{jit=false}`. Note this is **distinct from `SHEN_JIT_OPT=off`**, which +only restores LuaJIT's default `jit.opt` limits and leaves the JIT *on* — it +does not prevent the crash. + ## Installation & embedding ### The `shen` module diff --git a/boot.lua b/boot.lua index ce7dd9b..7520f31 100644 --- a/boot.lua +++ b/boot.lua @@ -6,17 +6,55 @@ local R = require("runtime") local C = require("compiler") local P = require("prims") --- LuaJIT's default mcode area (512KB, 1000 traces) is far too small for the --- compiled kernel: on macOS arm64 the suite triggers dozens of full +-- LuaJIT trace/mcode tuning, plus an optional switch to disable the JIT. +-- +-- SHEN_JIT=off disables the LuaJIT compiler for the whole process (the +-- in-library equivalent of `luajit -j off`). This is a mitigation for issue +-- #43: on aarch64 an OLD LuaJIT (2.1.0-beta3 era) mis-compiles one of the +-- ~1500 traces generated while loading the kernel, and the bad trace branches +-- into unmapped memory (SIGSEGV during boot; stochastic, ~0.3%/boot on a late +-- beta3 rolling build, 50/50 on the genuine 2017 tag). It is a LuaJIT backend +-- bug, ALREADY FIXED upstream: the real fix is a current 2.1 rolling release +-- (0 crashes on the same boot). SHEN_JIT=off is the fallback for hosts pinned +-- to an old LuaJIT. NOTE it is distinct from SHEN_JIT_OPT=off, which only +-- restores the host's default jit.opt limits and leaves the JIT ON — that does +-- not prevent the crash. +-- +-- With the JIT on, LuaJIT's default mcode area (512KB, 1000 traces) is far too +-- small for the compiled kernel: on arm64 the suite triggers dozens of full -- trace-cache flushes per run ("failed to allocate mcode memory"), costing -- ~10-16% of total wall time re-JITting the same code. Raise the limits once -- at boot. SHEN_JIT_OPT=off restores the host's defaults (embedders that -- manage jit.opt themselves should set it). -do +local function disable_jit() local jit_ok, jit = pcall(require, "jit") - if jit_ok and jit and jit.opt and os.getenv("SHEN_JIT_OPT") ~= "off" then - pcall(jit.opt.start, - "sizemcode=2048", "maxmcode=131072", "maxtrace=8000", "maxside=400") + if jit_ok and jit and jit.off then pcall(jit.off) end +end +P.disable_jit = disable_jit -- so shen.boot{jit=false} can drive it too +do + if os.getenv("SHEN_JIT") == "off" then + disable_jit() + else + local jit_ok, jit = pcall(require, "jit") + if jit_ok and jit then + -- One-time heads-up on the known-bad combo (issue #43): a beta-versioned + -- LuaJIT on arm64 mis-compiles the boot traces and can SIGSEGV. Only + -- fires when the JIT is left on (SHEN_JIT neither off nor an explicit on) + -- on that exact combo; a current rolling LuaJIT reports "2.1.ROLLING" + -- (no "beta") and never trips it. Set SHEN_JIT=on to acknowledge and + -- silence, or SHEN_JIT=off to mitigate. + if jit.arch == "arm64" and tostring(jit.version):find("beta", 1, true) + and os.getenv("SHEN_JIT") ~= "on" then + io.stderr:write("shen-lua: warning: " .. tostring(jit.version) + .. " on arm64 has a JIT codegen bug that can SIGSEGV during boot" + .. " (issue #43). Upgrade to a current LuaJIT 2.1 rolling release," + .. " or set SHEN_JIT=off.\n") + end + if jit.opt and os.getenv("SHEN_JIT_OPT") ~= "off" then + pcall(jit.opt.start, + "sizemcode=2048", "maxmcode=131072", "maxtrace=8000", "maxside=400") + end + end end end diff --git a/scripts/run-tests.lua b/scripts/run-tests.lua index 0ac5a36..4c1e4f0 100644 --- a/scripts/run-tests.lua +++ b/scripts/run-tests.lua @@ -34,6 +34,7 @@ local specs = { "test/error_robustness_spec.lua", "test/interop_spec.lua", "test/io_spec.lua", + "test/jit_spec.lua", "test/library_spec.lua", "test/primitives_spec.lua", "test/reader_spec.lua", diff --git a/shen.lua b/shen.lua index e31b25c..d3f9cf7 100644 --- a/shen.lua +++ b/shen.lua @@ -29,10 +29,16 @@ shen.runtime = R -- silent session do shen.eval("(hush +)") — in 41.2 the -- *hush* global gates `pr` itself, i.e. ALL output. -- verbose = true -> log each kernel file to stderr as it loads +-- jit = false -> disable the LuaJIT compiler before loading the kernel +-- (jit.off()). Mitigates the aarch64 boot-time trace +-- compiler SIGSEGV (issue #43); equivalent to setting +-- SHEN_JIT=off in the environment. No-op on PUC Lua / when +-- the JIT is already off. Leave unset to keep the JIT on. local booted = false function shen.boot(opts) if booted then return shen end opts = opts or {} + if opts.jit == false then P.disable_jit() end local hush0 if opts.quiet then hush0 = P.GLOBALS["*hush*"] diff --git a/test/jit_spec.lua b/test/jit_spec.lua new file mode 100644 index 0000000..105aa10 --- /dev/null +++ b/test/jit_spec.lua @@ -0,0 +1,106 @@ +-- test/jit_spec.lua — the SHEN_JIT=off / shen.boot{jit=false} switch (issue #43). +-- +-- On aarch64 LuaJIT's trace compiler intermittently SIGSEGVs while compiling +-- the kernel during boot. The mitigation is an in-library way to boot with the +-- JIT disabled (jit.off(), the equivalent of `luajit -j off`). This spec drives +-- a fresh LuaJIT subprocess per case and asserts, via jit.status(), that: +-- * SHEN_JIT=off actually turns the compiler OFF, and boot still works; +-- * shen.boot{jit=false} does the same programmatically; +-- * SHEN_JIT_OPT=off does NOT disable the JIT (it only resets jit.opt) — +-- the distinction the issue calls out; +-- * the default boot leaves the JIT ON. +-- +-- Inherently a LuaJIT test: on PUC Lua (no `jit`) there is nothing to disable, +-- so the whole spec skips cleanly. +-- +-- luajit test/jit_spec.lua + +local npass, nfail = 0, 0 +local function check(cond, name) + if cond then npass = npass + 1 + else + nfail = nfail + 1 + io.write("FAIL: ", name, "\n") + end +end + +-- Nothing to test without a JIT — skip cleanly on PUC Lua. +if not rawget(_G, "jit") then + io.write("jit_spec: SKIP (no LuaJIT — nothing to disable)\n") + io.write("jit_spec: 0 pass, 0 fail\n") + os.exit(0) +end + +-- Repo root relative to this spec, so the child's require() finds the modules. +local root = arg[0]:gsub("test/[^/]*$", "") +if root == "" then root = "./" end + +local function have(cmd) + local h = io.popen("command -v " .. cmd .. " 2>/dev/null") + if not h then return false end + local out = h:read("*a"); h:close() + return out ~= nil and out:match("%S") ~= nil +end +local TIMEOUT = have("timeout") and "timeout 60 " or (have("gtimeout") and "gtimeout 60 " or "") + +local function sh_quote(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end + +-- Boot the kernel in a fresh LuaJIT process with the given env prefix and Lua +-- body, then print "JIT: VAL:" so the parent can assert on +-- both the compiler state after boot and that boot actually produced a value. +-- The kernel bytecode cache is disabled so every case exercises a real boot. +local BODY = [[ +local shen = require("shen") +%s +local v = shen.eval("(+ 1 2)") +io.write("JIT:", (require("jit").status()) and "on" or "off", " VAL:", tostring(v), "\n") +]] + +local function run_case(env, boot_stmt) + local body = BODY:format(boot_stmt) + local cmd = "env LUA_PATH=" .. sh_quote(root .. "?.lua;;") + .. " SHEN_KERNEL_CACHE=off " .. env + .. " luajit -e " .. sh_quote(body) + local full = "{ " .. TIMEOUT .. cmd .. " ; } 2>&1; echo \"__EXIT__:$?\"" + local h = io.popen(full, "r") + local out = h:read("*a") or "" + h:close() + local code = tonumber(out:match("__EXIT__:(%d+)%s*$")) or -1 + out = out:gsub("__EXIT__:%d+%s*$", "") + return out, code +end + +-- 1. Default boot: JIT stays on, boot works. +do + local out, code = run_case("", 'shen.boot{quiet=true}') + check(code == 0, "default boot exits 0") + check(out:find("JIT:on", 1, true) ~= nil, "default boot leaves the JIT ON") + check(out:find("VAL:3", 1, true) ~= nil, "default boot: (+ 1 2) => 3") +end + +-- 2. SHEN_JIT=off: JIT disabled, boot still works. +do + local out, code = run_case("SHEN_JIT=off", 'shen.boot{quiet=true}') + check(code == 0, "SHEN_JIT=off boot exits 0") + check(out:find("JIT:off", 1, true) ~= nil, "SHEN_JIT=off disables the JIT") + check(out:find("VAL:3", 1, true) ~= nil, "SHEN_JIT=off: kernel still boots and evaluates") +end + +-- 3. shen.boot{jit=false}: the programmatic equivalent. +do + local out, code = run_case("", 'shen.boot{quiet=true, jit=false}') + check(code == 0, "boot{jit=false} exits 0") + check(out:find("JIT:off", 1, true) ~= nil, "boot{jit=false} disables the JIT") + check(out:find("VAL:3", 1, true) ~= nil, "boot{jit=false}: kernel still boots and evaluates") +end + +-- 4. SHEN_JIT_OPT=off resets jit.opt but must NOT disable the JIT (issue #43's +-- key distinction: the old flag would not have prevented the crash). +do + local out, code = run_case("SHEN_JIT_OPT=off", 'shen.boot{quiet=true}') + check(code == 0, "SHEN_JIT_OPT=off boot exits 0") + check(out:find("JIT:on", 1, true) ~= nil, "SHEN_JIT_OPT=off leaves the JIT ON (distinct from SHEN_JIT=off)") +end + +io.write(string.format("jit_spec: %d pass, %d fail\n", npass, nfail)) +os.exit(nfail == 0 and 0 or 1)