luagc: complete incremental GC -- no crashes, no addon errors on /reload

Closures, threads, and protos all go to grayagain for atomic re-traversal.
This covers all upvalue/stack/compiler mutations between mark steps without
needing to hook every individual write site.

Additional fixes this round:
- LUA_TUPVAL handling in markObject (5.1 reallymarkobject pattern)
- isCleared: strings never cleared from weak tables (5.1 lstring.c:337)
- Unified cleartable matching 5.1 (key OR value cleared removes entry)
- Clear stale weak flags when metatable is NULL
- lua_replace barrier hook (0x6F32B0) for upvalue pseudo-index writes
- Compiler barrier hooks (create_constant, finish_function) -- installed
  but superseded by proto grayagain
- fullgc abandon walks all objects with makewhite before restart
- Removed old separate clearWeakValues/clearWeakKeys

CHUNK_SIZE=5000, confirmed incremental: mark=46, sweep=184 on large heaps.
No crashes or addon errors through enter world, /reload, logout/login cycles.
This commit is contained in:
MarcelineVQ
2026-04-11 09:51:26 -07:00
parent a88d1f8411
commit ce5a2408d5
2 changed files with 181 additions and 85 deletions
+6
View File
@@ -40,6 +40,11 @@ pub const cleartablekeys: usize = 0x6F7970;
/// Resize string hash table. __fastcall(ECX=L, EDX=new_size).
pub const lua_string_hash_resize: usize = 0x6F9C50;
/// lua_replace (lua_copy_value): __fastcall(ECX=L, EDX=idx).
/// Writes stack top to the given index. For upvalue pseudo-indices,
/// writes directly to closure upvalue slots without any barrier.
pub const lua_replace: usize = 0x6F32B0;
/// Forward barrier hook targets (Lua C API functions that create cross-object refs).
pub const lua_setmetatable: usize = 0x6F4020;
pub const lua_setupvalue: usize = 0x6F47B0;
@@ -186,6 +191,7 @@ pub const LUA_TFUNCTION: u8 = 6;
pub const LUA_TUSERDATA: u8 = 7;
pub const LUA_TTHREAD: u8 = 8;
pub const LUA_TPROTO: u8 = 9;
pub const LUA_TUPVAL: u8 = 10;
/// ---------------------------------------------------------------------------
// TValue (16 bytes in WoW Lua 5.0)
+175 -85
View File
@@ -347,13 +347,32 @@ fn markObject(obj: u32) bool {
return true;
},
offsets.LUA_TUSERDATA => {
// Userdata: go black immediately, mark metatable inline
// Userdata: go black immediately, mark metatable inline (5.1 line 77-81)
white2gray(obj);
gray2black(obj);
const mt = readU32(obj + offsets.UDATA_metatable);
if (mt != 0) _ = markObject(mt);
return true;
},
offsets.LUA_TUPVAL => {
// Upvalue: mark value, if closed go black, if open stay gray (5.1 line 83-88)
// uv->v at +0x08. Closed if v == &uv->u.value (points to +0x10).
white2gray(obj);
const v_ptr = readU32(obj + 0x08);
if (v_ptr != 0) {
const v_tt = readU8(v_ptr + offsets.TVALUE_tt);
if (v_tt >= offsets.LUA_TSTRING) {
const v_gc = readU32(v_ptr + offsets.TVALUE_gcptr);
if (v_gc != 0) _ = markObject(v_gc);
}
}
// Closed upvalue: v points to inline value at obj+0x10
if (v_ptr == obj + 0x10) {
gray2black(obj);
}
// Open upvalues stay gray (never black)
return true;
},
else => {
// Tables, closures, threads, protos: go to gray stack
white2gray(obj);
@@ -399,9 +418,13 @@ fn propagatemark(obj: u32) void {
if (is_weak) black2gray(obj); // weak tables stay gray
},
offsets.LUA_TFUNCTION => {
// Closures go to grayagain (like threads/protos). Upvalues can
// be modified between mark steps via lua_replace, OP_SETUPVAL,
// lua_setupvalue, and other paths. Re-traversal in atomic catches
// all mutations regardless of which path modified the upvalue.
dbg_closure_count += 1;
gray2black(obj);
traverseClosure(obj);
grayagainAdd(obj);
},
offsets.LUA_TTHREAD => {
// Lua 5.1: threads NEVER go black. After traversal, add to
@@ -411,9 +434,15 @@ fn propagatemark(obj: u32) void {
grayagainAdd(obj); // stays gray, re-traversed in atomic
},
offsets.LUA_TPROTO => {
// Protos go to grayagain (like threads). The Lua compiler adds
// constants, nested protos, locvars, and upvalue names between
// mark steps. 5.1 uses 4 explicit barriers (lcode.c:244,
// lparser.c:151,199,319). We use grayagain re-traversal instead
// because the barrier sites are internal compiler functions with
// complex calling conventions that vary per call site.
dbg_proto_count += 1;
gray2black(obj);
traverseProto(obj);
grayagainAdd(obj);
},
offsets.LUA_TUSERDATA => {
// Should not reach here -- userdata goes black in markObject.
@@ -877,87 +906,77 @@ fn marktmuImpl(g: u32) void {
}
}
/// Clear dead entries from weak tables collected during mark.
/// Clear dead entries from weak tables. Matches 5.1 cleartable (lgc.c:351-375).
/// Single pass: for hash nodes, if EITHER key or value is cleared, remove entry.
fn atomicClearWeakTables() void {
var i: u32 = 0;
while (i < weak_table_count) : (i += 1) {
const table = weak_table_list[i];
const marked = readU8(table + offsets.OBJ_marked);
if ((marked & offsets.VALUEWEAK) != 0) clearWeakValues(table);
if ((marked & offsets.KEYWEAK) != 0) clearWeakKeys(table);
const has_weakvalue = (marked & offsets.VALUEWEAK) != 0;
const has_weakkey = (marked & offsets.KEYWEAK) != 0;
// Array part: only clear if value-weak
if (has_weakvalue) {
const array_ptr = readU32(table + offsets.TABLE_array);
const sizearray = readU32(table + offsets.TABLE_sizearray);
if (array_ptr != 0) {
var ai: u32 = 0;
while (ai < sizearray) : (ai += 1) {
const tv = array_ptr + ai * offsets.TVALUE_size;
if (isCleared(readU8(tv + offsets.TVALUE_tt), readU32(tv + offsets.TVALUE_gcptr))) {
writeU8(tv + offsets.TVALUE_tt, offsets.LUA_TNIL);
writeU32(tv + offsets.TVALUE_gcptr, 0);
}
}
}
}
// Hash part: clear if key OR value is cleared (5.1 line 367-368)
const node_ptr = readU32(table + offsets.TABLE_node);
if (node_ptr == 0) continue;
const lsizenode = readU8(table + offsets.TABLE_lsizenode);
const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode));
var ni: u32 = 0;
while (ni < sizenode) : (ni += 1) {
const node = node_ptr + ni * offsets.NODE_size;
const val_tt = readU8(node + offsets.NODE_value_tt);
if (val_tt == 0) continue; // already nil, skip
const key_tt = readU8(node + offsets.NODE_key_tt);
const key_gc = readU32(node + offsets.NODE_key_gcptr);
const val_gc = readU32(node + offsets.NODE_value_gcptr);
const key_dead = has_weakkey and isCleared(key_tt, key_gc);
const val_dead = has_weakvalue and isCleared(val_tt, val_gc);
if (key_dead or val_dead) {
// removekey: nil value + mark collectable key as dead
writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL);
writeU32(node + offsets.NODE_value_gcptr, 0);
if (key_tt >= offsets.LUA_TSTRING) {
writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE);
}
}
}
}
}
/// Clear dead entries from weak-value tables. Matches native cleartablevalues.
/// For hash nodes, uses removekey semantics: nil value + set dead key to TNONE.
fn clearWeakValues(table: u32) void {
// Array part: just nil dead values
const array_ptr = readU32(table + offsets.TABLE_array);
const sizearray = readU32(table + offsets.TABLE_sizearray);
if (array_ptr != 0) {
var i: u32 = 0;
while (i < sizearray) : (i += 1) {
const tv = array_ptr + i * offsets.TVALUE_size;
const tt = readU8(tv + offsets.TVALUE_tt);
if (tt >= offsets.LUA_TSTRING) {
const gc = readU32(tv + offsets.TVALUE_gcptr);
if (gc != 0 and isWhite(gc)) {
writeU8(tv + offsets.TVALUE_tt, offsets.LUA_TNIL);
writeU32(tv + offsets.TVALUE_gcptr, 0);
}
}
}
}
// Hash part: removekey -- nil value AND mark collectable keys as TNONE
const node_ptr = readU32(table + offsets.TABLE_node);
if (node_ptr == 0) return;
const lsizenode = readU8(table + offsets.TABLE_lsizenode);
const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode));
var ni: u32 = 0;
while (ni < sizenode) : (ni += 1) {
const node = node_ptr + ni * offsets.NODE_size;
const val_tt = readU8(node + offsets.NODE_value_tt);
if (val_tt < offsets.LUA_TSTRING) continue;
const val_gc = readU32(node + offsets.NODE_value_gcptr);
if (val_gc == 0) continue;
if (isWhite(val_gc)) {
// removekey: nil value
writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL);
writeU32(node + offsets.NODE_value_gcptr, 0);
// removekey: mark collectable key as dead
const key_tt = readU8(node + offsets.NODE_key_tt);
if (key_tt >= offsets.LUA_TSTRING) {
writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE);
}
}
}
}
/// Clear dead entries from weak-key tables. Matches native cleartablekeys.
/// Uses removekey semantics: nil value + set dead collectable key to TNONE.
fn clearWeakKeys(table: u32) void {
const node_ptr = readU32(table + offsets.TABLE_node);
if (node_ptr == 0) return;
const lsizenode = readU8(table + offsets.TABLE_lsizenode);
const sizenode: u32 = @as(u32, 1) << @as(u5, @intCast(lsizenode));
var ni: u32 = 0;
while (ni < sizenode) : (ni += 1) {
const node = node_ptr + ni * offsets.NODE_size;
const key_tt = readU8(node + offsets.NODE_key_tt);
if (key_tt < offsets.LUA_TSTRING) continue;
const key_gc = readU32(node + offsets.NODE_key_gcptr);
if (key_gc == 0) continue;
if (isWhite(key_gc)) {
// removekey: nil value
writeU8(node + offsets.NODE_value_tt, offsets.LUA_TNIL);
writeU32(node + offsets.NODE_value_gcptr, 0);
// removekey: mark collectable key as dead
writeU8(node + offsets.NODE_key_tt, offsets.LUA_TNONE);
}
/// 5.1 iscleared equivalent: check if a TValue should be cleared from a weak
/// table. Strings are NEVER cleared (they're "values", always kept). Non-string
/// collectables are cleared if white. Non-collectables are never cleared.
fn isCleared(tv_tt: u8, tv_gc: u32) bool {
if (tv_tt < offsets.LUA_TSTRING) return false; // not collectable
if (tv_tt == offsets.LUA_TSTRING) {
// Strings are values, never weak. Mark them to ensure survival.
if (tv_gc != 0) stringmark(tv_gc);
return false;
}
if (tv_gc == 0) return false;
return isWhite(tv_gc);
}
// =============================================================================
@@ -1387,10 +1406,42 @@ fn setgcthresholdDetour(L: u32, newthreshold: u32) callconv(hook.cc.fastcall) vo
if (readU32(L + offsets.L_active_check) == 0) return;
// 5.1 luaC_fullgc pattern: if a forced GC arrives mid-cycle,
// abandon the current cycle and start fresh. Without this,
// we'd continue processing stale gray stack entries from a
// partially-traversed state after /reload swaps in new objects.
// abandon the current cycle. Must reset ALL objects to white first
// (matching 5.1's sweep-everything-back-to-white before restart).
// Without this, BLACK objects from the abandoned mark are invisible
// to the new cycle and their children may be incorrectly freed.
if (phase != .idle) {
const g = getGlobalState(L);
// Walk rootgc and makewhite everything (clear BLACK/GRAY, set currentwhite)
var obj = readU32(g + offsets.GS_rootgc);
while (obj != 0) {
makewhite(obj);
obj = readU32(obj + offsets.OBJ_next);
}
// Walk rootudata
obj = readU32(g + offsets.GS_rootudata);
while (obj != 0) {
makewhite(obj);
obj = readU32(obj + offsets.OBJ_next);
}
// Walk string hash table
const hash_array = readU32(g + offsets.GS_strt_hash);
const bucket_count = readU32(g + offsets.GS_strt_size);
if (hash_array != 0) {
var bi: u32 = 0;
while (bi < bucket_count) : (bi += 1) {
var s = readU32(hash_array + bi * 4);
while (s != 0) {
const old = readU8(s + offsets.OBJ_marked);
writeU8(s + offsets.OBJ_marked, (old & offsets.MASKMARKS) | currentwhite);
s = readU32(s + offsets.OBJ_next);
}
}
}
// Mainthread
const mt = readU32(g + offsets.GS_mainthread);
if (mt != 0) makewhite(mt);
phase = .idle;
gray_count = 0;
grayagain_count = 0;
@@ -1575,6 +1626,44 @@ fn resolveStackIndex(L: u32, idx: u32) u32 {
}
}
// =============================================================================
// lua_replace barrier (5.1 lapi.c:220-221)
// =============================================================================
//
// lua_replace writes to stack indices, upvalue pseudo-indices, and
// LUA_ENVIRONINDEX/LUA_GLOBALSINDEX. For upvalue writes (idx < LUA_GLOBALSINDEX),
// 5.1 fires luaC_barrier(L, curr_func(L), value). WoW's 5.0 has no barrier.
// The native lua_replace does a direct 16-byte copy with no CALL to any
// hooked function.
const LuaReplaceFn = fn (u32, u32) callconv(hook.cc.fastcall) void;
var lua_replace_hook: hook.Detour(LuaReplaceFn) = .{};
fn luaReplaceDetour(L: u32, idx: u32) callconv(hook.cc.fastcall) void {
lua_replace_hook.callOriginal(.{ L, idx });
// Barrier for upvalue writes: idx < LUA_GLOBALSINDEX (0xffffd8f0)
// LUA_GLOBALSINDEX in Lua 5.0 = -10000 (0xffffd8f0 as u32)
if (phase == .marking) {
const idx_i: i32 = @bitCast(idx);
if (idx_i < -10000) {
// Upvalue or environment write. Barrier the running closure.
const ci_base = readU32(L + 0x0C); // L->base
if (ci_base != 0) {
// curr_func is at base[-1] (the closure TValue below the base)
const func_tv = ci_base - offsets.TVALUE_size;
const func_tt = readU8(func_tv + offsets.TVALUE_tt);
if (func_tt == offsets.LUA_TFUNCTION) {
const func_gc = readU32(func_tv + offsets.TVALUE_gcptr);
if (func_gc != 0 and isBlack(func_gc)) {
black2gray(func_gc);
grayagainAdd(func_gc);
}
}
}
}
}
}
// =============================================================================
// String resurrection (5.1 lstring.c:88)
// =============================================================================
@@ -1824,6 +1913,11 @@ pub fn installHooks() void {
installed += 1;
log.print("hooked luaC_link\n");
}
// lua_replace barrier (5.1 lapi.c:220-221)
if (lua_replace_hook.attach(offsets.lua_replace, &luaReplaceDetour) == .ok) {
installed += 1;
}
// Resurrection hooks (5.1 lstring.c:88, lfunc.c:61-62)
if (string_create_hook.attach(offsets.luaS_newlstr, &stringCreateDetour) == .ok) {
installed += 1;
@@ -1843,13 +1937,8 @@ pub fn installHooks() void {
installed += 1;
}
// Note: lua_pushcclosure doesn't need a barrier (new closures are WHITE)
// Compiler barriers (5.1 lcode.c, lparser.c)
if (create_constant_hook.attach(offsets.lua_parser_create_constant, &createConstantDetour) == .ok) {
installed += 1;
}
if (finish_function_hook.attach(offsets.lua_parser_finish_function, &finishFunctionDetour) == .ok) {
installed += 1;
}
// Compiler barriers: covered by proto grayagain in propagatemark.
// Direct hooks removed -- proto is re-traversed in atomic phase instead.
installSetupvalPatch();
log.print("installed forward barriers\n");
@@ -1875,13 +1964,14 @@ pub fn removeHooks() void {
setgcthreshold_hook.detach();
lua_close_hook.detach();
luac_link_hook.detach();
lua_replace_hook.detach();
string_create_hook.detach();
upval_create_hook.detach();
setmeta_hook.detach();
setupval_hook.detach();
setfenv_hook.detach();
create_constant_hook.detach();
finish_function_hook.detach();
// create_constant_hook.detach(); // not installed
// finish_function_hook.detach(); // not installed
removeSetupvalPatch();
luaalloc.remove();