Did you check docs and existing issues?
Neovim version (nvim -v)
v0.11.3
Operating system/version
Gentoo Linux
Describe the bug
This was a hard-to-diagnose crash: plain Neovim runs fine, but AstroNvim crashes. The failure happens deep inside LuaJIT, and to pinpoint it I had to rebuild everything with debug symbols and run under Valgrind. That exposed a struct size/layout mismatch. From there the trail led to the time-related FFI declaration, a small early init hook, and the corresponding fix.
Summary
On 32-bit Linux with musl (e.g., ARMv7 hard-float), lazy.nvim segfaults during startup when stats.lua calls clock_gettime via LuaJIT FFI. Root cause: in stats.lua FFI declares:
typedef long time_t;
struct timespec { time_t tv_sec; long tv_nsec; };
On musl 32-bit, time_t is 64-bit, while long is 32-bit. This wrong layout makes clock_gettime write 8 bytes into a 4-byte field (tv_sec), corrupting memory and later crashing the GC (lj_alloc_free).
Environment
Distro: Gentoo, musl 1.2.5 (32-bit)
Arch: ARMv7 hard-float (-march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard)
Neovim: 0.11.3
LuaJIT: 2.1.1731601260
lazy.nvim: current master (Sept 2025)
libc: musl (note: on musl 32-bit sizeof(time_t) == 8)
nvim --version (short):
NVIM v0.11.3
Build type: Release
LuaJIT 2.1.1731601260
Compilation: /usr/lib/distcc/bin/armv7a-unknown-linux-musleabihf-gcc -Os -pipe -march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard -Wall -Wextra -pedantic -Wno-unused-parameter -Wstrict-prototypes -std=gnu99 -Wshadow -Wconversion -Wvla -Wdouble-promotion -Wmissing-noreturn -Wmissing-format-attribute -Wmissing-prototypes -fsigned-char -fstack-protector-strong -Wno-conversion -fno-common -Wno-unused-result -Wimplicit-fallthrough -fdiagnostics-color=always -DUNIT_TESTING -D_GNU_SOURCE -DINCLUDE_GENERATED_DECLARATIONS -DUTF8PROC_STATIC -I/usr/include/luajit-2.1 -I/usr/include -I/var/tmp/portage/app-editors/neovim-0.11.3/work/neovim-0.11.3_build/src/nvim/auto -I/var/tmp/portage/app-editors/neovim-0.11.3/work/neovim-0.11.3_build/include -I/var/tmp/portage/app-editors/neovim-0.11.3/work/neovim-0.11.3_build/cmake.config -I/var/tmp/portage/app-editors/neovim-0.11.3/work/neovim-0.11.3/src
system vimrc file: "/etc/vim/sysinit.vim"
fall-back for $VIM: "/usr/share/nvim"
Why this only hits some platforms:
glibc 32-bit often still has 32-bit time_t unless compiled with _TIME_BITS=64; the mistaken FFI layout happens to match and hides the bug.
musl 32-bit always uses 64-bit time_t, so the mismatch is guaranteed and shows up as a crash.
LuaJIT FFI does not parse system headers/macros, so compiler flags like -D_TIME_BITS=64 don’t help here.
Proposed fix:
function M.cputime()
local ffi = require("ffi")
local is_linux_32 = (jit.os == "Linux") and ffi.abi("32bit")
-- Only set up FFI on non-32-bit Linux. On 32-bit Linux, use the uv fallback.
if M.C == nil and not is_linux_32 then
pcall(function()
ffi.cdef[[
typedef int clockid_t;
/* Do not typedef time_t. Use explicit layout that matches 64-bit ABIs. */
struct timespec { long tv_sec; long tv_nsec; };
typedef struct timespec nanotime;
int clock_gettime(clockid_t clk_id, struct timespec *tp);
]]
M.C = ffi.C
end)
end
local function real()
local pnano = assert(ffi.new("nanotime[?]", 1))
local CLOCK_PROCESS_CPUTIME_ID = jit.os == "OSX" and 12 or 2
ffi.C.clock_gettime(CLOCK_PROCESS_CPUTIME_ID, pnano)
return tonumber(pnano[0].tv_sec) * 1e3 + tonumber(pnano[0].tv_nsec) / 1e6
end
local function fallback()
return (vim.uv.hrtime() - require("lazy")._start) / 1e6
end
if M.C ~= nil then
local ok, ret = pcall(real)
if ok then
M.cputime = real
M._stats.real_cputime = true
return ret
end
end
M.cputime = fallback
return fallback()
end
O-ooo-och!
Why this is “the right thing”:
On 32-bit Linux (musl/glibc with TIME_BITS=64) the time_t layout is tricky. We avoid FFI entirely there.
We never redefine time_t globally in FFI (which can break other modules).
On 64-bit (where long is 64-bit), the explicit struct timespec { long, long } matches the ABI and is safe.
Steps To Reproduce
Repro (minimal):
-
Clean config; load lazy.nvim normally (e.g., AstroNvim or require("lazy").setup{}).
-
Run:
nvim --headless +qa
-
Segfault.
GDB backtrace (short)
Top frames (symbols from distro build):
#0 lj_alloc_free (lj_alloc.c:1400)
#3 lj_cdata_free (lj_cdata.c:83)
#4 gc_sweep (lj_gc.c:423)
#7 lua_pushstring (lj_api.c:669)
#8 lj_cf_package_require (lib_package.c:463) ; name="lazy.view.commands"
#13 lua_pcall (lj_api.c:1151)
#14 nlua_pcall (src/nvim/lua/executor.c:180)
#15 nlua_exec_file (src/nvim/lua/executor.c:1862) ; loading init.lua
Root cause
In stats.lua:
typedef long time_t; // WRONG for musl 32-bit
struct timespec { time_t tv_sec; long tv_nsec; };
int clock_gettime(clockid_t, struct timespec*);
On musl 32-bit, time_t is 64-bit (Y2038-safe ABI). FFI does not include system headers nor honor _TIME_BITS=64; it trusts the manual typedef.
As a result, struct timespec has 4-byte tv_sec in FFI, but kernel/libc writes 8 bytes → memory overwrite → later crash in GC.
Workarounds users can apply today
- One-liner fix (in place):
sed -i -E 's/^[[:space:]]*typedef[[:space:]]+long[[:space:]]+time_t;/typedef long long time_t;/' \
~/.local/share/nvim/lazy/lazy.nvim/lua/lazy/stats.lua
- Early system init hook (no patching plugin files):
/etc/xdg/nvim/sysinit.vim
" --- Build toolchain & flags (why this is right) ---
" Many Neovim plugins build outside Portage via ad-hoc Makefiles/CMake/Ninja and often hardcode -O2
" or override CFLAGS inside their Makefiles. Setting CC/CXX/CFLAGS/CXXFLAGS/LDFLAGS here **and**
" pushing them via MAKEFLAGS forces your ABI (armv7-a, vfpv3-d16, hard-float) and prevents random
" plugin builds from sneaking in wrong arch/opt flags. Using -j1 also avoids racy parallel builds
" on older ARM and keeps logs readable.
let $CC='gcc'
let $CXX='g++'
let $CFLAGS='-Os -pipe -march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard'
let $CXXFLAGS=$CFLAGS
let $LDFLAGS='-Wl,-O1 -Wl,--as-needed'
let $MAKEFLAGS='-j1 CFLAGS=$(CFLAGS) CXXFLAGS=$(CXXFLAGS) LDFLAGS=$(LDFLAGS)'
let $CMAKE_BUILD_PARALLEL_LEVEL='1'
" --- Lazy.nvim time_t workaround (musl 32-bit) ---
" LuaJIT FFI in lazy’s stats path assumed `time_t == long` (32-bit), but on musl 32-bit `time_t` is
" 64-bit. That mismatch corrupts memory and can crash in LuaJIT GC. This early hook defines the
" correct `struct timespec` layout **before** lazy loads. It’s a minimal safety patch; the proper
" upstream fix is to avoid typedef’ing time_t in FFI or skip the FFI path on 32-bit Linux.
" Early musl time_t fix for LuaJIT FFI
lua << EOF
-- Keep JIT ON; just fix the FFI layout on 32-bit musl
local ok, ffi = pcall(require, "ffi"); if not ok then return end
if not ffi.abi("32bit") then return end
-- define once
local has_timespec = pcall(ffi.typeof, "struct timespec")
if not has_timespec then
ffi.cdef[[
typedef long long time_t; /* musl 32-bit: time_t is 64-bit */
typedef int clockid_t;
struct timespec { time_t tv_sec; long tv_nsec; };
typedef struct timespec nanotime;
int clock_gettime(clockid_t clk_id, struct timespec *tp);
]]
end
EOF
Expected Behavior
^_^
Repro
vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
require("lazy.minit").repro({
spec = {
-- add any other plugins here
},
})
Did you check docs and existing issues?
Neovim version (nvim -v)
v0.11.3
Operating system/version
Gentoo Linux
Describe the bug
This was a hard-to-diagnose crash: plain Neovim runs fine, but AstroNvim crashes. The failure happens deep inside LuaJIT, and to pinpoint it I had to rebuild everything with debug symbols and run under Valgrind. That exposed a struct size/layout mismatch. From there the trail led to the time-related FFI declaration, a small early init hook, and the corresponding fix.
Summary
On 32-bit Linux with musl (e.g., ARMv7 hard-float), lazy.nvim segfaults during startup when stats.lua calls clock_gettime via LuaJIT FFI. Root cause: in stats.lua FFI declares:
typedef long time_t;
struct timespec { time_t tv_sec; long tv_nsec; };
On musl 32-bit, time_t is 64-bit, while long is 32-bit. This wrong layout makes clock_gettime write 8 bytes into a 4-byte field (tv_sec), corrupting memory and later crashing the GC (lj_alloc_free).
Environment
Distro: Gentoo, musl 1.2.5 (32-bit)
Arch: ARMv7 hard-float (-march=armv7-a -mfpu=vfpv3-d16 -mfloat-abi=hard)
Neovim: 0.11.3
LuaJIT: 2.1.1731601260
lazy.nvim: current master (Sept 2025)
libc: musl (note: on musl 32-bit sizeof(time_t) == 8)
nvim --version (short):
Why this only hits some platforms:
glibc 32-bit often still has 32-bit time_t unless compiled with _TIME_BITS=64; the mistaken FFI layout happens to match and hides the bug.
musl 32-bit always uses 64-bit time_t, so the mismatch is guaranteed and shows up as a crash.
LuaJIT FFI does not parse system headers/macros, so compiler flags like -D_TIME_BITS=64 don’t help here.
Proposed fix:
O-ooo-och!
Why this is “the right thing”:
On 32-bit Linux (musl/glibc with TIME_BITS=64) the time_t layout is tricky. We avoid FFI entirely there.
We never redefine time_t globally in FFI (which can break other modules).
On 64-bit (where long is 64-bit), the explicit struct timespec { long, long } matches the ABI and is safe.
Steps To Reproduce
Repro (minimal):
Clean config; load lazy.nvim normally (e.g., AstroNvim or require("lazy").setup{}).
Run:
nvim --headless +qa
Segfault.
GDB backtrace (short)
Top frames (symbols from distro build):
Root cause
In stats.lua:
typedef long time_t; // WRONG for musl 32-bit
struct timespec { time_t tv_sec; long tv_nsec; };
int clock_gettime(clockid_t, struct timespec*);
On musl 32-bit, time_t is 64-bit (Y2038-safe ABI). FFI does not include system headers nor honor _TIME_BITS=64; it trusts the manual typedef.
As a result, struct timespec has 4-byte tv_sec in FFI, but kernel/libc writes 8 bytes → memory overwrite → later crash in GC.
Workarounds users can apply today
/etc/xdg/nvim/sysinit.vim
Expected Behavior
^_^
Repro