@@ 0,0 1,249 @@
+#!/usr/bin/env tarantool
+-- Trace-stability gate (PLAN.md M6).
+--
+-- For each hot encode/decode path, run a few thousand iterations with
+-- a `trace` listener attached and assert that no trace aborts in
+-- `runtime/pb/*` or `examples/expected/**/*_pb.lua` fall into the
+-- "fatal" set — bytecodes/builtins LuaJIT can't compile, blacklists,
+-- persistent type instability. Benign aborts (loop boundaries, retry
+-- recording, short warmup traces) are ignored; they are normal JIT
+-- bookkeeping and don't mean the hot path fell off the JIT.
+--
+-- Scope: `pairs()` over a hash compiles to bytecode ISNEXT, which is
+-- NYI in the LuaJIT 2.1 fork Tarantool ships. That makes map-field
+-- encode/decode (the only place we use `pairs` in the hot path)
+-- inherently un-stay-on-trace. The last scenario pins that limitation
+-- so we notice if upstream ever lifts it.
+--
+-- Run as a standalone tarantool script — not via luatest. On macOS
+-- arm64, luatest's framework load fills the JIT mcode arena before
+-- tests run, so traces in the test body fail with "failed to allocate
+-- mcode memory" rather than the real reason we're trying to measure.
+--
+-- Usage:
+-- tarantool bench/jit_trace.lua
+-- exit 0 = all checks passed; non-zero = a fatal abort was hit or a
+-- hot path failed to compile at all.
+
+package.path = './runtime/?.lua;./runtime/?/init.lua;'
+ .. './examples/expected/?.lua;./examples/expected/?/init.lua;'
+ .. package.path
+
+jit.on()
+local vmdef = require('jit.vmdef')
+
+-- Codes from jit.vmdef.traceerr (1-indexed). We treat these as fatal —
+-- they mean the JIT genuinely cannot compile the path, not that it's
+-- reorganizing traces. See vmdef.traceerr for the full list.
+local FATAL = {
+ [5] = true, -- blacklisted
+ [7] = true, -- NYI: bytecode %s
+ [11] = true, -- bad argument type
+ [15] = true, -- NYI: unsupported variant of FastFunc %s
+ [16] = true, -- NYI: return to lower frame
+ [18] = true, -- missing metamethod
+ [19] = true, -- looping index lookup
+ [20] = true, -- NYI: mixed sparse/dense table
+ [22] = true, -- NYI: unsupported C type conversion
+ [23] = true, -- NYI: unsupported C function type
+ [26] = true, -- persistent type instability
+}
+
+local function is_our_code(src)
+ if type(src) ~= 'string' then return false end
+ return src:match('runtime/pb/') ~= nil
+ or src:match('examples/expected/') ~= nil
+end
+
+local function fmt_reason(code, info)
+ local msg = vmdef.traceerr[code] or ('?code=' .. tostring(code))
+ return (msg:gsub('%%s', tostring(info or '?')))
+end
+
+local jutil = require('jit.util')
+
+local function record(fn, warmup_iters, measured_iters)
+ for _ = 1, warmup_iters do fn() end
+ jit.flush()
+ local fatal, stops = {}, 0
+ local start_loc, stop_loc = {}, {} -- trace_no -> {src, line, pc, parent}
+ local cb = function(what, tr, func, pc, code, info)
+ if what == 'start' and func then
+ local di = debug.getinfo(func, 'S')
+ -- For side traces: `code` is the parent trace number,
+ -- `info` is the parent's exit index. Root traces have code=nil.
+ start_loc[tr] = {
+ src = di.short_src, line = di.linedefined, pc = pc,
+ parent = code, -- nil for root, traceno for side trace
+ }
+ elseif what == 'stop' then
+ stops = stops + 1
+ if func then
+ local di = debug.getinfo(func, 'S')
+ stop_loc[tr] = {src = di.short_src, line = di.linedefined}
+ end
+ elseif what == 'abort' and FATAL[code] then
+ local di = func and debug.getinfo(func, 'S') or {short_src = '?'}
+ if is_our_code(di.short_src) then
+ fatal[#fatal + 1] = {
+ src = di.short_src,
+ line = di.linedefined,
+ code = code,
+ info = info,
+ }
+ end
+ end
+ end
+ jit.attach(cb, 'trace')
+ for _ = 1, measured_iters do fn() end
+ jit.attach(cb)
+ -- A "bridge" we care about is a SIDE trace (i.e. a child compiled off
+ -- a guard exit of some parent trace) whose own natural exit drops to
+ -- the interpreter. Pattern: parent runs hot → guard fails → side
+ -- trace covers the divergent code → falls back to VM dispatch instead
+ -- of stitching to another trace. Each such bridge costs a few hundred
+ -- ns of interp dispatch on every hot iteration.
+ --
+ -- Pure root traces that end in linktype=interpreter are NOT bridges:
+ -- they're short JIT'd snippets entered from interpreter and exit back
+ -- to it, with no extra dispatch cost beyond normal interp execution
+ -- of the surrounding code.
+ local bridges = {}
+ for tr = 1, 1024 do
+ local info = jutil.traceinfo(tr)
+ if not info then break end
+ if info.linktype == 'interpreter' and info.link == 0 then
+ local s = start_loc[tr] or stop_loc[tr]
+ if s and is_our_code(s.src) and s.parent then
+ bridges[#bridges + 1] = {
+ tr = tr,
+ src = s.src,
+ line = s.line,
+ pc = s.pc,
+ parent = s.parent,
+ }
+ end
+ end
+ end
+ return fatal, stops, bridges
+end
+
+local failures = 0
+local checks = 0
+
+local function check(label, fn, opts)
+ opts = opts or {}
+ local fatal, stops, bridges = record(fn, 2000, 5000)
+ checks = checks + 1
+ if #fatal > 0 and not opts.expect_fatal_in then
+ failures = failures + 1
+ io.stderr:write(string.format(
+ ' [FAIL] %s — %d fatal abort(s) in our code:\n', label, #fatal))
+ local seen = {}
+ for _, a in ipairs(fatal) do
+ local k = a.src .. ':' .. a.line .. '|' .. a.code
+ if not seen[k] then
+ seen[k] = true
+ io.stderr:write(string.format(
+ ' %s:%d %s\n',
+ a.src, a.line, fmt_reason(a.code, a.info)))
+ end
+ end
+ return
+ end
+ if opts.expect_fatal_in then
+ local saw = false
+ for _, a in ipairs(fatal) do
+ if a.src:match(opts.expect_fatal_in) then saw = true; break end
+ end
+ if not saw then
+ failures = failures + 1
+ io.stderr:write(string.format(
+ ' [FAIL] %s — expected a fatal abort in %q (known limitation), got none\n',
+ label, opts.expect_fatal_in))
+ return
+ end
+ io.stderr:write(string.format(
+ ' [PIN ] %s — expected NYI present (known LuaJIT 2.1 limitation)\n',
+ label))
+ return
+ end
+ if stops == 0 then
+ failures = failures + 1
+ io.stderr:write(string.format(
+ ' [FAIL] %s — no trace was compiled (stops=0)\n', label))
+ return
+ end
+ -- Interpreter bridges (side trace -> interp) are real but their
+ -- compilation timing is non-deterministic — across 10 runs you'll
+ -- see 0–4 in decoder paths (parent trace bakes in the 1-byte varint
+ -- fast path, the multi-byte side trace can't self-loop). They're a
+ -- topology metric, not a pass/fail signal — report them for
+ -- visibility, don't fail the gate.
+ io.stderr:write(string.format(
+ ' [ OK ] %s (stops=%d, bridges=%d)\n', label, stops, #bridges))
+ if #bridges > 0 then
+ for _, b in ipairs(bridges) do
+ io.stderr:write(string.format(
+ ' info: bridge tr%d side-of tr%s %s:%d pc=%s\n',
+ b.tr, tostring(b.parent), b.src, b.line, tostring(b.pc)))
+ end
+ end
+end
+
+-- ---------------------------------------------------------------------------
+
+io.stderr:write('tarantool-protobuf trace-stability gate ('
+ .. (jit.version or '?') .. ')\n')
+
+for _, mode in ipairs({'full', 'runtime'}) do
+ local hello = require(mode .. '.hello.hello_pb')
+
+ local addr = {street = '1 Main St', city = 'Springfield', zip = 12345}
+ local addr_bytes = hello.Address_encode(addr)
+
+ -- Person with repeated string, packed int32, nested message — no
+ -- map fields (see top-of-file scope note).
+ local person = {
+ name = 'bigbes', age = 42,
+ address = addr,
+ lucky_numbers = {7, 13, 21, 42, 99, 144, 233, 377},
+ emails = {'a@b.c', 'd@e.f', 'g@h.i', 'j@k.l'},
+ status = hello.Status.OK,
+ }
+ local person_bytes = hello.Person_encode(person)
+
+ local result = {value = 'ok'}
+ local result_bytes = hello.Result_encode(result)
+
+ check(mode .. '/Address_encode',
+ function() hello.Address_encode(addr) end)
+ check(mode .. '/Address_decode',
+ function() hello.Address_decode(addr_bytes) end)
+ check(mode .. '/Person_encode',
+ function() hello.Person_encode(person) end)
+ check(mode .. '/Person_decode',
+ function() hello.Person_decode(person_bytes) end)
+ check(mode .. '/Result_encode (oneof)',
+ function() hello.Result_encode(result) end)
+ check(mode .. '/Result_decode (oneof)',
+ function() hello.Result_decode(result_bytes) end)
+end
+
+-- Pin the known map limitation: pairs() over a hash compiles to bytecode
+-- ISNEXT, which Tarantool LuaJIT 2.1 can't trace. If this stops triggering,
+-- upstream lifted the restriction and our scope claim can broaden.
+do
+ local hello = require('full.hello.hello_pb')
+ local with_map = {
+ name = 'bigbes', age = 42,
+ ages_by_nickname = {bigbes = 1, eb = 2, blikh = 3},
+ }
+ check('full/Person_encode with map (known NYI)',
+ function() hello.Person_encode(with_map) end,
+ {expect_fatal_in = 'hello_pb%.lua'})
+end
+
+io.stderr:write(string.format(
+ '\n%d/%d checks passed\n', checks - failures, checks))
+os.exit(failures > 0 and 1 or 0)