# LuaJIT trace checking LuaJIT discovers an unsupported hot path only once that path is hot. Nupp finds a smaller, deterministic set before the program runs: the operations the selected recorder refuses whenever recording reaches them. ```nupp @jit local function sum(values: {integer}): integer local total: integer = 0 for index = 1, #values do total = total + values[index] end return total end ``` [`@jit`](../../reference/annotations.md#built-in-annotations) is a contract, so every check and every build reports a refused operation the annotated body can reach. Without the annotation the same analysis is available on request, and it can also normalize the aborts observed in one profiled execution. ::: note A clean static result does not promise that a function will run, become hot, compile, or stay compiled for every input. It means there is no catalogued unconditional blocker in the checked scope for that trace profile. ::: ## Result kinds Every finding carries a class, and the class decides what it can do to a build. | Result | Meaning | Can fail a build? | | --- | --- | --- | | blocker, must-reach | The recorder refuses the operation, and every repeatable path through the loop reaches it | `bc --check` exits 1; `@jit` is an error | | blocker, may-reach | The recorder refuses it when that branch runs, but another path can avoid it | advisory from `bc --check`; `@jit` is an error | | risk | Runtime types, call targets, ABI details, shapes, or recorder limits decide | warning or advice; selected conservative boundaries are errors under `@jit` | | stop | Normal trace formation, such as leaving a loop or encountering recursion | informational and hidden by default | | observed abort | The LuaJIT hook reported this event in one run | never changes whether source checks | The operation-level answer and the reachability answer are deterministic. Runtime observation is evidence from one input and one execution, so it is never used as the static oracle. ## Checking surfaces The same catalog answers from six places, which differ in when they run and how much of the program they see. | Surface | When it runs | What it checks | Runtime cost in an ordinary run | | --- | --- | --- | --- | | ordinary `nupp check` | automatically | enabled trace lints | none | | `@jit` | automatically on every check and build | the annotated function and statically resolved checked callees | none | | `nupp bc --check FILE` | when requested | exact generated bytecode and loop control flow | none; it does not execute the chunk | | VS Code **Nupp: Check Function for JIT Trace Blockers** | when requested | the method or function under the cursor and resolved callees, using unsaved text | none | | `nupp lsp trace-check --json FILE LINE COLUMN` | when requested | the same one-function query, for scripts and agents | none | | `nupp run --jit-aborts FILE` | when requested | recorder events observed while that program runs | the trace callback costs something only during this session | For continuous enforcement, put `@jit` on a function. For investigation, use the VS Code command or lightbulb without changing source. A successful one-shot check offers **Add `@jit` contract** when the declaration can carry it. See [editors.md](../tooling/editors.md#visual-studio-code) for the extension that supplies it. ### Checking in CI Two static scopes matter to a project, and they catch different things: ```bash nupp check nupp bc --check src/physics.nupp nupp bc --check --json src/physics.nupp > build/physics-bytecode.json ``` `nupp check` enforces every `@jit` contract. `bc --check` catches blockers in exact generated loop bytecode even where no source annotation was written. Its JSON includes the generated-artifact fingerprint, trace profile, catalog version, prototype, PC, opcode, source line, reason ID, class, reachability, explanation, and repair. See [`bc`](../../reference/cli.md#bc) for the command. ### Agent skill The static-first performance workflow ejects as a focused skill: ```bash nupp reference performance --skill \ -o .claude/skills/nupp-performance/SKILL.md ``` The generated skill belongs to the compiler that produced it. Its trigger covers slow code, hot loops, JIT behavior, profiler output, and performance regressions; its body leads with trace checking before it asks the agent to measure a process. ## Function construction in a loop `FNEW` constructs a Lua function, and the selected LuaJIT recorder refuses it. This capturing closure therefore breaks an `@jit` contract: ```nupp @jit local function sum(values: {integer}): integer local total: integer = 0 for index = 1, #values do local current = function(): integer return values[index] end total = total + current() end return total end ``` ```text src/sum.nupp:5:33: error: NUPP2707: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles 5 | local current = function(): integer | ^ note: trace classification: blocker (jit/loop-function-construction) help: declare one function outside the loop and pass what varies to it ``` Move what varies into arguments to one function declared outside the loop: ```nupp local function valueAt(values: {integer}, index: integer): integer return values[index] end @jit local function sum(values: {integer}): integer local total: integer = 0 for index = 1, #values do total = total + valueAt(values, index) end return total end ``` No rewrite can safely lift every capturing closure, which is why the compiler reports this rather than repairing it. Replacing the capture with shared mutable state would change which value each returned function observes. ### Configurable source lint Without `@jit`, a capturing loop closure is [`jit-loop-closure`](../../reference/lints.md#jit-loop-closure). It is off by default because the code is correct and some programs accept the interpreted loop deliberately. Enable it in `nupp.lua` where this is a project policy: ```lua return { include = {"src"}, lints = { ["jit-loop-closure"] = "warning" }, } ``` The same source then says: ```text src/sum.nupp:4:33: warning: NUPP2515 jit-loop-closure: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles note: trace classification: blocker (jit/loop-function-construction) help: declare one function outside the loop and pass what varies to it ``` A non-capturing function is more directly repairable, so [`loop-invariant-closure`](../../reference/lints.md#loop-invariant-closure) remains its automatic diagnostic: ```nupp for _, item in ipairs(items) do register(item, function(event) return event.kind == "click" end) end ``` ```text src/events.nupp:2:28: warning: NUPP2505 loop-invariant-closure: this function is built once per iteration but does not use the iteration, so every one of them is the same function, and building one is what keeps the loop from compiling help: declare it once above the loop and pass the name ``` ## Blockers reached through checked calls The contract follows resolved Nupp calls, including recursive call graphs and exact exported callees. The error belongs to the annotated caller and shows a bounded path: ```nupp local function helper(values: {integer}): integer local total: integer = 0 for index = 1, #values do local current = function(): integer return values[index] end total = total + current() end return total end @jit local function sum(values: {integer}): integer return helper(values) end ``` ```text src/sum.nupp:12:12: error: NUPP2707: @jit call path sum -> helper reaches jit/loop-function-construction: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles 12 | return helper(values) | ^~~~~~ src/sum.nupp:4:33: note: the recorder blocker is here note: trace classification: blocker (jit/loop-function-construction) help: declare one function outside the loop and pass what varies to it ``` Recursive strongly connected components reach a fixed point over the reason set, so the path does not expand forever. ## Explicit `jit.off` boundaries Calling a function deliberately disabled with `jit.off` is valid ordinary code. It is an error from an `@jit` body, because that body promised not to leave compiled code deliberately: ```nupp local function logValue(value: integer): nil print(value) end jit.off(logValue) @jit local function update(value: integer): nil logValue(value) end ``` ```text src/update.nupp:9:5: error: NUPP2707: @jit call path update -> logValue reaches jit/disabled-callee: the resolved callee is explicitly disabled with jit.off 9 | logValue(value) | ^~~~~~~~ note: trace classification: blocker (jit/disabled-callee) help: remove @jit from the caller or keep the disabled call outside its checked hot path ``` Move the logging call outside the annotated operation, or remove `@jit` when the interpreter transition is intentional. Applying `jit.off` to the enclosing function also makes a local trace lint irrelevant: a function taken off the JIT has no trace to lose. ## Variadic FFI A variadic C signature depends on the exact argument types and target ABI. Nupp classifies it as a conservative risk rather than as a universal opcode blocker: ```nupp cdef function printf(format: cstring, ...): int32 local function report(value: int32): nil printf("%d", value) end ``` ```text src/report.nupp:4:5: warning: NUPP2514 jit-boundary: a variadic FFI call cannot safely execute on a compiled trace 4 | printf("%d", value) | ^~~~~~ note: trace classification: risk (jit/ffi-vararg-policy) help: move the call behind an explicit jit.off boundary when it is not a hot operation ``` Under `@jit`, the same site is a non-suppressible contract error. A clear boundary makes the choice explicit: ```nupp cdef function printf(format: cstring, ...): int32 local function reportCold(value: int32): nil printf("%d", value) end jit.off(reportCold) ``` Do not call `reportCold` from an `@jit` function; that would correctly become the `jit/disabled-callee` error shown above. See [`jit-boundary`](../../reference/lints.md#jit-boundary) for the lint, and [c-interop.md](../runtime/c-interop/index.md#jit-sensitive-c-boundaries) for the C side of the same boundary. ## Lua callbacks passed through C C cannot safely re-enter an ordinary Lua callback from a compiled trace. The checker uses the resolved FFI signature and callback identity rather than guessing from a generated form: ```nupp cdef function each(fn: function(int32), n: int32) local function visit(value: int32): nil print(value) end local function run(): nil each(visit, 1) end ``` ```text src/callback.nupp:8:10: warning: NUPP2502 jit-callback: a Lua function passed to C becomes an FFI callback and cannot run from a compiled trace 8 | each(visit, 1) | ^~~~~ note: trace classification: risk (jit/ffi-callback) help: disable the callback and its calling boundary with jit.off ``` Keep the complete C-to-Lua callback boundary cold: ```nupp local function visit(value: int32): nil print(value) end local function runCold(): nil each(visit, 1) end jit.off(visit) jit.off(runCold) ``` Inside `@jit`, allowing the [`jit-callback`](../../reference/lints.md#jit-callback) lint does not waive the contract. It remains a contract error. ## Dynamic call targets An unresolved call is not fabricated into a blocker. The one-shot inspection reports what is actually known: ```nupp local function dispatch(callback: any): nil callback() end ``` ```bash nupp lsp trace-check --json src/dispatch.g.nupp 2 5 ``` ```json { "functionName": "dispatch", "contract": "inspection", "findings": [{ "reason": "jit/dynamic-call", "class": "risk", "message": "the call target is dynamic, so its bytecode recordability is unknown", "callPath": ["dispatch"] }], "reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1} } ``` That risk does not mean every possible target is bad. Give the call a statically resolved checked identity where continuous transport is required. ## `FNEW` and `UCLO` in generated bytecode `bc --check` compiles but does not execute the exact artifact at the requested optimization level. A capturing closure commonly produces both function construction (`FNEW`) and upvalue closing (`UCLO`) in the repeatable region: ```text $ nupp bc --check src/sum.g.nupp nupp: 2 instructions in a loop that cannot compile ... 0006 FNEW 6 0 <-- this loop never compiles: LuaJIT has no recorder for constructing a function [jit/loop-function-construction, must-reach] ... 0010 UCLO 5 => 0011 <-- this loop never compiles: LuaJIT has no recorder for closing an upvalue [jit/loop-upvalue-close, must-reach] 0011 FORL 2 => 0006 ``` The command exits 1 because every repeatable path reaches both blockers. A conditional non-capturing construction can be only may-reach: ```nupp local current = function(): integer return 0 end local first = true for index = 1, #values do if first then current = function(): integer return 1 end first = false end end ``` ```text 0007 ISF 2 0008 JMP 7 => 0011 0009 FNEW 1 1 <-- this path aborts recording: jit/loop-function-construction [may-reach] 0011 FORL 3 => 0007 ``` That finding is visible, but `bc --check` exits 0 because it cannot truthfully claim the whole loop always fails to complete a root trace. `@jit` is stronger and rejects a statically reachable may-reach blocker, because its contract covers every checked path. ## Manual checking in VS Code Place the cursor anywhere in a method or function and run **Nupp: Check Function for JIT Trace Blockers** from the Command Palette, editor context menu, or lightbulb. The request: - selects the smallest enclosing function; - reads the current unsaved editor buffer; - follows statically resolved checked callees; - puts findings in the temporary **Nupp JIT Check** diagnostic collection; and - clears those diagnostics on edit or on the next manual check. It does not add an annotation, run another process, execute the program, or attach a trace hook. A clean answer says: ```text update: no catalogued unconditional trace blockers or conditional risks. ``` That is deliberately not "this function will compile." Choose **Add `@jit` contract** afterwards when the function should be checked continuously. ## Observing real aborts Static checking cannot know whether a loop runs or becomes hot. Observe one workload when that is the question: ```bash nupp run --jit-aborts app.nupp nupp run --jit-aborts=jit-aborts.json --json app.nupp ``` CSV remains compatible with existing consumers: ```csv severity,count,reason,location,zone warn,7,NYI: bytecode FNEW,app.nupp:41,frame/spawn ``` JSON retains the raw VM detail and adds the stable identity: ```json { "totalAborts": 7, "blacklisted": 0, "reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1}, "sites": [{ "severity": "warn", "count": 7, "reason": "NYI: bytecode FNEW", "rawReason": "NYI: bytecode FNEW", "reasonId": "jit/loop-function-construction", "class": "blocker", "location": "app.nupp:41", "zone": "frame/spawn" }] } ``` The callback runs on recorder events, not on every loop iteration or table access, but it still has nonzero profiling cost. Stop the session after the interesting window. With no trace session, no callback is attached and no aggregation state is allocated. See [profiling.md](profiling.md#trace-aborts) for collecting the same events from inside a program. ## Reason catalog Static and runtime surfaces use the same identities, published as `nupp-trace-reasons-v1`. Raw LuaJIT strings remain report detail rather than public identifiers, and `nupp explain REASON` prints the current explanation plus a repair only where Nupp has a specific semantics-preserving one. ### Statically attributable reasons | Stable reason | Class | What the diagnostic says | Repair or interpretation | | --- | --- | --- | --- | | `jit/loop-function-construction` | blocker | LuaJIT has no recorder for constructing a function | Declare one function outside the loop and pass what varies | | `jit/loop-upvalue-close` | blocker | LuaJIT has no recorder for closing an upvalue | Move the captured lifetime outside the repeated region | | `jit/ffi-vararg-policy` | risk | This variadic FFI form depends on argument types and the target ABI | Put an intentionally cold call behind `jit.off` | | `jit/ffi-callback` | risk | A C call that re-enters Lua through this callback cannot remain on a trace | Disable the callback and its calling boundary | | `jit/disabled-callee` | blocker | The resolved callee is explicitly disabled with `jit.off` | Keep the call outside `@jit`, or remove the contract | | `jit/dynamic-call` | risk | The call target is dynamic, so its bytecode recordability is unknown | Resolve the target; do not assume every target is bad | ### Expected stops These are class `stop` and severity `info`, and they are omitted unless benign events are requested. They are not warnings to fix. | Stable reason | Typical raw VM detail | Meaning | | --- | --- | --- | | `jit/expected-loop-leave` | `leaving loop in root trace` | The recorded path left the loop | | `jit/expected-inner-loop` | `inner loop in root trace` | Trace formation encountered an inner loop | | `jit/expected-down-recursion` | `down-recursion, restarting` | The recorder restarted at a recursive callee | | `jit/expected-up-recursion` | `up-recursion` | The trace reached an upward recursive edge | | `jit/retry-recording` | `retry recording` | LuaJIT requested an ordinary retry | | `jit/runtime-trace-too-short` | `trace too short` | The attempted trace did not contain enough work | ### Runtime risks and blockers These are observations from the active run. Counts and timing change with inputs; the normalized identity does not. | Stable reason | Class | Typical raw VM detail | What the report means | | --- | --- | --- | --- | | `jit/runtime-blacklisted` | risk | `blacklisted` | LuaJIT permanently demoted that trace in this process | | `jit/runtime-trace-too-long` | risk | `trace too long` | The recorder reached its trace-length limit | | `jit/runtime-trace-too-deep` | risk | `trace too deep` | Nested recording exceeded the depth limit | | `jit/runtime-too-many-snapshots` | risk | `too many snapshots` | Guard and snapshot capacity was exhausted | | `jit/runtime-loop-unroll-limit` | risk | `loop unroll limit reached` | Recording exceeded its loop-unroll allowance | | `jit/runtime-call-unroll-limit` | risk | `call unroll limit reached` | Recording exceeded its call-unroll allowance | | `jit/runtime-disabled-callee` | blocker | `JIT compilation disabled for function` | The observed path entered a disabled function | | `jit/runtime-ffi-call` | risk | `NYI: unsupported C function type` | This runtime C signature was unsupported | | `jit/runtime-ffi-conversion` | risk | `NYI: unsupported C type conversion` | This runtime C conversion was unsupported | | `jit/runtime-type-instability` | risk | `persistent type instability` | Repeated attempts observed incompatible runtime types | | `jit/runtime-machine-code-limit` | risk | `machine code too long`, `hit mcode limit (retrying)`, or `failed to allocate mcode memory` | Machine-code size or allocation limits stopped compilation | | `jit/runtime-recorder-error` | risk | `error thrown or hook called during recording` | A language error or hook interrupted recording | | `jit/runtime-unknown` | risk | the VM revision and unrecognized raw payload | The event stays visible, with no guessed explanation or repair | An observed `NYI: bytecode FNEW` or `UCLO` maps back to the corresponding static blocker rather than receiving a second runtime-only identity. ## Profiles, versions, and reproducibility Every answer names its `TraceProfile`: LuaJIT revision, architecture, operating system, enabled recorder features, bytecode schema, and reason-catalog version. Reports from different profiles are not merged. The bytecode fingerprint prevents a static result for an old artifact from being presented as though it described new code. Adding a newly proved unconditional blocker creates a new catalog identity and version, and it can make an existing `@jit` contract fail after a compiler upgrade. ::: deepdive The bar for promoting an observation to a static rule is a deterministic VM fixture, a neighboring accepted fixture, source attribution, and a specific working alternative. One application abort is evidence to investigate rather than enough to generalize: a rule added from a single measurement would fail builds for every program whose only fault was resembling that one. ::: ## Large-application workflow Use static checking to keep known cliffs from spreading through checked call graphs, then profile to prioritize what remains: 1. Put `@jit` on important subsystem boundaries, not on every tiny helper. 2. Run `bc --check` on release entry modules at their release optimization level. 3. Use the VS Code one-shot check while investigating a method, before committing to a contract. 4. Sample first to find interpreted hot frames, then collect aborts over a short, representative window. 5. Fix must-reach blockers first, then observed blacklists, then risks that overlap measured interpreted time. 6. Keep benchmarks: removing an abort does not prove the replacement is faster. Static checking removes the catalogued, attributable causes. It does not replace profiling for cold code, input-dependent types and shapes, side exits, recorder limits, or deciding whether a reported site matters to the workload. ## FAQ ### Does a clean `@jit` result mean the function is compiled? No. It means no catalogued unconditional blocker is reachable in the checked scope for that trace profile. Whether the function runs at all, becomes hot, and stays compiled is a runtime question, answered by [profiling.md](profiling.md#trace-aborts). ### Why is a variadic FFI call a risk rather than a blocker? The answer depends on the exact argument types and the target ABI, so no static rule covers every form. Under `@jit` the conservative boundary is still an error, because the contract covers every checked path. ### When is `@aot` the answer instead of a trace contract? When the loop is numeric, maps over spans, and the measured gap is codegen rather than an aborted trace. See [ahead-of-time.md](ahead-of-time/index.md) for the subset `@aot` admits and what it costs. ::: seealso - [performance.md](index.md) for the rewrites the compiler applies before LuaJIT sees the code - [profiling.md](profiling.md) for where a running program spends its time - [lints.md](../../reference/lints.md#jit-loop-closure) for the trace lints and their severities - [`bc`](../../reference/cli.md#bc) for the bytecode command's options and JSON :::