diff --git a/go.mod b/go.mod index b7b08f64f..09eea53c2 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/tree-sitter/tree-sitter-php v0.24.2 github.com/tree-sitter/tree-sitter-python v0.25.0 github.com/tree-sitter/tree-sitter-typescript v0.23.2 - github.com/wippyai/go-lua v1.5.16 + github.com/wippyai/go-lua v1.5.17-0.20260707180131-68784b1e34c8 github.com/wippyai/module-registry-proto-go v0.0.1 github.com/wippyai/tree-sitter-markdown v0.0.3 github.com/wippyai/tree-sitter-sql v0.0.4 diff --git a/go.sum b/go.sum index 2c4ef371b..ebc28b33c 100644 --- a/go.sum +++ b/go.sum @@ -576,8 +576,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/wippyai/go-lua v1.5.16 h1:FOjZfd3H73MLQY0/nnKKW4Fik/rZDgCcg0L3W0Yr6Iw= -github.com/wippyai/go-lua v1.5.16/go.mod h1:cwD+390gJgx9cs+Zamby/lch5csqPV9RN1aDEfx/27M= +github.com/wippyai/go-lua v1.5.17-0.20260707180131-68784b1e34c8 h1:mlOaUK32iUOUxwDxn6qo4vFUBFVNyYp3tZN4JsqI0u0= +github.com/wippyai/go-lua v1.5.17-0.20260707180131-68784b1e34c8/go.mod h1:cwD+390gJgx9cs+Zamby/lch5csqPV9RN1aDEfx/27M= github.com/wippyai/module-registry-proto-go v0.0.1 h1:EYnW8MTrI/gs7TJ0ilTVgJal3e0NWmXQigeamrXu0mk= github.com/wippyai/module-registry-proto-go v0.0.1/go.mod h1:p4ihYQKRQuqRVLxaH1YL0IEnkz4zKdyfVADOwvA5Tno= github.com/wippyai/tree-sitter-markdown v0.0.3 h1:u6e53+hzPSS848Xhm+I48SwtvWQHrC21wDrogdwCwuc= diff --git a/runtime/lua/engine/cached_libs.go b/runtime/lua/engine/cached_libs.go index 43b5549c1..748365767 100644 --- a/runtime/lua/engine/cached_libs.go +++ b/runtime/lua/engine/cached_libs.go @@ -18,6 +18,7 @@ var ( cachedCoroutineLib *lua.LTable cachedStringLib *lua.LTable cachedErrorsLib *lua.LTable + cachedDebugLib *lua.LTable // cachedLibs is the ordered set of standard libraries bound as globals by // BindCachedLibs. It is the single source for both binding and StdLibNames. @@ -58,6 +59,13 @@ func initCachedLibs() { cachedErrorsLib = tmp.GetGlobal(lua.ErrorsLibName).(*lua.LTable) cachedErrorsLib.Immutable = true + lua.OpenDebug(tmp) + cachedDebugLib = tmp.GetGlobal(lua.DebugLibName).(*lua.LTable) + for _, fn := range []string{"setlocal", "setmetatable", "setupvalue", "getmetatable"} { + cachedDebugLib.RawSetString(fn, lua.LNil) + } + cachedDebugLib.Immutable = true + cachedLibs = []cachedLib{ {name: lua.TabLibName, tbl: cachedTableLib}, {name: lua.MathLibName, tbl: cachedMathLib}, @@ -65,6 +73,7 @@ func initCachedLibs() { {name: lua.CoroutineLibName, tbl: cachedCoroutineLib}, {name: lua.StringLibName, tbl: cachedStringLib}, {name: lua.ErrorsLibName, tbl: cachedErrorsLib}, + {name: lua.DebugLibName, tbl: cachedDebugLib}, } }) } diff --git a/runtime/lua/engine/cached_libs_test.go b/runtime/lua/engine/cached_libs_test.go index d7f9e2b72..fb47481fd 100644 --- a/runtime/lua/engine/cached_libs_test.go +++ b/runtime/lua/engine/cached_libs_test.go @@ -96,6 +96,94 @@ func TestBindCachedLibs_ErrorsLib(t *testing.T) { `)) } +func TestBindCachedLibs_DebugLib_Present(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + require.NoError(t, l.DoString(` + assert(type(debug) == "table") + `)) +} + +func TestBindCachedLibs_DebugLib_HasSafeFuncs(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + for _, fn := range []string{"traceback", "getinfo", "getlocal", "getupvalue"} { + err := l.DoString(`assert(type(debug.` + fn + `) == "function", "debug.` + fn + ` must be a function")`) + require.NoError(t, err, "debug.%s must be exposed", fn) + } +} + +func TestBindCachedLibs_DebugLib_MissingUnsafeFuncs(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + for _, fn := range []string{"setlocal", "setmetatable", "setupvalue", "getmetatable"} { + err := l.DoString(`assert(debug.` + fn + ` == nil, "debug.` + fn + ` must be absent")`) + require.NoError(t, err, "debug.%s must NOT be exposed", fn) + } +} + +func TestBindCachedLibs_DebugLib_Immutable(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + setErr := l.DoString(` + local ok, err = pcall(function() + debug.setlocal = function() end + end) + assert(not ok, "mutating the immutable debug table must fail") + `) + require.NoError(t, setErr, "mutating debug table must error, not crash") + + getErr := l.DoString(`assert(debug.setlocal == nil, "setlocal must remain nil")`) + require.NoError(t, getErr) +} + +func TestBindCachedLibs_DebugLib_GetInfoWorks(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + err := l.DoString(` + local function sample() return debug.getinfo(1) end + local info = sample() + assert(type(info) == "table") + assert(type(info.currentline) == "number") + assert(type(info.source) == "string") + assert(info.name == "sample") + `) + require.NoError(t, err) +} + +func TestBindCachedLibs_DebugLib_GetLocalWorks(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + BindCachedLibs(l) + + err := l.DoString(` + local function sample() + local captured = "value-here" + local name, value = debug.getlocal(1, 1) + assert(name == "captured", "first local name should be 'captured'") + assert(value == "value-here", "first local value should be 'value-here'") + end + sample() + `) + require.NoError(t, err) +} + func TestBindCachedLibs_MultipleStates(t *testing.T) { states := make([]*lua.LState, 3) for i := range states { @@ -123,4 +211,5 @@ func TestBindCachedLibs_Immutable(t *testing.T) { assert.True(t, cachedCoroutineLib.Immutable) assert.True(t, cachedStringLib.Immutable) assert.True(t, cachedErrorsLib.Immutable) + assert.True(t, cachedDebugLib.Immutable) } diff --git a/runtime/lua/engine/core_modules.go b/runtime/lua/engine/core_modules.go index 6361f5bdb..8d73f9d86 100644 --- a/runtime/lua/engine/core_modules.go +++ b/runtime/lua/engine/core_modules.go @@ -14,6 +14,7 @@ import ( "github.com/wippyai/runtime/runtime/lua/engine/value" "github.com/wippyai/runtime/runtime/lua/modules/ostime" "github.com/wippyai/runtime/runtime/lua/modules/payload" + "github.com/wippyai/runtime/runtime/lua/modules/traceback" "go.uber.org/zap" ) @@ -104,6 +105,7 @@ func printFunc(l *lua.LState) int { var coreModules = []*luaapi.ModuleDef{ payload.Module, ostime.Module, + traceback.Module, PrintModule, ChannelModule, } diff --git a/runtime/lua/modules/traceback/module.go b/runtime/lua/modules/traceback/module.go new file mode 100644 index 000000000..df815bb94 --- /dev/null +++ b/runtime/lua/modules/traceback/module.go @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package traceback provides Python-style stack traces with local variables +// and upvalues for the Wippy Lua runtime. It is the convenience layer over +// the read-only debug subset: debug.traceback/getinfo/getlocal/getupvalue are +// the primitives; traceback.format/frames give you "frames + locals in one +// call", which Lua's standard debug.traceback does not provide. +package traceback + +import ( + "fmt" + "strings" + + lua "github.com/wippyai/go-lua" + "github.com/wippyai/go-lua/inspect" + luaapi "github.com/wippyai/runtime/api/runtime/lua" +) + +// Module is the traceback module definition, registered as a core ambient +// module so it is both require-able (require("traceback")) and available as a +// global. +var Module = &luaapi.ModuleDef{ + Name: "traceback", + Description: "Python-style stack traces with local variables and upvalues", + Class: []string{luaapi.ClassIO}, + Build: buildModule, +} + +func buildModule() (*lua.LTable, []luaapi.YieldType) { + tbl := lua.CreateTable(0, 2) + tbl.RawSetString("format", lua.LGoFunc(formatFunc)) + tbl.RawSetString("frames", lua.LGoFunc(framesFunc)) + tbl.Immutable = true + + return tbl, nil +} + +// framesFunc implements traceback.frames([level[, opts]]). It returns a +// 1-indexed array of frame tables, one per stack level from `level` up to the +// top of the stack (capped by opts.depth, default 20). Each frame is +// { source, currentline, name, what, locals, upvalues } where locals and +// upvalues are arrays of { name, value } pairs. +func framesFunc(l *lua.LState) int { + level := optLevel(l, 1) + opts := parseOptions(l, 2) + + result := l.NewTable() + count := 0 + for lvl := level; count < opts.Depth && lvl < level+opts.Depth+maxSkippedFrames; lvl++ { + frame, status := captureFrame(l, lvl, opts) + if status == frameEnd { + break + } + + if status == frameSkip { + continue + } + + count++ + result.RawSetInt(count, frame) + } + + l.Push(result) + + return 1 +} + +// formatFunc implements traceback.format([level[, opts]]). It returns a +// formatted multi-line string (Python traceback.format_exc style) covering +// frames from `level` up to the top (capped by opts.depth). +func formatFunc(l *lua.LState) int { + level := optLevel(l, 1) + opts := parseOptions(l, 2) + + var sb strings.Builder + seen := make(map[*lua.LTable]bool) + count := 0 + for lvl := level; count < opts.Depth && lvl < level+opts.Depth+maxSkippedFrames; lvl++ { + fr, status := safeGetFrame(l, lvl) + if status == frameEnd { + break + } + + if status == frameSkip { + continue + } + + count++ + writeFrame(&sb, fr, opts, seen) + } + + l.Push(lua.LString(sb.String())) + + return 1 +} + +const maxSkippedFrames = 64 + +type frameStatus int + +const ( + frameCaptured frameStatus = iota + frameSkip // introspection panicked for this frame (e.g. a Go frame whose + // internals are stale right after an error recover); skip it + frameEnd // no frame at this level (end of stack) +) + +func optLevel(l *lua.LState, idx int) int { + if l.GetTop() < idx { + return 1 + } + + v := l.Get(idx) + if !isNumber(v) { + return 1 + } + + return int(lua.LVAsNumber(v)) +} + +func isNumber(v lua.LValue) bool { + return v.Type() == lua.LTNumber || v.Type() == lua.LTInteger +} + +// safeGetFrame wraps inspect.GetStackFrame with a recover. Right after an +// error recover (inside an xpcall handler, for example) some intermediate Go +// frames can have stale internals that make GetStackFrame panic; rather than +// abort the whole trace, we skip those frames so the throw site and other +// valid frames are still captured. +func safeGetFrame(l *lua.LState, level int) (fr inspect.StackFrame, status frameStatus) { + defer func() { + if r := recover(); r != nil { + status = frameSkip + } + }() + + var ok bool + fr, ok = inspect.GetStackFrame(l, level) + if !ok { + return inspect.StackFrame{}, frameEnd + } + + return fr, frameCaptured +} + +func captureFrame(l *lua.LState, level int, opts options) (lua.LValue, frameStatus) { + fr, status := safeGetFrame(l, level) + if status != frameCaptured { + return lua.LNil, status + } + + tbl := l.NewTable() + tbl.RawSetString("source", lua.LString(fr.Source)) + tbl.RawSetString("currentline", lua.LNumber(fr.CurrentLine)) + tbl.RawSetString("name", lua.LString(fr.Name)) + tbl.RawSetString("what", lua.LString(fr.FuncType)) + + if opts.Locals { + localsTbl := l.NewTable() + for i, p := range fr.Locals { + addPair(l, localsTbl, i, p.Name, p.Value) + } + + tbl.RawSetString("locals", localsTbl) + } + + if opts.Upvalues { + upTbl := l.NewTable() + for i, p := range fr.Upvalues { + addPair(l, upTbl, i, p.Name, p.Value) + } + + tbl.RawSetString("upvalues", upTbl) + } + + return tbl, frameCaptured +} + +func addPair(l *lua.LState, tbl *lua.LTable, idx int, name string, value lua.LValue) { + entry := l.NewTable() + entry.RawSetString("name", lua.LString(name)) + entry.RawSetString("value", value) + tbl.RawSetInt(idx+1, entry) +} + +func writeFrame(sb *strings.Builder, fr inspect.StackFrame, opts options, seen map[*lua.LTable]bool) { + if sb.Len() > 0 { + sb.WriteByte('\n') + } + + name := fr.Name + if name == "" { + name = "?" + } + + fmt.Fprintf(sb, "[%d] %s:%d (%s)", fr.Level, fr.Source, fr.CurrentLine, name) + + if opts.Locals && len(fr.Locals) > 0 { + sb.WriteString("\n Locals:") + for _, p := range fr.Locals { + sb.WriteString("\n ") + sb.WriteString(p.Name) + sb.WriteString(" = ") + renderValue(sb, p.Value, 0, opts, seen) + } + } + + if opts.Upvalues && len(fr.Upvalues) > 0 { + sb.WriteString("\n Upvalues:") + for _, p := range fr.Upvalues { + sb.WriteString("\n ") + sb.WriteString(p.Name) + sb.WriteString(" = ") + renderValue(sb, p.Value, 0, opts, seen) + } + } +} + +// renderValue writes a Lua value's printable form. Tables are rendered with +// depth limiting and cycle detection so a self-referential local (the common +// case when debugging stateful code) cannot make format() loop forever. +func renderValue(sb *strings.Builder, v lua.LValue, depth int, opts options, seen map[*lua.LTable]bool) { + switch lv := v.(type) { + case *lua.LNilType: + sb.WriteString("nil") + case lua.LBool: + if bool(lv) { + sb.WriteString("true") + } else { + sb.WriteString("false") + } + case lua.LNumber, lua.LInteger: + sb.WriteString(v.String()) + case lua.LString: + writeTruncated(sb, string(lv), opts.MaxValueLen) + case *lua.LTable: + renderTable(sb, lv, depth, opts, seen) + case *lua.LFunction: + sb.WriteString("") + case *lua.LState: + sb.WriteString("") + case *lua.LUserData: + sb.WriteString("") + default: + writeTruncated(sb, v.String(), opts.MaxValueLen) + } +} + +func renderTable(sb *strings.Builder, t *lua.LTable, depth int, opts options, seen map[*lua.LTable]bool) { + if depth >= opts.MaxTableDepth { + sb.WriteString("{...}") + + return + } + + if seen[t] { + sb.WriteString("{cyclic}") + + return + } + + seen[t] = true + deleteOnExit := depth == 0 + defer func() { + if deleteOnExit { + delete(seen, t) + } + }() + + var parts []string + t.ForEach(func(k, val lua.LValue) { + var ks strings.Builder + renderValue(&ks, k, depth+1, opts, seen) + + var vs strings.Builder + renderValue(&vs, val, depth+1, opts, seen) + + parts = append(parts, ks.String()+" = "+vs.String()) + }) + + if len(parts) == 0 { + sb.WriteString("{}") + + return + } + + sb.WriteByte('{') + sb.WriteString(strings.Join(parts, ", ")) + sb.WriteByte('}') +} + +func writeTruncated(sb *strings.Builder, s string, maxLen int) { + if maxLen > 0 && len(s) > maxLen { + sb.WriteString(s[:maxLen]) + sb.WriteString("...") + + return + } + + sb.WriteString(s) +} diff --git a/runtime/lua/modules/traceback/module_test.go b/runtime/lua/modules/traceback/module_test.go new file mode 100644 index 000000000..8956d9c91 --- /dev/null +++ b/runtime/lua/modules/traceback/module_test.go @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: MPL-2.0 + +package traceback + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + lua "github.com/wippyai/go-lua" +) + +func newTestState(t *testing.T) *lua.LState { + t.Helper() + + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + t.Cleanup(l.Close) + lua.OpenBase(l) + lua.OpenString(l) + lua.OpenTable(l) + tbl, _ := Module.Build() + l.SetGlobal(Module.Name, tbl) + + return l +} + +func TestModule_Load(t *testing.T) { + l := newTestState(t) + + mod := l.GetGlobal("traceback") + require.Equal(t, lua.LTTable, mod.Type(), "traceback module not registered") + + modTbl := mod.(*lua.LTable) + for _, fn := range []string{"format", "frames"} { + assert.Equal(t, lua.LTFunction, modTbl.RawGetString(fn).Type(), "%s function not registered", fn) + } +} + +func TestModule_Immutable(t *testing.T) { + tbl, _ := Module.Build() + assert.True(t, tbl.Immutable, "module table must be immutable") +} + +func TestFrames_CapturesCallerLocals(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local x = 42 + local greeting = "hello" + result = traceback.frames(1) + end + inner() + `) + + require.NoError(t, err) + result := l.GetGlobal("result") + require.Equal(t, lua.LTTable, result.Type(), "frames() must return a table") + + frames := result.(*lua.LTable) + require.Equal(t, lua.LTTable, frames.RawGetInt(1).Type(), "first frame missing") + + first := frames.RawGetInt(1).(*lua.LTable) + assert.Equal(t, lua.LTString, first.RawGetString("source").Type()) + assert.Equal(t, lua.LTNumber, first.RawGetString("currentline").Type()) + assert.Equal(t, "inner", first.RawGetString("name").String()) + + locals := first.RawGetString("locals") + require.Equal(t, lua.LTTable, locals.Type(), "locals must be a table") + + localsMap := localsToMap(t, locals.(*lua.LTable)) + assert.Equal(t, lua.LNumber(42), lua.LVAsNumber(localsMap["x"]), "local x must be captured") + assert.Equal(t, lua.LString("hello"), localsMap["greeting"], "local greeting must be captured") +} + +func TestFrames_WalksMultipleFrames(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function leaf() + result = traceback.frames(1) + end + local function middle() + local mid = "mid-value" + leaf() + end + local function top() + local topvar = 7 + middle() + end + top() + `) + + require.NoError(t, err) + frames := l.GetGlobal("result").(*lua.LTable) + + count := 0 + frames.ForEach(func(_, _ lua.LValue) { count++ }) + require.GreaterOrEqual(t, count, 3, "must capture at least 3 frames (leaf, middle, top)") +} + +func TestFrames_DefaultLevelSkipsGoFrame(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function caller() + local marker = "present" + result = traceback.frames() + end + caller() + `) + + require.NoError(t, err) + frames := l.GetGlobal("result").(*lua.LTable) + first := frames.RawGetInt(1).(*lua.LTable) + + locals := localsToMap(t, first.RawGetString("locals").(*lua.LTable)) + assert.Equal(t, lua.LString("present"), locals["marker"], "default level must start at the Lua caller") +} + +func TestFrames_BadLevelReturnsEmpty(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + result = traceback.frames(9999) + assert(type(result) == "table") + local count = 0 + for _ in pairs(result) do count = count + 1 end + assert(count == 0, "expected empty table for out-of-range level") + `) + + require.NoError(t, err) +} + +func TestFormat_ProducesStringWithLocals(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local x = 42 + local greeting = "hello" + result = traceback.format(1) + end + inner() + `) + + require.NoError(t, err) + result := l.GetGlobal("result") + require.Equal(t, lua.LTString, result.Type(), "format() must return a string") + + s := result.String() + assert.Contains(t, s, "inner", "formatted trace should name the function") + assert.Contains(t, s, "x", "formatted trace should list local x") + assert.Contains(t, s, "greeting", "formatted trace should list local greeting") +} + +func TestFormat_BadLevelReturnsEmptyString(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + result = traceback.format(9999) + assert(type(result) == "string") + assert(#result == 0, "expected empty string for out-of-range level") + `) + + require.NoError(t, err) +} + +func TestFormat_CyclicTableDoesNotInfiniteLoop(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local cyclic = {} + cyclic.self = cyclic + result = traceback.format(1) + end + inner() + `) + + require.NoError(t, err, "format() must terminate on cyclic tables") + s := l.GetGlobal("result").String() + assert.Contains(t, s, "cyclic", "cyclic local should still be named") +} + +func TestFormat_OptsDepthLimitsFrames(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function leaf() + result = traceback.format(1, { depth = 1 }) + end + local function middle() leaf() end + local function top() middle() end + top() + `) + + require.NoError(t, err) + s := l.GetGlobal("result").String() + + frameCount := strings.Count(s, "[0]") + frameCount += strings.Count(s, "[1]") + frameCount += strings.Count(s, "[2]") + frameCount += strings.Count(s, "[3]") + assert.LessOrEqual(t, frameCount, 1, "depth=1 should yield at most 1 frame") +} + +func TestFormat_OptsDisableLocals(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local secret = "s3cret" + result = traceback.format(1, { locals = false }) + end + inner() + `) + + require.NoError(t, err) + s := l.GetGlobal("result").String() + assert.NotContains(t, s, "s3cret", "locals=false must omit local values") +} + +func TestFrames_XPCallHandlerCapturesThrowSite(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function boom() + local trouble = "found-me" + error("kaboom") + end + xpcall(boom, function(err) + result = traceback.frames(1) + end) + `) + + require.NoError(t, err, "xpcall must catch the error (fork fix required)") + frames := l.GetGlobal("result").(*lua.LTable) + + allLocals := make(map[string]lua.LValue) + frames.ForEach(func(_, frame lua.LValue) { + ft, ok := frame.(*lua.LTable) + if !ok { + return + } + + loc := ft.RawGetString("locals") + if loc.Type() != lua.LTTable { + return + } + + for k, v := range localsToMap(t, loc.(*lua.LTable)) { + allLocals[k] = v + } + }) + + _, hasTrouble := allLocals["trouble"] + assert.True(t, hasTrouble, "xpcall handler must capture locals at the throw site (trouble); got locals: %v", allLocals) +} + +func TestFrames_UpvaluesCapturedByDefault(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local upval = tostring("upvalue-content") + local function inner() + local _ = upval + result = traceback.frames(1) + end + inner() + `) + + require.NoError(t, err) + frames := l.GetGlobal("result").(*lua.LTable) + first := frames.RawGetInt(1).(*lua.LTable) + + upvalues := first.RawGetString("upvalues") + require.Equal(t, lua.LTTable, upvalues.Type()) + + uvMap := localsToMap(t, upvalues.(*lua.LTable)) + assert.Equal(t, lua.LString("upvalue-content"), uvMap["upval"], "upvalue must be captured") +} + +func TestFrames_OptsDepthProducesExactCount(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function leaf() result = traceback.frames(1, { depth = 2 }) end + local function mid() leaf() end + local function top() mid() end + top() + `) + + require.NoError(t, err) + frames := l.GetGlobal("result").(*lua.LTable) + + count := 0 + frames.ForEach(func(_, _ lua.LValue) { count++ }) + assert.Equal(t, 2, count, "depth=2 must produce exactly 2 frames") +} + +func TestFormat_OptsMaxValueLenTruncates(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local big = string.rep("x", 500) + result = traceback.format(1, { max_value_len = 10 }) + end + inner() + `) + + require.NoError(t, err) + s := l.GetGlobal("result").String() + assert.Contains(t, s, "xxxxxxxxxx...", "long string must be truncated to max_value_len with ellipsis") + assert.NotContains(t, s, strings.Repeat("x", 50), "must not contain the full untruncated value") +} + +func TestFormat_OptsMaxTableDepthCaps(t *testing.T) { + l := newTestState(t) + + err := l.DoString(` + local function inner() + local nested = { a = { b = { c = { d = "deep" } } } } + result = traceback.format(1, { max_table_depth = 2 }) + end + inner() + `) + + require.NoError(t, err) + s := l.GetGlobal("result").String() + assert.Contains(t, s, "{...}", "table beyond max_table_depth must render as {...}") + assert.NotContains(t, s, "deep", "table beyond the cap must not render its deep contents") +} + +func localsToMap(t *testing.T, tbl *lua.LTable) map[string]lua.LValue { + t.Helper() + + out := make(map[string]lua.LValue) + tbl.ForEach(func(_, v lua.LValue) { + entry, ok := v.(*lua.LTable) + if !ok { + return + } + + name := entry.RawGetString("name").String() + val := entry.RawGetString("value") + out[name] = val + }) + + return out +} diff --git a/runtime/lua/modules/traceback/proof_test.go b/runtime/lua/modules/traceback/proof_test.go new file mode 100644 index 000000000..6796bbdf2 --- /dev/null +++ b/runtime/lua/modules/traceback/proof_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MPL-2.0 + +package traceback + +import ( + "testing" + + lua "github.com/wippyai/go-lua" +) + +// TestProof_Demo is the empirical end-to-end proof for the traceback feature. +// It wires the module exactly as the runtime does (the traceback module bound +// as a global, alongside the standard libs a real state gets) and exercises +// every capability: on-demand frames/format, locals + upvalues, depth/locals +// options, cyclic tables, and throw-site capture from inside an xpcall handler. +// +// The read-only debug-subset proof (BindCachedLibs stripping the mutating +// funcs) lives in engine/cached_libs_test.go, since that path requires the +// engine package. +// +// Run with `go test -v -run TestProof_Demo` and capture the output. +func TestProof_Demo(t *testing.T) { + l := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer l.Close() + lua.OpenBase(l) + lua.OpenString(l) + lua.OpenTable(l) + tbl, _ := Module.Build() + l.SetGlobal(Module.Name, tbl) + + t.Run("on_demand_format_with_locals_and_upvalues", func(t *testing.T) { + err := l.DoString(` + local up = "i-am-an-upvalue" + local function alpha(a, b) + local sum = a + b + print("=== traceback.format() (on-demand, like Python traceback.format_stack) ===") + print(traceback.format(1)) + return sum + end + alpha(10, 32) + `) + if err != nil { + t.Fatalf("format demo failed: %v", err) + } + }) + + t.Run("on_demand_frames_structured", func(t *testing.T) { + err := l.DoString(` + local function beta() + local flag = true + local nested = { x = 1, y = { z = 2 } } + print("=== traceback.frames() structured (locals + upvalues as Lua tables) ===") + local frames = traceback.frames(1) + for i = 1, #frames do + local f = frames[i] + local locStr = {} + if f.locals then + for j = 1, #f.locals do + locStr[#locStr+1] = f.locals[j].name .. "=" .. tostring(f.locals[j].value) + end + end + print(string.format(" frame[%d] %s:%d (%s) locals: %s", + i, f.source, f.currentline, f.name, table.concat(locStr, ", "))) + end + end + beta() + `) + if err != nil { + t.Fatalf("frames demo failed: %v", err) + } + }) + + t.Run("xpcall_captures_throw_site", func(t *testing.T) { + err := l.DoString(` + local function risky(depth) + local context = "processing layer " .. depth + local payload = { id = depth, secret = "shh" } + if depth <= 0 then + error("exploded at the bottom") + end + risky(depth - 1) + end + print("=== xpcall handler captures throw-site locals (Python traceback at exception) ===") + local ok, err = xpcall(function() risky(3) end, function(e) + print("caught:", tostring(e)) + print("--- traceback.format(1) from inside the handler: ---") + print(traceback.format(1)) + return "recovered" + end) + print("xpcall returned:", ok, err) + `) + if err != nil { + t.Fatalf("xpcall demo failed: %v", err) + } + }) + + t.Run("cyclic_table_does_not_loop", func(t *testing.T) { + err := l.DoString(` + local function with_cycle() + local t = { name = "root" } + t.self = t + t.child = { parent = t } + print("=== cyclic table rendered without infinite recursion ===") + print(traceback.format(1)) + end + with_cycle() + `) + if err != nil { + t.Fatalf("cyclic demo failed: %v", err) + } + }) + + t.Run("opts_depth_and_locals_disabled", func(t *testing.T) { + err := l.DoString(` + local function f1() local a = 1 + local function f2() local b = 2 + local function f3() local c = 3 + print("=== format(1, {depth=2, locals=false}) — capped frames, no locals ===") + print(traceback.format(1, { depth = 2, locals = false })) + end + f3() + end + f2() + end + f1() + `) + if err != nil { + t.Fatalf("opts demo failed: %v", err) + } + }) +} diff --git a/runtime/lua/modules/traceback/types.go b/runtime/lua/modules/traceback/types.go new file mode 100644 index 000000000..073097a90 --- /dev/null +++ b/runtime/lua/modules/traceback/types.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 + +package traceback + +import ( + lua "github.com/wippyai/go-lua" +) + +const ( + defaultDepth = 20 + defaultMaxValueLen = 200 + defaultMaxTableDepth = 3 +) + +type options struct { + Depth int + Locals bool + Upvalues bool + MaxValueLen int + MaxTableDepth int +} + +func defaultOptions() options { + return options{ + Depth: defaultDepth, + Locals: true, + Upvalues: true, + MaxValueLen: defaultMaxValueLen, + MaxTableDepth: defaultMaxTableDepth, + } +} + +func parseOptions(l *lua.LState, idx int) options { + opts := defaultOptions() + + if l.GetTop() < idx { + return opts + } + + v := l.Get(idx) + if v == lua.LNil || v.Type() != lua.LTTable { + return opts + } + + tbl := v.(*lua.LTable) + if d := tbl.RawGetString("depth"); isNumber(d) { + opts.Depth = int(lua.LVAsNumber(d)) + } + + if b := tbl.RawGetString("locals"); b.Type() == lua.LTBool { + opts.Locals = bool(b.(lua.LBool)) + } + + if b := tbl.RawGetString("upvalues"); b.Type() == lua.LTBool { + opts.Upvalues = bool(b.(lua.LBool)) + } + + if m := tbl.RawGetString("max_value_len"); isNumber(m) { + opts.MaxValueLen = int(lua.LVAsNumber(m)) + } + + if m := tbl.RawGetString("max_table_depth"); isNumber(m) { + opts.MaxTableDepth = int(lua.LVAsNumber(m)) + } + + return opts +}