Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 44 additions & 6 deletions boot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions scripts/run-tests.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions shen.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]
Expand Down
106 changes: 106 additions & 0 deletions test/jit_spec.lua
Original file line number Diff line number Diff line change
@@ -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:<on|off> VAL:<eval result>" 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)
Loading