From 37c438a4f4b31f7eb89cbbef5c2fc28820b97172 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Tue, 7 Jul 2026 15:01:20 -0300 Subject: [PATCH 1/5] fix(xpcall): catch errors under direct PCall/DoString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: xpcall leaked its error and never invoked the message handler when invoked outside a coroutine (e.g. under DoString/PCall), aborting the surrounding chunk. basePCall was updated to wrap CallK in its own defer/recover, but baseXPCall was left depending on threadRun's recover via handleProtectedError — a layer that only exists inside coroutines. What: - Wrap baseXPCall's CallK in an inline defer/recover, mirroring basePCall. - Invoke the error handler BEFORE resetting the stack so it can inspect the throw site (debug.getlocal / debug.traceback), matching standard Lua semantics (PCall's own errfunc path does the same). - Add errorObjectFromRecover and invokeErrorHandler helpers. - Add xpcall_direct_test.go covering direct-call catch, chunk preservation, success path, nesting under pcall, and the still-working coroutine path. --- baselib.go | 74 +++++++++++++++++++++-- xpcall_direct_test.go | 133 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 xpcall_direct_test.go diff --git a/baselib.go b/baselib.go index ff8a23c7c..2226b5e72 100644 --- a/baselib.go +++ b/baselib.go @@ -405,27 +405,93 @@ func baseXPCall(L *LState) int { fn := L.CheckFunction(1) errfunc := L.CheckFunction(2) - // Mark the xpcall frame as protected for error handling + // Mark the xpcall frame as protected. handleProtectedError (in threadRun) + // honors this for errors that surface after a yield inside a coroutine. + // The inline recover below is the boundary for synchronous errors and is + // the only recover layer present under a direct DoString/PCall call, which + // is why xpcall previously leaked its error in non-coroutine contexts. L.currentFrame.Protected = true L.setFrameExt(L.currentFrame).ErrFunc = errfunc top := L.GetTop() + sp := L.stack.Sp() L.Push(fn) + base := L.reg.Top() - 1 // nargs == 0 for xpcall's protected call - // Use CallK with continuation for yield-transparent xpcall - L.CallK(0, MultRet, xpcallContinuation, top) + var errValue LValue + func() { + defer func() { + if rcv := recover(); rcv != nil { + errValue = errorObjectFromRecover(rcv) + } + }() + L.CallK(0, MultRet, xpcallContinuation, top) + }() - // Check for yield before any cleanup + // Check for yield before any cleanup. if L.yieldState != yieldNone { return -1 } + // Clear the protected marker and errfunc binding regardless of outcome. L.currentFrame.Protected = false if ext := L.getFrameExt(L.currentFrame); ext != nil { ext.ErrFunc = nil } + + if errValue != nil { + // Invoke the handler BEFORE resetting the stack, while the errored + // frames are still on L.stack. This matches standard Lua semantics so + // the handler can inspect the throw site (debug.getlocal, + // debug.traceback, etc.). PCall's own errfunc path does the same. + handled := invokeErrorHandler(L, errfunc, errValue, sp, base) + + L.stack.SetSp(sp) + L.currentFrame = L.stack.Last() + L.reg.SetTop(base) + L.Push(LFalse) + L.Push(handled) + return 2 + } + L.Insert(LTrue, top+1) return L.GetTop() - top } +// errorObjectFromRecover extracts the Lua value carried by a recovered panic. +// Lua errors panic with *ApiError; anything else is stringified. +func errorObjectFromRecover(rcv any) LValue { + if aerr, ok := rcv.(*ApiError); ok { + return aerr.Object + } + + return LString(fmt.Sprint(rcv)) +} + +// invokeErrorHandler calls the xpcall error handler with the caught error +// value while the throw-site frames are still on the stack. If the handler +// itself raises, its error value is surfaced instead of the original. sp/base +// are the pre-xpcall-call snapshots used to reset on handler failure. +func invokeErrorHandler(L *LState, errfunc *LFunction, errValue LValue, sp, base int) LValue { + L.Push(errfunc) + L.Push(errValue) + + var handled LValue = errValue + func() { + defer func() { + if rcv := recover(); rcv != nil { + L.stack.SetSp(sp) + L.currentFrame = L.stack.Last() + L.reg.SetTop(base) + handled = errorObjectFromRecover(rcv) + } + }() + + L.Call(1, 1) + handled = L.Get(-1) + }() + + return handled +} + /* }}} */ diff --git a/xpcall_direct_test.go b/xpcall_direct_test.go new file mode 100644 index 000000000..bb5e4cb2c --- /dev/null +++ b/xpcall_direct_test.go @@ -0,0 +1,133 @@ +package lua + +import ( + "strings" + "testing" +) + +// TestXPCallDirectCallCatchesError verifies that xpcall catches errors when +// invoked in a direct (non-coroutine) context, i.e. under DoString/PCall. +// +// basePCall wraps CallK in its own defer/recover (baselib.go), so pcall works +// whether the outermost boundary is PCall or threadRun. baseXPCall historically +// relied on threadRun's recover via handleProtectedError, which is ONLY the +// boundary inside coroutines. Under DoString the panic hit PCall's recover +// first, errFunc never fired, and the error leaked past xpcall. +func TestXPCallDirectCallCatchesError(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local ok, err = xpcall(function() error("boom") end, function(e) + return "handled: " .. tostring(e) + end) + assert(ok == false, "xpcall must return false on error") + assert(type(err) == "string", "xpcall must return the handler's value") + assert(string.find(err, "handled"), "xpcall must invoke the handler; got: " .. tostring(err)) + `) + if err != nil { + t.Fatalf("xpcall leaked the error past the call (bug): %v", err) + } +} + +// TestXPCallDirectCallPreservesSurroundingChunk verifies that a leaked xpcall +// error must not abort the rest of the chunk. Before the fix, the panic +// propagated through DoString and killed every statement after the xpcall. +func TestXPCallDirectCallPreservesSurroundingChunk(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local xpcall_ok, xpcall_err = xpcall(function() error("boom") end, function(e) + return "handled" + end) + after_marker = "reached" + `) + if err != nil { + t.Fatalf("xpcall error leaked and aborted the chunk: %v", err) + } + + if got := L.GetGlobal("after_marker").String(); got != "reached" { + t.Fatalf("statement after xpcall did not execute; after_marker=%q", got) + } +} + +// TestXPCallDirectCallReturnsTrueOnSuccess verifies the success path under +// direct call. +func TestXPCallDirectCallReturnsTrueOnSuccess(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local ok, val = xpcall(function() return "ok-value" end, function(e) + return "should-not-run" + end) + assert(ok == true, "xpcall must return true on success") + assert(val == "ok-value", "xpcall must return the function's results; got: " .. tostring(val)) + `) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestXPCallNestedUnderPCall verifies xpcall also works when nested inside a +// pcall in a direct context (a common recovery pattern). +func TestXPCallNestedUnderPCall(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local outer_ok, outer_err = pcall(function() + local ok, err = xpcall(function() error("inner") end, function(e) + return "handled: " .. tostring(e) + end) + assert(ok == false, "xpcall should have caught the error") + assert(string.find(tostring(err), "handled"), "handler should have run") + return "completed" + end) + assert(outer_ok == true, "pcall must succeed because xpcall handled the error; got err: " .. tostring(outer_err)) + assert(outer_err == "completed", "unexpected outer return: " .. tostring(outer_err)) + `) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestXPCallCoroutineStillWorks guards against the fix breaking the existing +// coroutine path (where xpcall was already handled via threadRun). +func TestXPCallCoroutineStillWorks(t *testing.T) { + L := NewState() + defer L.Close() + + if err := L.DoString(` + function with_handler() + local ok, err = xpcall(function() error("co-boom") end, function(e) + return "co-handled: " .. tostring(e) + end) + return ok, err + end + `); err != nil { + t.Fatal(err) + } + + co, cancel := L.NewThread() + defer cancel() + fn := L.GetGlobal("with_handler").(*LFunction) + + state, results, err := L.Resume(co, fn) + if err != nil { + t.Fatalf("resume failed: %v", err) + } + if state != ResumeOK { + t.Fatalf("expected ResumeOK, got %v", state) + } + if len(results) < 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0] != LFalse { + t.Errorf("expected xpcall false, got %v", results[0]) + } + if !strings.Contains(results[1].String(), "co-handled") { + t.Errorf("expected handler output, got %v", results[1]) + } +} From 68784b1e34c8e7a25a9cfd5f870060e52467379f Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Tue, 7 Jul 2026 15:01:31 -0300 Subject: [PATCH 2/5] fix(inspect): capture upvalues in GetStackFrame Why: GetStackFrame never reported upvalues: it read funcTable["f"] for the frame function, but GetInfo's 'f' flag RETURNS the *LFunction (it does not store it under any table key). The funcTable was always empty, so the upvalue loop was skipped and debug.getupvalue kept disagreeing with inspect. What: - Capture the *LFunction from GetInfo's return value and use it for both the Go-function name resolution and the upvalue walk. - Drop the unused funcTable allocation. - Add TestGetStackFrameCapturesUpvalues guarding against regression. --- inspect/stack.go | 31 ++++++++++++++++--------------- inspect/stack_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/inspect/stack.go b/inspect/stack.go index 98c235db4..af0c57f4f 100644 --- a/inspect/stack.go +++ b/inspect/stack.go @@ -105,9 +105,12 @@ func GetStackFrame(l *lua.LState, level int) (StackFrame, bool) { return StackFrame{}, false } - // Spawn debug info with function info - funcTable := l.NewTable() - if _, err := l.GetInfo("nSluf", ar, funcTable); err != nil { + // GetInfo with the "f" flag returns the frame's *LFunction as its first + // return value (it does not store it in a table). Capture it here so the + // Go-function name resolution and the upvalue walk below use the real + // function rather than reading a never-populated table key. + fnVal, err := l.GetInfo("nSluf", ar, nil) + if err != nil { return StackFrame{}, false } @@ -121,18 +124,16 @@ func GetStackFrame(l *lua.LState, level int) (StackFrame, bool) { // If no name is provided and this is a Go function, try to determine the name if frame.Name == "" && (frame.FuncType == "Go" || frame.FuncType == "C") { - // Spawn the actual function from the debug info table - fn := funcTable.RawGet(lua.LString("f")) - if luaFn, ok := fn.(*lua.LFunction); ok { + if luaFn, ok := fnVal.(*lua.LFunction); ok { // Iterate over globals and see if any key maps to this function globals := l.Get(lua.GlobalsIndex).(*lua.LTable) globals.ForEach(func(key, value lua.LValue) { if globalFn, ok := value.(*lua.LFunction); ok && globalFn == luaFn { - // Use the global key as the function name. frame.Name = key.String() } }) } + // Fallback if no match was found. if frame.Name == "" { frame.Name = fmt.Sprintf("", frame.Source, frame.CurrentLine) @@ -145,19 +146,19 @@ func GetStackFrame(l *lua.LState, level int) (StackFrame, bool) { if name == "" { break } + frame.Locals = append(frame.Locals, Local{name, value}) } // Only get upvalues for Lua functions - if fn := funcTable.RawGet(lua.LString("f")); fn != lua.LNil { - if luaFn, ok := fn.(*lua.LFunction); ok { - for i := 1; ; i++ { - name, value := l.GetUpvalue(luaFn, i) - if name == "" { - break - } - frame.Upvalues = append(frame.Upvalues, Upvalue{name, value}) + if luaFn, ok := fnVal.(*lua.LFunction); ok && !luaFn.IsG { + for i := 1; ; i++ { + name, value := l.GetUpvalue(luaFn, i) + if name == "" { + break } + + frame.Upvalues = append(frame.Upvalues, Upvalue{name, value}) } } diff --git a/inspect/stack_test.go b/inspect/stack_test.go index dc73e5960..309b76ced 100644 --- a/inspect/stack_test.go +++ b/inspect/stack_test.go @@ -250,3 +250,43 @@ func makeInspectFunc(key string) lua.LGFunction { return 0 } } + +// TestGetStackFrameCapturesUpvalues guards against a regression where +// GetStackFrame discarded the function returned by GetInfo("...f...") and read +// a never-populated table key instead, so upvalues were never reported. +func TestGetStackFrameCapturesUpvalues(t *testing.T) { + L := lua.NewState() + defer L.Close() + + L.SetGlobal("inspect_up", L.NewFunction(func(L *lua.LState) int { + trace := GetStackTrace(L) + L.SetField(L.Get(lua.RegistryIndex).(*lua.LTable), "up_trace", lua.LString(trace.String())) + return 0 + })) + + if err := L.DoString(` + local function make_outer() + local captured = "upvalue-payload" + local function inner() + captured = captured .. "." + inspect_up() + return captured + end + inner() + end + make_outer() + `); err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + registry := L.Get(lua.RegistryIndex).(*lua.LTable) + traceStr := registry.RawGetString("up_trace").String() + + if !strings.Contains(traceStr, "Upvalues:") { + t.Errorf("trace must include an Upvalues section;\ngot:\n%s", traceStr) + } + + if !strings.Contains(traceStr, "captured = upvalue-payload") { + t.Errorf("trace must list the captured upvalue;\ngot:\n%s", traceStr) + } +} From 83ce7eb5ed8a1ba11494023ab236df6ec52bbd79 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 10 Jul 2026 11:34:28 -0400 Subject: [PATCH 3/5] fix(pcall): clear discarded frame continuations --- baselib.go | 51 +++++-------- state.go | 20 ++++-- xpcall_direct_test.go | 163 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 37 deletions(-) diff --git a/baselib.go b/baselib.go index 2226b5e72..ab529e7b6 100644 --- a/baselib.go +++ b/baselib.go @@ -171,20 +171,16 @@ func basePCall(L *LState) int { }() if err != nil { - L.stack.SetSp(sp) - L.currentFrame = L.stack.Last() + L.unwindCallFrames(sp) L.reg.SetTop(base) L.currentFrame.Protected = false - // Clear continuation info after error - if ext := L.getFrameExt(L.currentFrame); ext != nil { - ext.Continuation = nil - ext.ContinuationCtx = nil - } + L.clearFrameExt(L.currentFrame) L.Push(LFalse) L.Push(err.(*ApiError).Object) return 2 } L.currentFrame.Protected = false + L.clearFrameExt(L.currentFrame) } else { // In coroutine - use continuation for yield-transparency // Still need defer/recover for error handling @@ -211,20 +207,16 @@ func basePCall(L *LState) int { } if err != nil { - L.stack.SetSp(sp) - L.currentFrame = L.stack.Last() + L.unwindCallFrames(sp) L.reg.SetTop(base) L.currentFrame.Protected = false - // Clear continuation info after error - if ext := L.getFrameExt(L.currentFrame); ext != nil { - ext.Continuation = nil - ext.ContinuationCtx = nil - } + L.clearFrameExt(L.currentFrame) L.Push(LFalse) L.Push(err.(*ApiError).Object) return 2 } L.currentFrame.Protected = false + L.clearFrameExt(L.currentFrame) } L.Insert(LTrue, 1) @@ -404,14 +396,15 @@ func xpcallContinuation(L *LState, ctx interface{}, _ ResumeState) int { func baseXPCall(L *LState) int { fn := L.CheckFunction(1) errfunc := L.CheckFunction(2) + protectedFrame := L.currentFrame // Mark the xpcall frame as protected. handleProtectedError (in threadRun) // honors this for errors that surface after a yield inside a coroutine. // The inline recover below is the boundary for synchronous errors and is // the only recover layer present under a direct DoString/PCall call, which // is why xpcall previously leaked its error in non-coroutine contexts. - L.currentFrame.Protected = true - L.setFrameExt(L.currentFrame).ErrFunc = errfunc + protectedFrame.Protected = true + L.setFrameExt(protectedFrame).ErrFunc = errfunc top := L.GetTop() sp := L.stack.Sp() @@ -433,27 +426,24 @@ func baseXPCall(L *LState) int { return -1 } - // Clear the protected marker and errfunc binding regardless of outcome. - L.currentFrame.Protected = false - if ext := L.getFrameExt(L.currentFrame); ext != nil { - ext.ErrFunc = nil - } - if errValue != nil { // Invoke the handler BEFORE resetting the stack, while the errored // frames are still on L.stack. This matches standard Lua semantics so // the handler can inspect the throw site (debug.getlocal, // debug.traceback, etc.). PCall's own errfunc path does the same. - handled := invokeErrorHandler(L, errfunc, errValue, sp, base) + handled := invokeErrorHandler(L, errfunc, errValue) - L.stack.SetSp(sp) - L.currentFrame = L.stack.Last() + L.unwindCallFrames(sp) L.reg.SetTop(base) + protectedFrame.Protected = false + L.clearFrameExt(protectedFrame) L.Push(LFalse) L.Push(handled) return 2 } + protectedFrame.Protected = false + L.clearFrameExt(protectedFrame) L.Insert(LTrue, top+1) return L.GetTop() - top } @@ -470,19 +460,16 @@ func errorObjectFromRecover(rcv any) LValue { // invokeErrorHandler calls the xpcall error handler with the caught error // value while the throw-site frames are still on the stack. If the handler -// itself raises, its error value is surfaced instead of the original. sp/base -// are the pre-xpcall-call snapshots used to reset on handler failure. -func invokeErrorHandler(L *LState, errfunc *LFunction, errValue LValue, sp, base int) LValue { +// itself raises, its error value is surfaced instead of the original. Stack +// cleanup belongs to baseXPCall so both handler outcomes use the same unwind. +func invokeErrorHandler(L *LState, errfunc *LFunction, errValue LValue) LValue { L.Push(errfunc) L.Push(errValue) - var handled LValue = errValue + handled := errValue func() { defer func() { if rcv := recover(); rcv != nil { - L.stack.SetSp(sp) - L.currentFrame = L.stack.Last() - L.reg.SetTop(base) handled = errorObjectFromRecover(rcv) } }() diff --git a/state.go b/state.go index fc259270e..cd6eeb169 100644 --- a/state.go +++ b/state.go @@ -196,6 +196,18 @@ func (ls *LState) clearFrameExt(cf *callFrame) { } } +// unwindCallFrames discards frames down to sp and removes their extension +// metadata first. Extensions are keyed by frame index, so a plain SetSp would +// let a later, unrelated Go call at a reused index inherit a stale continuation +// or xpcall error handler. +func (ls *LState) unwindCallFrames(sp int) { + for i := ls.stack.Sp() - 1; i >= sp; i-- { + ls.clearFrameExt(ls.stack.At(i)) + } + ls.stack.SetSp(sp) + ls.currentFrame = ls.stack.Last() +} + type callFrame struct { Fn *LFunction GoFunc LGoFunc // stateless Go function (used when Fn is nil) @@ -1925,8 +1937,7 @@ func (ls *LState) PCall(nargs, nret int, errfunc *LFunction) (err error) { err = rcv.(*ApiError) err.(*ApiError).StackTrace = ls.stackTrace(0) } - ls.stack.SetSp(sp) - ls.currentFrame = ls.stack.Last() + ls.unwindCallFrames(sp) ls.reg.SetTop(base) } }() @@ -1935,15 +1946,14 @@ func (ls *LState) PCall(nargs, nret int, errfunc *LFunction) (err error) { } else if len(err.(*ApiError).StackTrace) == 0 { err.(*ApiError).StackTrace = ls.stackTrace(0) } - ls.stack.SetSp(sp) - ls.currentFrame = ls.stack.Last() + ls.unwindCallFrames(sp) ls.reg.SetTop(base) } // Skip stack reset if yield happened if ls.yieldState != yieldNone { return } - ls.stack.SetSp(sp) + ls.unwindCallFrames(sp) if sp == 0 { ls.currentFrame = nil } diff --git a/xpcall_direct_test.go b/xpcall_direct_test.go index bb5e4cb2c..1a69afe59 100644 --- a/xpcall_direct_test.go +++ b/xpcall_direct_test.go @@ -52,6 +52,169 @@ func TestXPCallDirectCallPreservesSurroundingChunk(t *testing.T) { } } +// TestXPCallDirectErrorDoesNotSkipNextGoCall guards the protected-frame +// cleanup invariant. CallK stores xpcall's continuation on its call frame. If +// an error unwinds without clearing that metadata, the next Go-backed Lua call +// at the same stack depth resumes the stale xpcall continuation instead of +// invoking its own function. +func TestXPCallDirectErrorDoesNotSkipNextGoCall(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local ok, handled = xpcall(function() + error("first") + end, function(e) + return "handled: " .. tostring(e) + end) + assert(ok == false) + assert(string.find(handled, "handled")) + + local body_ran = false + local next_ok, next_value = pcall(function() + body_ran = true + return "next-result" + end) + assert(next_ok == true, "next pcall must execute normally") + assert(next_value == "next-result", "next pcall returned the wrong result") + assert(body_ran == true, "next pcall body was skipped by a stale continuation") + `) + if err != nil { + t.Fatalf("call after failed xpcall was corrupted: %v", err) + } +} + +// TestXPCallHandlerErrorDoesNotSkipNextGoCall covers the same cleanup path +// when the message handler itself raises. The handler's error must become the +// xpcall result, and no handler/continuation state may survive the return. +func TestXPCallHandlerErrorDoesNotSkipNextGoCall(t *testing.T) { + L := NewState() + defer L.Close() + + err := L.DoString(` + local ok, handled = xpcall(function() + error("original") + end, function() + error("handler-failed") + end) + assert(ok == false) + assert(string.find(tostring(handled), "handler%-failed")) + + local body_ran = false + local next_ok = pcall(function() + body_ran = true + end) + assert(next_ok == true) + assert(body_ran == true, "handler failure left stale frame metadata") + `) + if err != nil { + t.Fatalf("handler failure corrupted the next call: %v", err) + } +} + +func TestXPCallDirectErrorClearsFrameExtensions(t *testing.T) { + L := NewState() + defer L.Close() + + if err := L.DoString(` + xpcall(function() error("boom") end, function(e) return e end) + `); err != nil { + t.Fatal(err) + } + + for idx, ext := range L.frameExt { + if ext != nil && (ext.ErrFunc != nil || ext.Continuation != nil || ext.ContinuationCtx != nil) { + t.Fatalf("stale protected-call metadata remains at frame index %d: %+v", idx, ext) + } + } +} + +// TestPCallErrorClearsNestedFrameExtensions covers the general unwind path, +// not only xpcall's own frame. A Go function using CallK leaves continuation +// metadata on its frame when the called Lua function panics. The surrounding +// pcall must discard that metadata before the frame index is reused. +func TestPCallErrorClearsNestedFrameExtensions(t *testing.T) { + L := NewState() + defer L.Close() + + L.SetGlobal("call_with_continuation", L.NewFunction(func(L *LState) int { + fn := L.CheckFunction(1) + L.Push(fn) + L.CallK(0, 0, func(*LState, any, ResumeState) int { return 0 }, nil) + return 0 + })) + + markerCalled := false + L.SetGlobal("mark_called", L.NewFunction(func(*LState) int { + markerCalled = true + return 0 + })) + + err := L.DoString(` + local ok = pcall(function() + call_with_continuation(function() + error("nested failure") + end) + end) + assert(ok == false) + + local next_ok = pcall(function() + mark_called() + end) + assert(next_ok == true) + `) + if err != nil { + t.Fatalf("nested protected-call unwind failed: %v", err) + } + if !markerCalled { + t.Fatal("next Go call was skipped by a discarded frame's continuation") + } +} + +func TestAPIPCallErrorClearsNestedFrameExtensions(t *testing.T) { + L := NewState() + defer L.Close() + + L.SetGlobal("call_with_continuation", L.NewFunction(func(L *LState) int { + fn := L.CheckFunction(1) + L.Push(fn) + L.CallK(0, 0, func(*LState, any, ResumeState) int { return 0 }, nil) + return 0 + })) + + markerCalled := false + L.SetGlobal("mark_called", L.NewFunction(func(*LState) int { + markerCalled = true + return 0 + })) + + if err := L.DoString(` + function api_failure() + call_with_continuation(function() + error("api failure") + end) + end + function api_next_call() + mark_called() + end + `); err != nil { + t.Fatal(err) + } + + L.Push(L.GetGlobal("api_failure")) + if err := L.PCall(0, 0, nil); err == nil { + t.Fatal("expected API PCall to catch the nested error") + } + + L.Push(L.GetGlobal("api_next_call")) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatalf("next API PCall failed: %v", err) + } + if !markerCalled { + t.Fatal("next API PCall was skipped by a discarded frame's continuation") + } +} + // TestXPCallDirectCallReturnsTrueOnSuccess verifies the success path under // direct call. func TestXPCallDirectCallReturnsTrueOnSuccess(t *testing.T) { From cae8fc3e3799a677c520a47cd897a897174181c6 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 10 Jul 2026 11:34:34 -0400 Subject: [PATCH 4/5] ci: restore workflow validation and lint --- .github/workflows/ci.yml | 25 +++--- .../check/synth/phase/extract/function.go | 2 +- fixture_harness_test.go | 2 +- types/constraint/infer.go | 83 +------------------ types/typ/subst/subst.go | 10 --- 5 files changed, 18 insertions(+), 104 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8664f6d2..1e70bd393 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,6 @@ jobs: name: Lint runs-on: ubuntu-latest timeout-minutes: 15 - env: - GOCACHE: ${{ runner.temp }}/go-build - GOLANGCI_LINT_CACHE: ${{ runner.temp }}/golangci-lint steps: - name: Check out code uses: actions/checkout@v6 @@ -32,6 +29,9 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9.2.0 + env: + GOCACHE: ${{ runner.temp }}/go-build + GOLANGCI_LINT_CACHE: ${{ runner.temp }}/golangci-lint with: version: v2.8.0 only-new-issues: false @@ -45,8 +45,6 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - env: - GOCACHE: ${{ runner.temp }}/go-build steps: - name: Check out code uses: actions/checkout@v6 @@ -58,14 +56,14 @@ jobs: cache: true - name: Run tests + env: + GOCACHE: ${{ runner.temp }}/go-build run: go test -timeout 120s ./... race: name: Race Tests runs-on: ubuntu-latest timeout-minutes: 40 - env: - GOCACHE: ${{ runner.temp }}/go-build steps: - name: Check out code uses: actions/checkout@v6 @@ -78,6 +76,7 @@ jobs: - name: Run race tests env: + GOCACHE: ${{ runner.temp }}/go-build GORACE: "halt_on_error=1" run: go test -race -timeout 120s ./... @@ -85,8 +84,6 @@ jobs: name: Fuzz runs-on: ubuntu-latest timeout-minutes: 20 - env: - GOCACHE: ${{ runner.temp }}/go-build steps: - name: Check out code uses: actions/checkout@v6 @@ -98,20 +95,24 @@ jobs: cache: true - name: Fuzz type decode + env: + GOCACHE: ${{ runner.temp }}/go-build run: go test -fuzz=FuzzTypeDecodeToValidation -fuzztime=60s -timeout=120s - name: Fuzz Lua source types + env: + GOCACHE: ${{ runner.temp }}/go-build run: go test -fuzz=FuzzLuaTypeValidation -fuzztime=60s -timeout=120s - name: Fuzz Lua with manifest + env: + GOCACHE: ${{ runner.temp }}/go-build run: go test -fuzz=FuzzLuaWithManifestTypes -fuzztime=60s -timeout=120s bench: name: Benchmarks runs-on: ubuntu-latest timeout-minutes: 15 - env: - GOCACHE: ${{ runner.temp }}/go-build steps: - name: Check out code uses: actions/checkout@v6 @@ -123,6 +124,8 @@ jobs: cache: true - name: Run benchmarks + env: + GOCACHE: ${{ runner.temp }}/go-build run: go test -bench="Benchmark(Validate|Is)" -benchmem -count=1 -timeout=60s | tee bench.txt - name: Upload benchmark results diff --git a/compiler/check/synth/phase/extract/function.go b/compiler/check/synth/phase/extract/function.go index ed3f9293c..6c869f641 100644 --- a/compiler/check/synth/phase/extract/function.go +++ b/compiler/check/synth/phase/extract/function.go @@ -159,7 +159,7 @@ func (s *Synthesizer) synthFunctionTypeWithCapturePoint( // Build CFG once, shared between overlay inference and return inference. var fnGraph *cfg.Graph - if fn.Stmts != nil && len(fn.Stmts) > 0 { + if len(fn.Stmts) > 0 { fnGraph = s.getOrBuildFunctionGraph(fn) } diff --git a/fixture_harness_test.go b/fixture_harness_test.go index b12c2060b..36a30f112 100644 --- a/fixture_harness_test.go +++ b/fixture_harness_test.go @@ -277,7 +277,7 @@ func matchesExpectation(exp inlineExpectation, d diag.Diagnostic, entryFile stri expFile := exp.File // Match diagnostic file: d.Position.File is set by the checker (e.g. "test.lua" or module name) if !strings.HasSuffix(d.Position.File, strings.TrimSuffix(expFile, ".lua")) && - !(expFile == entryFile && d.Position.File == "test.lua") { + (expFile != entryFile || d.Position.File != "test.lua") { return false } if d.Position.Line != exp.Line { diff --git a/types/constraint/infer.go b/types/constraint/infer.go index a43dbf096..33773e529 100644 --- a/types/constraint/infer.go +++ b/types/constraint/infer.go @@ -373,8 +373,8 @@ func (c *InferSet) unifySCC(scc []int) { } } -// walkType traverses a type tree, calling pred on each node. -// Returns true if pred returns true for any node. +// canContainTypeVar reports whether a type kind can contain nested type +// variables and therefore needs recursive inspection during inference. func canContainTypeVar(k kind.Kind) bool { switch k { case kind.Optional, kind.Union, kind.Intersection, kind.Array, @@ -386,85 +386,6 @@ func canContainTypeVar(k kind.Kind) bool { } } -func walkType(t typ.Type, depth int, pred func(typ.Type) bool) bool { - if stopDepth(t, depth) { - return false - } - - if pred(t) { - return true - } - - if !canContainTypeVar(t.Kind()) { - return false - } - - return typ.Visit(t, typ.Visitor[bool]{ - Optional: func(o *typ.Optional) bool { - return walkType(o.Inner, depth+1, pred) - }, - Union: func(u *typ.Union) bool { - for _, m := range u.Members { - if walkType(m, depth+1, pred) { - return true - } - } - return false - }, - Intersection: func(in *typ.Intersection) bool { - for _, m := range in.Members { - if walkType(m, depth+1, pred) { - return true - } - } - return false - }, - Tuple: func(tup *typ.Tuple) bool { - for _, e := range tup.Elements { - if walkType(e, depth+1, pred) { - return true - } - } - return false - }, - Array: func(a *typ.Array) bool { - return walkType(a.Element, depth+1, pred) - }, - Map: func(m *typ.Map) bool { - return walkType(m.Key, depth+1, pred) || walkType(m.Value, depth+1, pred) - }, - Function: func(fn *typ.Function) bool { - for _, p := range fn.Params { - if walkType(p.Type, depth+1, pred) { - return true - } - } - - for _, r := range fn.Returns { - if walkType(r, depth+1, pred) { - return true - } - } - - return walkType(fn.Variadic, depth+1, pred) - }, - Record: func(r *typ.Record) bool { - for _, f := range r.Fields { - if walkType(f.Type, depth+1, pred) { - return true - } - } - return false - }, - Alias: func(a *typ.Alias) bool { - return walkType(a.Target, depth+1, pred) - }, - Default: func(t typ.Type) bool { - return false - }, - }) -} - func occursIn(varID int, t typ.Type) bool { seen := make(map[typ.Type]bool) return walkTypeMemo(t, 0, seen, func(inner typ.Type) bool { diff --git a/types/typ/subst/subst.go b/types/typ/subst/subst.go index d4b1845ca..d87338750 100644 --- a/types/typ/subst/subst.go +++ b/types/typ/subst/subst.go @@ -98,16 +98,6 @@ func putExpandMemo(m map[typ.Type]typ.Type) { expandMemoPool.Put(m) } -func expandInstantiatedWithDepth(t typ.Type, maxDepth int) typ.Type { - if t == nil || !expandInstantiatedCanDescend(t) { - return t - } - memo := getExpandMemo() - defer putExpandMemo(memo) - guard := typ.GuardForDepth(maxDepth) - return expandInstantiatedGuard(t, guard, memo) -} - func expandInstantiatedGuard(t typ.Type, guard internal.RecursionGuard, memo map[typ.Type]typ.Type) typ.Type { if t == nil || !expandInstantiatedCanDescend(t) { return t From 0ba54f67de717d46373c31187d213365da186e44 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 10 Jul 2026 11:42:36 -0400 Subject: [PATCH 5/5] test: normalize fixture golden line endings --- fixture_harness_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fixture_harness_test.go b/fixture_harness_test.go index 36a30f112..b317653de 100644 --- a/fixture_harness_test.go +++ b/fixture_harness_test.go @@ -502,7 +502,10 @@ func verifyGoldenOutput(t *testing.T, s namedSuite, buf *bytes.Buffer) { } got := buf.String() - want := string(golden) + // Git may check text fixtures out with CRLF on Windows, while capturePrint + // deliberately emits Lua's canonical newline. Compare the content rather + // than the checkout's platform-specific line endings. + want := string(bytes.ReplaceAll(golden, []byte("\r\n"), []byte("\n"))) if got != want { t.Errorf("output mismatch:\n--- want ---\n%s--- got ---\n%s", want, got) }