luna_core/jit/aot_meta.rs
1//! Wire format for AOT trace metadata.
2//!
3//! # Why a luna-core module
4//!
5//! The format is shared by two distinct crates:
6//!
7//! - `luna-aot` (compile-time): serializes a runtime `CompiledTrace`
8//! into bytes embedded in the AOT object's `luna_trace_blob`
9//! section.
10//! - `luna-runtime-helpers` (deploy-time): walks the
11//! `luna_trace_meta` index section, deserializes each entry's blob
12//! into the **minimal fields** needed to construct a fresh
13//! `CompiledTrace` for the deploy `Vm`'s dispatcher.
14//!
15//! Putting the wire format under `luna-core` keeps both crates pinned
16//! to the same constants without giving either a dep on the other.
17//!
18//! # 0-dep contract
19//!
20//! Hand-rolled `u8` packing — no `bincode`, no `serde`. The header
21//! carries [`AOT_META_MAGIC`] +
22//! [`AOT_META_VERSION`]; a mismatch on the deploy side is a hard
23//! reject, not silent fallback.
24//!
25//! # Wire format versions
26//!
27//! - **v1** — minimal format. Fields: `head_pc`, `n_ops`,
28//! `window_size`, `dispatchable`, `entry_tags`, `exit_tags`,
29//! `global_tag_res_kind`. Only "simple" traces (no inline side-
30//! exits, no per-cont_pc tag exits) installable.
31//! - **v2** — Appends a trailing
32//! `per_exit_tags` array (`(cont_pc, [ExitTag])` per entry) so
33//! traces with typed-register side-exit guards (GetUpval-heavy
34//! closures, type-specialized GetField loops) are AOT-installable.
35//! v2 readers MUST accept v1 blobs as if `per_exit_tags`
36//! were empty (the trailing block becomes optional via the
37//! `total_payload < bytes.len()` predicate at decode time).
38//! - **v3** — Inline cmp@d>0 side-exit scaffolding. Appends a second
39//! trailing block after `per_exit_tags`: a list of
40//! [`PerExitInlineEntry`] records carrying the `cont_pc`,
41//! `head_resume_pc`, the per-slot exit-tag snapshot covering the
42//! trace's full `window_size`, and the `FrameMaterializeInfo`
43//! chain bytes. v3 readers accept v1 and v2 blobs as if the
44//! inline block were empty.
45//!
46//! **Important — install-time invariant**: emitting the v3 inline
47//! block into a meta blob is necessary but **not sufficient** to
48//! safely AOT-install a trace whose `per_exit_inline` is non-
49//! empty. The trace mcode itself today bakes a raw
50//! `Rc::as_ptr(&chain_rc)` value as an `iconst` immediate at lower
51//! time (see `luna-jit/src/jit_backend/trace.rs` near the cmp@d>0
52//! side-exit emit sites). Under JIT the immediate is a live heap
53//! pointer; under AOT it would be the warmup VM's heap address,
54//! invalid in the deploy binary. To actually unlock the install
55//! path the lowerer must:
56//! 1. Emit a writable per-site slot (`__luna_aot_inline_chain_
57//! slot_<key>`) and load through it instead of the raw
58//! `iconst`.
59//! 2. Register a new `luna_inline_chain_idx` bracketed section
60//! analogous to `luna_strkey_idx` so the deploy resolver can
61//! match installed entries to slot addresses.
62//! 3. The deploy walker (after rebuilding `Rc<[FrameMaterializeInfo
63//! ]>` from v3 blob bytes) writes the live address of the
64//! rebuilt chain into each slot before the first dispatch.
65//!
66//! Until those three pieces land, the AOT harvester
67//! (`luna-aot::embed`) MUST keep its
68//! `per_exit_inline.is_empty()` filter so non-empty-inline
69//! traces never produce a v3 blob with inline data — they stay
70//! JIT-only. The wire format below is forward-ready so the
71//! lowerer / resolver work doesn't need a fresh version bump.
72//!
73//! # Field summary
74//!
75//! `CompiledTrace` carries 30+ fields including `RefCell<HashMap>`,
76//! `Box<Cell<*const u8>>`, `Rc<[InlineSideExit]>` — most are side-
77//! trace bookkeeping irrelevant for AOT (the deploy `Vm` never side-
78//! traces an AOT-installed trace, so all those start empty). The
79//! AOT meta format serializes only the **dispatch-load-bearing**
80//! fields:
81//!
82//! - `head_pc`, `n_ops`, `window_size`, `dispatchable`
83//! - `entry_tags: Rc<[u8]>` — per-slot entry-tag specialization
84//! - `exit_tags: Rc<[ExitTag]>` — per-slot exit-tag restore (clean tail)
85//! - `global_tag_res_kind` — fast-path classification
86//! - `per_exit_tags` *(v2+)* — per-cont_pc slot-shape entries the
87//! dispatcher uses to restore vm.stack on a typed-register
88//! side-exit
89//! - `per_exit_inline` *(v3+)* — per-site inline cmp@d>0 side-exit
90//! records: cont_pc + head_resume_pc + per-slot exit_tags + the
91//! `FrameMaterializeInfo` chain bytes. Forward-compat scaffold;
92//! the harvester does not yet emit non-empty entries because the
93//! trace mcode side needs a relocatable chain-slot scheme first
94//! (see the v3 paragraph above).
95//!
96//! `body_writes`, side-trace ptrs etc. default to empty / null.
97
98use crate::jit::trace_types::{ExitTag, FrameMaterializeInfo, TagResKind};
99
100/// Magic bytes at the start of every AOT meta blob. The deploy walker
101/// checks this against `read::<u32>` before parsing the rest;
102/// mismatches are reported (and the entry skipped) rather than
103/// causing arbitrary deserialization.
104pub const AOT_META_MAGIC: u32 = 0xAA77_0001;
105
106/// Wire-format version. v1 = minimal format. v2 = appends
107/// trailing `per_exit_tags` block so typed-register side-exits
108/// (GetUpval-heavy traces) install at deploy time. v3 = appends a
109/// second trailing block carrying `per_exit_inline` data so
110/// depth>0 inlined cmp side-exits can be rebuilt at install time
111/// (the trace mcode side still needs a relocatable-slot scheme
112/// before non-empty inline entries can actually be emitted —
113/// see the module docs).
114///
115/// **Forward compatibility contract**: a v3 writer emits the same
116/// fixed-prefix header layout as v1/v2 plus the v2 tail (always
117/// present, count=0 if empty) plus the v3 tail (count=0 if empty).
118/// A v3 reader MUST accept v1 blobs (= header + tags only, no v2
119/// tail) and v2 blobs (= header + tags + v2 tail only, no v3 tail)
120/// as if the missing tails were empty — implementation lives in
121/// [`decode_meta_blob`]'s `bytes.len() > cur` predicate at each
122/// tail boundary. v2 readers on a v3 blob would mis-parse the v3
123/// tail as garbage; we bump `AOT_META_VERSION` so older readers
124/// hard-reject instead of silently mis-installing.
125pub const AOT_META_VERSION: u32 = 3;
126
127/// Fixed-size header at the top of every meta blob. All ints are
128/// little-endian.
129///
130/// Total = 28 bytes. The variable-length tag arrays follow this
131/// header back-to-back (`entry_tags_len` u8s then `exit_tags_len`
132/// u8s).
133#[repr(C)]
134#[derive(Clone, Copy, Debug)]
135pub struct AotTraceMetaHeader {
136 /// [`AOT_META_MAGIC`]. Deploy-side hard-rejects mismatch.
137 pub magic: u32,
138 /// [`AOT_META_VERSION`]. Deploy-side hard-rejects mismatch.
139 pub version: u32,
140 /// Trace's `head_pc` — the PC the dispatcher matches on.
141 pub head_pc: u32,
142 /// Trace's `n_ops` — diagnostic only on the deploy side.
143 pub n_ops: u32,
144 /// Trace's `window_size` — sizes the dispatcher's `reg_state` buffer.
145 pub window_size: u32,
146 /// Trace's `dispatchable` flag as `u8` (0 / 1).
147 pub dispatchable: u8,
148 /// Trace's `global_tag_res_kind` packed:
149 /// `0 = AllUntouched`, `1 = AllInt`, `2 = Mixed`.
150 pub tag_res_kind: u8,
151 /// Length of the `entry_tags` array that follows the header.
152 /// `u16` is enough: trace's `max_stack` is bounded by Lua's
153 /// `MAXREGS` (255) and even worst-case inlining caps under 4K.
154 pub entry_tags_len: u16,
155 /// Length of the `exit_tags` array that follows after `entry_tags`.
156 pub exit_tags_len: u32,
157}
158
159impl AotTraceMetaHeader {
160 /// Byte size of the fixed prefix. Used to compute payload offset.
161 pub const SIZE: usize = 28;
162}
163
164/// Pack an `ExitTag` into its on-disk `u8` representation. Mirrors the
165/// `#[repr(u8)]` discriminant so the wire format is the same byte the
166/// compiler would lay out — but we go through the explicit match so a
167/// future reorder of [`ExitTag`]'s variants doesn't silently change
168/// the format.
169pub fn pack_exit_tag(t: ExitTag) -> u8 {
170 match t {
171 ExitTag::Untouched => 0,
172 ExitTag::Int => 1,
173 ExitTag::Float => 2,
174 ExitTag::Table => 3,
175 ExitTag::Closure => 4,
176 ExitTag::Nil => 5,
177 ExitTag::Str => 6,
178 }
179}
180
181/// Inverse of [`pack_exit_tag`]. Returns `None` on an unknown byte
182/// (treated as a corruption signal by the deploy walker).
183pub fn unpack_exit_tag(b: u8) -> Option<ExitTag> {
184 match b {
185 0 => Some(ExitTag::Untouched),
186 1 => Some(ExitTag::Int),
187 2 => Some(ExitTag::Float),
188 3 => Some(ExitTag::Table),
189 4 => Some(ExitTag::Closure),
190 5 => Some(ExitTag::Nil),
191 6 => Some(ExitTag::Str),
192 _ => None,
193 }
194}
195
196/// Pack a [`TagResKind`] into its wire byte.
197pub fn pack_tag_res_kind(k: TagResKind) -> u8 {
198 match k {
199 TagResKind::AllUntouched => 0,
200 TagResKind::AllInt => 1,
201 TagResKind::Mixed => 2,
202 }
203}
204
205/// Inverse of [`pack_tag_res_kind`]. Returns `None` on an unknown byte.
206pub fn unpack_tag_res_kind(b: u8) -> Option<TagResKind> {
207 match b {
208 0 => Some(TagResKind::AllUntouched),
209 1 => Some(TagResKind::AllInt),
210 2 => Some(TagResKind::Mixed),
211 _ => None,
212 }
213}
214
215/// One per-cont_pc side-exit entry serialized into the v2 tail of a
216/// meta blob. Mirrors `CompiledTrace::per_exit_tags`'s `(u32,
217/// Rc<[ExitTag]>)` shape, with the `ExitTag` slice already packed
218/// through [`pack_exit_tag`].
219#[derive(Clone, Debug)]
220pub struct PerExitTagsEntry {
221 /// Pc the interp resumes at after the side-exit fires. Matches
222 /// the IR's `iconst` baked into the side-exit return.
223 pub cont_pc: u32,
224 /// Per-slot `ExitTag` snapshot at the side-exit moment, packed
225 /// via [`pack_exit_tag`]. Length is the trace's caller-window
226 /// `max_stack` (always ≤ `window_size`).
227 pub tags_packed: Vec<u8>,
228}
229
230/// One per-site inline cmp@d>0 side-exit entry serialized into the v3
231/// tail of a meta blob. Mirrors `CompiledTrace::per_exit_inline`'s
232/// [`crate::jit::trace_types::InlineSideExit`] shape minus the
233/// runtime-only `side_trace_ptr` cell (always defaults null on AOT
234/// install). The `chain` field carries [`FrameMaterializeInfo`]
235/// records as raw bytes — `FrameMaterializeInfo` is `repr(C)` with
236/// three 32-bit fields = exactly 12 bytes per entry on every
237/// supported target, so the wire layout is stable.
238#[derive(Clone, Debug)]
239pub struct PerExitInlineEntry {
240 /// Pc the interpreter resumes at after the inline side-exit
241 /// fires. Mirrors `InlineSideExit::cont_pc`.
242 pub cont_pc: u32,
243 /// Pc to write on the trace head frame when the side-exit fires
244 /// (the outermost self-rec Call's `pc + 1`). Mirrors
245 /// `InlineSideExit::head_resume_pc`.
246 pub head_resume_pc: u32,
247 /// Per-slot `ExitTag` snapshot at the side-exit moment, packed
248 /// via [`pack_exit_tag`]. Length equals the trace's full
249 /// `window_size` (caller + every inlined frame's register
250 /// window) — `per_exit_tags`'s arrays cover only `max_stack`,
251 /// inline arrays cover the full window.
252 pub tags_packed: Vec<u8>,
253 /// `FrameMaterializeInfo` records as raw bytes (count * 12).
254 /// Outermost = depth 1 first, innermost = depth N last; the
255 /// innermost frame's `pc` is already overwritten to the side-
256 /// exit PC at AOT-compile time (matches the JIT-side
257 /// snapshot.last_mut().pc = side_exit_pc step). Length divisible
258 /// by 12 is a wire-format invariant; the decoder rejects otherwise.
259 pub chain_bytes: Vec<u8>,
260}
261
262impl PerExitInlineEntry {
263 /// Byte size of one `FrameMaterializeInfo` on the wire. Asserted
264 /// at compile time via [`FRAME_MATERIALIZE_INFO_WIRE_SIZE_CHECK`]
265 /// against the live struct so layout drift fails the build.
266 pub const FRAME_MATERIALIZE_INFO_SIZE: usize = 12;
267
268 /// Construct a `PerExitInlineEntry` from a live
269 /// [`crate::jit::trace_types::InlineSideExit`]. The chain is
270 /// serialized via a raw-byte copy — `FrameMaterializeInfo` is
271 /// `repr(C)` + all-`Copy` fields with no padding, so the byte
272 /// pattern matches the on-disk wire layout verbatim.
273 ///
274 /// # Safety
275 ///
276 /// `FrameMaterializeInfo` is `#[repr(C)]` with three 32-bit
277 /// fields and no padding; the byte-level transmute below is
278 /// sound per the wire-size assertion at module top.
279 pub fn from_inline_side_exit(src: &crate::jit::trace_types::InlineSideExit) -> Self {
280 let tags_packed: Vec<u8> = src.exit_tags.iter().copied().map(pack_exit_tag).collect();
281 let n = src.chain.len();
282 let mut chain_bytes = Vec::with_capacity(n * Self::FRAME_MATERIALIZE_INFO_SIZE);
283 for fm in src.chain.iter() {
284 chain_bytes.extend_from_slice(&fm.base_offset.to_le_bytes());
285 chain_bytes.extend_from_slice(&fm.pc.to_le_bytes());
286 chain_bytes.extend_from_slice(&fm.nresults.to_le_bytes());
287 }
288 PerExitInlineEntry {
289 cont_pc: src.cont_pc,
290 head_resume_pc: src.head_resume_pc,
291 tags_packed,
292 chain_bytes,
293 }
294 }
295
296 /// Reconstruct a `Vec<FrameMaterializeInfo>` from this entry's
297 /// `chain_bytes`. Returns `None` if `chain_bytes.len()` is not a
298 /// multiple of [`Self::FRAME_MATERIALIZE_INFO_SIZE`] (corruption
299 /// signal — the deploy walker should skip the entry).
300 pub fn rebuild_chain(&self) -> Option<Vec<FrameMaterializeInfo>> {
301 let unit = Self::FRAME_MATERIALIZE_INFO_SIZE;
302 if !self.chain_bytes.len().is_multiple_of(unit) {
303 return None;
304 }
305 let n = self.chain_bytes.len() / unit;
306 let mut out = Vec::with_capacity(n);
307 for i in 0..n {
308 let off = i * unit;
309 let base_offset =
310 u32::from_le_bytes(self.chain_bytes[off..off + 4].try_into().unwrap());
311 let pc = u32::from_le_bytes(self.chain_bytes[off + 4..off + 8].try_into().unwrap());
312 let nresults =
313 i32::from_le_bytes(self.chain_bytes[off + 8..off + 12].try_into().unwrap());
314 out.push(FrameMaterializeInfo {
315 base_offset,
316 pc,
317 nresults,
318 });
319 }
320 Some(out)
321 }
322}
323
324/// Static assertion that [`FrameMaterializeInfo`]'s in-memory size
325/// matches the wire-format constant. A regression here (a fourth
326/// field, a different layout attribute) silently misaligns the
327/// `chain_bytes` (re)serialization; the build break makes the drift
328/// loud at the source instead of mysterious at deploy time.
329pub const FRAME_MATERIALIZE_INFO_WIRE_SIZE_CHECK: () = assert!(
330 core::mem::size_of::<FrameMaterializeInfo>() == PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE,
331 "FrameMaterializeInfo wire size drifted — update PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE \
332 and any deploy-side rebuilders together"
333);
334
335/// Serialize a header + the two tag arrays + the v2 `per_exit_tags`
336/// tail + the v3 `per_exit_inline` tail into a fresh `Vec<u8>`. Pass
337/// empty slices to emit a "simple" trace (each tail then carries a
338/// single `count = 0` u32 — still v3 layout, just empty).
339///
340/// The produced bytes are what `luna-aot` embeds into the
341/// `luna_trace_blob` section per-trace; the deploy walker reads from
342/// the same wire shape via [`decode_meta_blob`].
343pub fn encode_meta_blob(
344 header: &AotTraceMetaHeader,
345 entry_tags: &[u8],
346 exit_tags_packed: &[u8],
347 per_exit_tags: &[PerExitTagsEntry],
348 per_exit_inline: &[PerExitInlineEntry],
349) -> Vec<u8> {
350 assert_eq!(entry_tags.len(), header.entry_tags_len as usize);
351 assert_eq!(exit_tags_packed.len(), header.exit_tags_len as usize);
352 assert_eq!(header.version, AOT_META_VERSION);
353 let v2_tail_bytes: usize = 4 + per_exit_tags
354 .iter()
355 .map(|e| 4 + 4 + e.tags_packed.len())
356 .sum::<usize>();
357 let v3_tail_bytes: usize = 4 + per_exit_inline
358 .iter()
359 .map(|e| 4 + 4 + 4 + e.tags_packed.len() + 4 + e.chain_bytes.len())
360 .sum::<usize>();
361 let mut out = Vec::with_capacity(
362 AotTraceMetaHeader::SIZE
363 + entry_tags.len()
364 + exit_tags_packed.len()
365 + v2_tail_bytes
366 + v3_tail_bytes,
367 );
368 out.extend_from_slice(&header.magic.to_le_bytes());
369 out.extend_from_slice(&header.version.to_le_bytes());
370 out.extend_from_slice(&header.head_pc.to_le_bytes());
371 out.extend_from_slice(&header.n_ops.to_le_bytes());
372 out.extend_from_slice(&header.window_size.to_le_bytes());
373 out.push(header.dispatchable);
374 out.push(header.tag_res_kind);
375 out.extend_from_slice(&header.entry_tags_len.to_le_bytes());
376 out.extend_from_slice(&header.exit_tags_len.to_le_bytes());
377 out.extend_from_slice(entry_tags);
378 out.extend_from_slice(exit_tags_packed);
379 // v2 tail: u32 count, then per entry [cont_pc:u32, tags_len:u32, tags:[u8; tags_len]].
380 out.extend_from_slice(&(per_exit_tags.len() as u32).to_le_bytes());
381 for ent in per_exit_tags {
382 out.extend_from_slice(&ent.cont_pc.to_le_bytes());
383 out.extend_from_slice(&(ent.tags_packed.len() as u32).to_le_bytes());
384 out.extend_from_slice(&ent.tags_packed);
385 }
386 // v3 tail: u32 count, then per entry
387 // [cont_pc:u32, head_resume_pc:u32, tags_len:u32, tags:[u8; tags_len],
388 // chain_bytes_len:u32, chain_bytes:[u8; chain_bytes_len]].
389 // chain_bytes_len is always a multiple of 12 (FrameMaterializeInfo
390 // wire size); the decoder rejects otherwise as a corruption signal.
391 out.extend_from_slice(&(per_exit_inline.len() as u32).to_le_bytes());
392 for ent in per_exit_inline {
393 out.extend_from_slice(&ent.cont_pc.to_le_bytes());
394 out.extend_from_slice(&ent.head_resume_pc.to_le_bytes());
395 out.extend_from_slice(&(ent.tags_packed.len() as u32).to_le_bytes());
396 out.extend_from_slice(&ent.tags_packed);
397 out.extend_from_slice(&(ent.chain_bytes.len() as u32).to_le_bytes());
398 out.extend_from_slice(&ent.chain_bytes);
399 }
400 out
401}
402
403/// Decoded shape returned by [`decode_meta_blob`].
404#[derive(Debug)]
405pub struct DecodedMeta {
406 /// The fixed-prefix header.
407 pub header: AotTraceMetaHeader,
408 /// `entry_tags` payload (length = `header.entry_tags_len`).
409 pub entry_tags: Vec<u8>,
410 /// `exit_tags` payload (length = `header.exit_tags_len`), still in
411 /// packed `u8` form. Caller maps each through [`unpack_exit_tag`].
412 pub exit_tags: Vec<u8>,
413 /// v2 tail — per-cont_pc tag arrays. Empty for v1 blobs and for
414 /// v2+ traces with no typed-register side-exits.
415 pub per_exit_tags: Vec<PerExitTagsEntry>,
416 /// v3 tail — per-site inline cmp@d>0 side-exit metadata.
417 /// Empty for v1 / v2 blobs and for v3 traces with no inlined
418 /// side-exits. Today the AOT harvester filters out traces with
419 /// non-empty `per_exit_inline` regardless (see module docs);
420 /// the field exists so the wire format is forward-ready for the
421 /// relocatable-chain-slot lowerer work that flips the filter.
422 pub per_exit_inline: Vec<PerExitInlineEntry>,
423}
424
425/// Deserialize a blob produced by [`encode_meta_blob`]. Returns
426/// `Err(reason)` on magic / version / length mismatch — the deploy
427/// walker should skip the entry and log the reason rather than
428/// installing a broken trace.
429pub fn decode_meta_blob(bytes: &[u8]) -> Result<DecodedMeta, &'static str> {
430 if bytes.len() < AotTraceMetaHeader::SIZE {
431 return Err("blob shorter than header");
432 }
433 let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
434 if magic != AOT_META_MAGIC {
435 return Err("AOT_META_MAGIC mismatch");
436 }
437 let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
438 if version != AOT_META_VERSION {
439 return Err("AOT_META_VERSION mismatch");
440 }
441 let head_pc = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
442 let n_ops = u32::from_le_bytes(bytes[12..16].try_into().unwrap());
443 let window_size = u32::from_le_bytes(bytes[16..20].try_into().unwrap());
444 let dispatchable = bytes[20];
445 let tag_res_kind = bytes[21];
446 let entry_tags_len = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
447 let exit_tags_len = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
448 let header = AotTraceMetaHeader {
449 magic,
450 version,
451 head_pc,
452 n_ops,
453 window_size,
454 dispatchable,
455 tag_res_kind,
456 entry_tags_len,
457 exit_tags_len,
458 };
459 let total_payload = entry_tags_len as usize + exit_tags_len as usize;
460 if bytes.len() < AotTraceMetaHeader::SIZE + total_payload {
461 return Err("blob shorter than declared payload");
462 }
463 let entry_start = AotTraceMetaHeader::SIZE;
464 let entry_end = entry_start + entry_tags_len as usize;
465 let exit_end = entry_end + exit_tags_len as usize;
466 let entry_tags = bytes[entry_start..entry_end].to_vec();
467 let exit_tags = bytes[entry_end..exit_end].to_vec();
468 // v2 tail: optional per_exit_tags block. Absent (= empty) when
469 // the blob ends exactly at `exit_end` — covers v1-shaped
470 // producers (which never wrote a tail) and v2+ producers
471 // serializing a trace with zero typed-register side-exits.
472 let mut per_exit_tags: Vec<PerExitTagsEntry> = Vec::new();
473 let mut cur = exit_end;
474 if bytes.len() > cur {
475 if bytes.len() < cur + 4 {
476 return Err("v2 tail truncated at count");
477 }
478 let count = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
479 cur += 4;
480 // the count comes from the blob: reserve only what the rest of it
481 // can hold (an entry takes at least its 8-byte header), so a
482 // corrupt count fails in the loop below instead of asking for
483 // gigabytes
484 per_exit_tags.reserve(count.min((bytes.len() - cur) / 8));
485 for _ in 0..count {
486 if bytes.len() < cur + 8 {
487 return Err("v2 tail truncated at entry header");
488 }
489 let cont_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
490 cur += 4;
491 let tags_len = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
492 cur += 4;
493 if bytes.len() < cur + tags_len {
494 return Err("v2 tail truncated at entry tags");
495 }
496 let tags_packed = bytes[cur..cur + tags_len].to_vec();
497 cur += tags_len;
498 per_exit_tags.push(PerExitTagsEntry {
499 cont_pc,
500 tags_packed,
501 });
502 }
503 }
504 // v3 tail: optional per_exit_inline block. Absent when the blob
505 // ends at the v2 tail boundary — covers v1 / v2 producers and
506 // v3 producers serializing a trace with zero inline cmp@d>0
507 // side-exits. Tail layout per entry:
508 // cont_pc:u32, head_resume_pc:u32,
509 // tags_len:u32, tags:[u8; tags_len],
510 // chain_bytes_len:u32, chain_bytes:[u8; chain_bytes_len]
511 // chain_bytes_len validated as a multiple of 12
512 // (FrameMaterializeInfo wire size) — non-multiple = corruption,
513 // we Err so the deploy walker skips the entry cleanly.
514 let mut per_exit_inline: Vec<PerExitInlineEntry> = Vec::new();
515 if bytes.len() > cur {
516 if bytes.len() < cur + 4 {
517 return Err("v3 tail truncated at count");
518 }
519 let count = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
520 cur += 4;
521 // as above: an entry is at least 16 bytes (cont_pc, resume pc and
522 // the two length fields)
523 per_exit_inline.reserve(count.min((bytes.len() - cur) / 16));
524 for _ in 0..count {
525 if bytes.len() < cur + 12 {
526 return Err("v3 tail truncated at entry header");
527 }
528 let cont_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
529 cur += 4;
530 let head_resume_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
531 cur += 4;
532 let tags_len = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
533 cur += 4;
534 if bytes.len() < cur + tags_len {
535 return Err("v3 tail truncated at entry tags");
536 }
537 let tags_packed = bytes[cur..cur + tags_len].to_vec();
538 cur += tags_len;
539 if bytes.len() < cur + 4 {
540 return Err("v3 tail truncated at chain header");
541 }
542 let chain_bytes_len =
543 u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
544 cur += 4;
545 if bytes.len() < cur + chain_bytes_len {
546 return Err("v3 tail truncated at chain bytes");
547 }
548 if !chain_bytes_len.is_multiple_of(PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE) {
549 return Err("v3 tail chain_bytes_len not a multiple of FrameMaterializeInfo size");
550 }
551 let chain_bytes = bytes[cur..cur + chain_bytes_len].to_vec();
552 cur += chain_bytes_len;
553 per_exit_inline.push(PerExitInlineEntry {
554 cont_pc,
555 head_resume_pc,
556 tags_packed,
557 chain_bytes,
558 });
559 }
560 }
561 Ok(DecodedMeta {
562 header,
563 entry_tags,
564 exit_tags,
565 per_exit_tags,
566 per_exit_inline,
567 })
568}
569
570/// Index entry layout in the deploy-side `luna_trace_meta` section.
571///
572/// 48 bytes per entry; the static linker fills `fn_ptr` and `meta_ptr`
573/// with relocations resolving to the trace's `.text` body and the
574/// matching `luna_trace_blob` payload respectively.
575///
576/// The deploy walker brackets the section via linker-synthetic
577/// `__start_luna_trace_meta` / `__stop_luna_trace_meta` (ELF) or
578/// `section$start$__DATA$luna_trace_meta` (Mach-O), mirroring sub-
579/// piece 3's `luna_strkey_idx` plumbing.
580#[repr(C)]
581#[derive(Clone, Copy, Debug)]
582pub struct AotTraceIndexEntry {
583 /// `Proto::stable_hash()` — matches the AOT-time proto identity
584 /// against the deploy-loaded proto tree.
585 pub proto_hash: [u8; 16],
586 /// Trace's `head_pc`. Used together with `proto_hash` to detect
587 /// duplicate installs and to log which trace fired.
588 pub head_pc: u32,
589 /// Padding so the following 64-bit address fields align at 8 bytes.
590 pub _pad: u32,
591 /// Address of the AOT-emitted trace fn
592 /// (`extern "C" fn(*mut i64) -> i64`). Stored as `u64` so the
593 /// wire layout is identical across 32/64-bit targets — wasm32 +
594 /// other 32-bit targets cast through this field. AOT-binary
595 /// deploy is always 64-bit (cross-compile to 32-bit targets
596 /// disabled at the linker step), so the upper 32 bits are zero
597 /// in practice. Linker-resolved relocation against the
598 /// `luna_aot_trace_<idx>` symbol the lowerer exports.
599 pub fn_ptr: u64,
600 /// Address of the matching meta blob in `luna_trace_blob`. Same
601 /// width-stable rationale as `fn_ptr`.
602 pub meta_ptr: u64,
603 /// Length of the meta blob (the deploy walker hard-rejects entries
604 /// whose declared payload exceeds this).
605 pub meta_len: u32,
606 /// Padding so the entry is a multiple of 8 bytes (48 total).
607 pub _pad2: u32,
608}
609
610impl AotTraceIndexEntry {
611 /// Byte size of one index entry. Compile-time assertion lives
612 /// next to the type via [`AOT_TRACE_INDEX_ENTRY_SIZE_CHECK`].
613 pub const SIZE: usize = 48;
614}
615
616/// Static assertion that `AotTraceIndexEntry` is exactly 48 bytes on
617/// the host build. Both crates that consume this format (`luna-aot`,
618/// `luna-runtime-helpers`) inherit the assertion via the type, so a
619/// padding regression fails compilation before the wire format
620/// silently misaligns.
621pub const AOT_TRACE_INDEX_ENTRY_SIZE_CHECK: () = assert!(
622 core::mem::size_of::<AotTraceIndexEntry>() == AotTraceIndexEntry::SIZE,
623 "AotTraceIndexEntry must be 48 bytes — alignment / padding regressed"
624);
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 /// A tail count the rest of the blob cannot hold is a truncation. It
631 /// sized a `reserve` before, so a corrupt count asked for tens of
632 /// gigabytes and aborted the process (found by `fuzz_aot_meta`).
633 #[test]
634 fn a_tail_count_larger_than_the_blob_is_refused_before_allocating() {
635 let header = AotTraceMetaHeader {
636 magic: AOT_META_MAGIC,
637 version: AOT_META_VERSION,
638 head_pc: 0,
639 n_ops: 0,
640 window_size: 0,
641 dispatchable: 1,
642 tag_res_kind: pack_tag_res_kind(TagResKind::AllInt),
643 entry_tags_len: 0,
644 exit_tags_len: 0,
645 };
646 let blob = encode_meta_blob(&header, &[], &[], &[], &[]);
647 let v2 = AotTraceMetaHeader::SIZE;
648 let mut bad = blob.clone();
649 bad[v2..v2 + 4].copy_from_slice(&u32::MAX.to_le_bytes());
650 assert_eq!(
651 decode_meta_blob(&bad).err(),
652 Some("v2 tail truncated at entry header")
653 );
654 let mut bad = blob;
655 bad[v2 + 4..v2 + 8].copy_from_slice(&u32::MAX.to_le_bytes());
656 assert_eq!(
657 decode_meta_blob(&bad).err(),
658 Some("v3 tail truncated at entry header")
659 );
660 }
661
662 #[test]
663 fn header_round_trip() {
664 let header = AotTraceMetaHeader {
665 magic: AOT_META_MAGIC,
666 version: AOT_META_VERSION,
667 head_pc: 42,
668 n_ops: 7,
669 window_size: 4,
670 dispatchable: 1,
671 tag_res_kind: pack_tag_res_kind(TagResKind::AllInt),
672 entry_tags_len: 2,
673 exit_tags_len: 3,
674 };
675 let entry_tags = vec![1u8, 2u8];
676 let exit_tags = vec![
677 pack_exit_tag(ExitTag::Int),
678 pack_exit_tag(ExitTag::Untouched),
679 pack_exit_tag(ExitTag::Float),
680 ];
681 let blob = encode_meta_blob(&header, &entry_tags, &exit_tags, &[], &[]);
682 // SIZE + entry_tags + exit_tags + v2-tail-count(4) + v3-tail-count(4)
683 assert_eq!(blob.len(), AotTraceMetaHeader::SIZE + 2 + 3 + 4 + 4);
684 let decoded = decode_meta_blob(&blob).expect("decode");
685 assert!(decoded.per_exit_tags.is_empty());
686 assert!(decoded.per_exit_inline.is_empty());
687 assert_eq!(decoded.header.head_pc, 42);
688 assert_eq!(decoded.header.window_size, 4);
689 assert_eq!(decoded.header.dispatchable, 1);
690 assert_eq!(decoded.entry_tags, entry_tags);
691 assert_eq!(decoded.exit_tags, exit_tags);
692 assert_eq!(
693 unpack_tag_res_kind(decoded.header.tag_res_kind),
694 Some(TagResKind::AllInt)
695 );
696 for (raw, expected) in
697 decoded
698 .exit_tags
699 .iter()
700 .zip([ExitTag::Int, ExitTag::Untouched, ExitTag::Float])
701 {
702 assert_eq!(unpack_exit_tag(*raw), Some(expected));
703 }
704 }
705
706 #[test]
707 fn decode_rejects_magic_mismatch() {
708 let mut blob = vec![0u8; AotTraceMetaHeader::SIZE];
709 // Magic stays zero.
710 let err = decode_meta_blob(&blob).unwrap_err();
711 assert!(err.contains("MAGIC"));
712 // Now valid magic + wrong version.
713 blob[..4].copy_from_slice(&AOT_META_MAGIC.to_le_bytes());
714 let err = decode_meta_blob(&blob).unwrap_err();
715 assert!(err.contains("VERSION"));
716 }
717
718 #[test]
719 fn v2_per_exit_tags_round_trip() {
720 // Two entries — one shorter than the other so the tail walker
721 // exercises variable-length parsing per entry.
722 let header = AotTraceMetaHeader {
723 magic: AOT_META_MAGIC,
724 version: AOT_META_VERSION,
725 head_pc: 7,
726 n_ops: 12,
727 window_size: 5,
728 dispatchable: 1,
729 tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
730 entry_tags_len: 0,
731 exit_tags_len: 0,
732 };
733 let entries = vec![
734 PerExitTagsEntry {
735 cont_pc: 3,
736 tags_packed: vec![
737 pack_exit_tag(ExitTag::Int),
738 pack_exit_tag(ExitTag::Untouched),
739 ],
740 },
741 PerExitTagsEntry {
742 cont_pc: 11,
743 tags_packed: vec![
744 pack_exit_tag(ExitTag::Closure),
745 pack_exit_tag(ExitTag::Table),
746 pack_exit_tag(ExitTag::Float),
747 ],
748 },
749 ];
750 let blob = encode_meta_blob(&header, &[], &[], &entries, &[]);
751 let decoded = decode_meta_blob(&blob).expect("decode v2");
752 assert_eq!(decoded.per_exit_tags.len(), 2);
753 assert_eq!(decoded.per_exit_tags[0].cont_pc, 3);
754 assert_eq!(decoded.per_exit_tags[0].tags_packed.len(), 2);
755 assert_eq!(decoded.per_exit_tags[1].cont_pc, 11);
756 assert_eq!(decoded.per_exit_tags[1].tags_packed.len(), 3);
757 assert!(decoded.per_exit_inline.is_empty());
758 }
759
760 #[test]
761 fn v3_per_exit_inline_round_trip() {
762 // Two inline-side-exit entries with different chain depths so
763 // the tail walker exercises variable-length per-entry parsing.
764 let header = AotTraceMetaHeader {
765 magic: AOT_META_MAGIC,
766 version: AOT_META_VERSION,
767 head_pc: 0,
768 n_ops: 0,
769 window_size: 8,
770 dispatchable: 1,
771 tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
772 entry_tags_len: 0,
773 exit_tags_len: 0,
774 };
775 // Hand-roll the inline entries (the live-CompiledTrace
776 // converter is exercised by the round-trip-from-live test
777 // below).
778 let inline = vec![
779 PerExitInlineEntry {
780 cont_pc: 5,
781 head_resume_pc: 9,
782 tags_packed: vec![pack_exit_tag(ExitTag::Int), pack_exit_tag(ExitTag::Int)],
783 // 1 FrameMaterializeInfo = 12 bytes:
784 // base_offset = 3, pc = 4, nresults = 1
785 chain_bytes: {
786 let mut v = Vec::new();
787 v.extend_from_slice(&3u32.to_le_bytes());
788 v.extend_from_slice(&4u32.to_le_bytes());
789 v.extend_from_slice(&1i32.to_le_bytes());
790 v
791 },
792 },
793 PerExitInlineEntry {
794 cont_pc: 17,
795 head_resume_pc: 21,
796 tags_packed: vec![
797 pack_exit_tag(ExitTag::Closure),
798 pack_exit_tag(ExitTag::Untouched),
799 pack_exit_tag(ExitTag::Float),
800 ],
801 // 2 frames = 24 bytes.
802 chain_bytes: {
803 let mut v = Vec::new();
804 for (off, pc, nr) in [(2u32, 7u32, 1i32), (5u32, 11u32, 2i32)] {
805 v.extend_from_slice(&off.to_le_bytes());
806 v.extend_from_slice(&pc.to_le_bytes());
807 v.extend_from_slice(&nr.to_le_bytes());
808 }
809 v
810 },
811 },
812 ];
813 let blob = encode_meta_blob(&header, &[], &[], &[], &inline);
814 let decoded = decode_meta_blob(&blob).expect("decode v3");
815 assert_eq!(decoded.per_exit_inline.len(), 2);
816 assert_eq!(decoded.per_exit_inline[0].cont_pc, 5);
817 assert_eq!(decoded.per_exit_inline[0].head_resume_pc, 9);
818 assert_eq!(decoded.per_exit_inline[0].tags_packed.len(), 2);
819 let chain0 = decoded.per_exit_inline[0]
820 .rebuild_chain()
821 .expect("rebuild chain[0]");
822 assert_eq!(chain0.len(), 1);
823 assert_eq!(chain0[0].base_offset, 3);
824 assert_eq!(chain0[0].pc, 4);
825 assert_eq!(chain0[0].nresults, 1);
826 let chain1 = decoded.per_exit_inline[1]
827 .rebuild_chain()
828 .expect("rebuild chain[1]");
829 assert_eq!(chain1.len(), 2);
830 assert_eq!(chain1[0].base_offset, 2);
831 assert_eq!(chain1[1].pc, 11);
832 assert_eq!(chain1[1].nresults, 2);
833 }
834
835 #[test]
836 fn v3_per_exit_inline_round_trip_from_live() {
837 // Exercise PerExitInlineEntry::from_inline_side_exit against
838 // a hand-built InlineSideExit, then encode + decode +
839 // rebuild and check field equality. Catches drift if either
840 // the live-struct shape or the wire layout changes without
841 // updating the other.
842 use crate::jit::trace_types::{ExitTag, FrameMaterializeInfo, InlineSideExit};
843 let chain = vec![FrameMaterializeInfo {
844 base_offset: 1,
845 pc: 2,
846 nresults: 3,
847 }];
848 let live = InlineSideExit {
849 cont_pc: 42,
850 head_resume_pc: 50,
851 exit_tags: crate::jit::send_compat::TArc::from(
852 vec![ExitTag::Int, ExitTag::Float, ExitTag::Untouched].into_boxed_slice(),
853 ),
854 chain: crate::jit::send_compat::TArc::from(chain.into_boxed_slice()),
855 side_trace_ptr: Box::new(crate::jit::send_compat::TCellPtr::null()),
856 };
857 let entry = PerExitInlineEntry::from_inline_side_exit(&live);
858 assert_eq!(entry.cont_pc, 42);
859 assert_eq!(entry.head_resume_pc, 50);
860 assert_eq!(entry.tags_packed.len(), 3);
861 assert_eq!(
862 entry.chain_bytes.len(),
863 PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE
864 );
865 let header = AotTraceMetaHeader {
866 magic: AOT_META_MAGIC,
867 version: AOT_META_VERSION,
868 head_pc: 0,
869 n_ops: 0,
870 window_size: 4,
871 dispatchable: 1,
872 tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
873 entry_tags_len: 0,
874 exit_tags_len: 0,
875 };
876 let blob = encode_meta_blob(&header, &[], &[], &[], &[entry]);
877 let decoded = decode_meta_blob(&blob).expect("decode v3 from live");
878 assert_eq!(decoded.per_exit_inline.len(), 1);
879 let rebuilt = decoded.per_exit_inline[0].rebuild_chain().expect("rebuild");
880 assert_eq!(rebuilt.len(), 1);
881 assert_eq!(rebuilt[0].base_offset, 1);
882 assert_eq!(rebuilt[0].pc, 2);
883 assert_eq!(rebuilt[0].nresults, 3);
884 }
885
886 #[test]
887 fn decode_rejects_v3_chain_bytes_misaligned() {
888 // Hand-emit a v3 blob whose chain_bytes_len is not a multiple
889 // of 12 — the decoder MUST refuse (returning Err) instead of
890 // silently truncating, so the deploy walker has a clean skip
891 // signal.
892 let header = AotTraceMetaHeader {
893 magic: AOT_META_MAGIC,
894 version: AOT_META_VERSION,
895 head_pc: 0,
896 n_ops: 0,
897 window_size: 0,
898 dispatchable: 0,
899 tag_res_kind: 0,
900 entry_tags_len: 0,
901 exit_tags_len: 0,
902 };
903 let mut blob = Vec::new();
904 blob.extend_from_slice(&header.magic.to_le_bytes());
905 blob.extend_from_slice(&header.version.to_le_bytes());
906 blob.extend_from_slice(&header.head_pc.to_le_bytes());
907 blob.extend_from_slice(&header.n_ops.to_le_bytes());
908 blob.extend_from_slice(&header.window_size.to_le_bytes());
909 blob.push(header.dispatchable);
910 blob.push(header.tag_res_kind);
911 blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
912 blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
913 // v2 tail: count=0.
914 blob.extend_from_slice(&0u32.to_le_bytes());
915 // v3 tail: count=1, cont_pc=0, head_resume_pc=0, tags_len=0,
916 // chain_bytes_len=7 (not a multiple of 12), chain_bytes=7 zeros.
917 blob.extend_from_slice(&1u32.to_le_bytes());
918 blob.extend_from_slice(&0u32.to_le_bytes());
919 blob.extend_from_slice(&0u32.to_le_bytes());
920 blob.extend_from_slice(&0u32.to_le_bytes());
921 blob.extend_from_slice(&7u32.to_le_bytes());
922 blob.extend(std::iter::repeat_n(0u8, 7));
923 let err = decode_meta_blob(&blob).unwrap_err();
924 assert!(
925 err.contains("FrameMaterializeInfo"),
926 "expected misalignment err, got {err:?}"
927 );
928 }
929
930 #[test]
931 fn decode_tolerates_v1_blob_shape() {
932 // Emulate a v1-shaped blob: header + tags, NO trailing v2/v3
933 // tails. The v3 decoder should accept as empty everywhere.
934 let header = AotTraceMetaHeader {
935 magic: AOT_META_MAGIC,
936 version: AOT_META_VERSION,
937 head_pc: 0,
938 n_ops: 0,
939 window_size: 0,
940 dispatchable: 0,
941 tag_res_kind: 0,
942 entry_tags_len: 1,
943 exit_tags_len: 0,
944 };
945 let mut blob = Vec::new();
946 blob.extend_from_slice(&header.magic.to_le_bytes());
947 blob.extend_from_slice(&header.version.to_le_bytes());
948 blob.extend_from_slice(&header.head_pc.to_le_bytes());
949 blob.extend_from_slice(&header.n_ops.to_le_bytes());
950 blob.extend_from_slice(&header.window_size.to_le_bytes());
951 blob.push(header.dispatchable);
952 blob.push(header.tag_res_kind);
953 blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
954 blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
955 blob.push(0); // entry_tags[0]
956 // No v2/v3 tails.
957 let decoded = decode_meta_blob(&blob).expect("decode v1-shaped");
958 assert!(decoded.per_exit_tags.is_empty());
959 assert!(decoded.per_exit_inline.is_empty());
960 }
961
962 #[test]
963 fn decode_tolerates_v2_blob_shape() {
964 // Emulate a v2-shaped blob: header + tags + v2 tail only,
965 // NO trailing v3 count u32. The v3 decoder should accept it
966 // as an empty per_exit_inline.
967 let header = AotTraceMetaHeader {
968 magic: AOT_META_MAGIC,
969 version: AOT_META_VERSION,
970 head_pc: 0,
971 n_ops: 0,
972 window_size: 0,
973 dispatchable: 0,
974 tag_res_kind: 0,
975 entry_tags_len: 0,
976 exit_tags_len: 0,
977 };
978 let mut blob = Vec::new();
979 blob.extend_from_slice(&header.magic.to_le_bytes());
980 blob.extend_from_slice(&header.version.to_le_bytes());
981 blob.extend_from_slice(&header.head_pc.to_le_bytes());
982 blob.extend_from_slice(&header.n_ops.to_le_bytes());
983 blob.extend_from_slice(&header.window_size.to_le_bytes());
984 blob.push(header.dispatchable);
985 blob.push(header.tag_res_kind);
986 blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
987 blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
988 // v2 tail: count=1, one entry (cont_pc=3, tags_len=1, tags=[Int])
989 blob.extend_from_slice(&1u32.to_le_bytes());
990 blob.extend_from_slice(&3u32.to_le_bytes());
991 blob.extend_from_slice(&1u32.to_le_bytes());
992 blob.push(pack_exit_tag(ExitTag::Int));
993 // No v3 tail.
994 let decoded = decode_meta_blob(&blob).expect("decode v2-shaped");
995 assert_eq!(decoded.per_exit_tags.len(), 1);
996 assert!(decoded.per_exit_inline.is_empty());
997 }
998
999 #[test]
1000 fn decode_rejects_truncated() {
1001 // Header is fine, but exit_tags_len declares 10 bytes that
1002 // aren't there.
1003 let header = AotTraceMetaHeader {
1004 magic: AOT_META_MAGIC,
1005 version: AOT_META_VERSION,
1006 head_pc: 0,
1007 n_ops: 0,
1008 window_size: 0,
1009 dispatchable: 0,
1010 tag_res_kind: 0,
1011 entry_tags_len: 0,
1012 exit_tags_len: 10,
1013 };
1014 let blob = {
1015 let mut b = Vec::new();
1016 b.extend_from_slice(&header.magic.to_le_bytes());
1017 b.extend_from_slice(&header.version.to_le_bytes());
1018 b.extend_from_slice(&header.head_pc.to_le_bytes());
1019 b.extend_from_slice(&header.n_ops.to_le_bytes());
1020 b.extend_from_slice(&header.window_size.to_le_bytes());
1021 b.push(header.dispatchable);
1022 b.push(header.tag_res_kind);
1023 b.extend_from_slice(&header.entry_tags_len.to_le_bytes());
1024 b.extend_from_slice(&header.exit_tags_len.to_le_bytes());
1025 b
1026 };
1027 // Only header, no payload — should fail truncation check.
1028 let err = decode_meta_blob(&blob).unwrap_err();
1029 assert!(err.contains("payload"));
1030 }
1031}