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.

@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 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.

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 for the extension that supplies it.

Checking in CI#

Two static scopes matter to a project, and they catch different things:

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 for the command.

Agent skill#

The static-first performance workflow ejects as a focused skill:

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:

@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
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:

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. 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:

return {
    include = {"src"},
    lints = { ["jit-loop-closure"] = "warning" },
}

The same source then says:

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 remains its automatic diagnostic:

for _, item in ipairs(items) do
    register(item, function(event)
        return event.kind == "click"
    end)
end
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:

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
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:

local function logValue(value: integer): nil
    print(value)
end

jit.off(logValue)

@jit
local function update(value: integer): nil
    logValue(value)
end
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:

cdef function printf(format: cstring, ...): int32

local function report(value: int32): nil
    printf("%d", value)
end
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:

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 for the lint, and c-interop.md 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:

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
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:

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 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:

local function dispatch(callback: any): nil
    callback()
end
nupp lsp trace-check --json src/dispatch.g.nupp 2 5
{
  "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:

$ 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:

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
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:

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:

nupp run --jit-aborts app.nupp
nupp run --jit-aborts=jit-aborts.json --json app.nupp

CSV remains compatible with existing consumers:

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:

{
  "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 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.

Dive deeper

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.

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 for the subset @aot admits and what it costs.