Found by CC while porting our line-coverage tool; reproduced on the box
with 0.17.0 and with master 0.18.0-dev.610+720939f8c (2026-10-25); the
cause found in Sema. The fix is the structural one (one decoder of a
function's ZIR payload), built and tested here; attach
zig-fnpayload.patch or paste it as a PR.
Title: Sema: zirFunc reads SrcLocs from the wrong offset when the return type is generic, giving bogus debug line info
Zig version: 0.17.0 and master 0.18.0-dev.610+720939f8c, x86_64-linux, self-hosted x86_64 backend and -fllvm.
Repro (r2.zig, 11 lines):
const std = @import("std");
pub fn pick(r: std.Random, comptime weights: anytype) std.meta.FieldEnum(@TypeOf(weights)) {
_ = r;
return @enumFromInt(0);
}
test "pick" {
var prng: std.Random.DefaultPrng = .init(1);
_ = pick(prng.random(), .{ .a = 3, .b = 1 });
}
zig test r2.zig --test-no-exec -femit-bin=bin
readelf --debug-dump=decodedline bin | awk '$1=="r2.zig"'
Actual: pick's rows are at lines 24, 5, 25 (self-hosted) and 24, 5
(-fllvm), past the end of the file. Expected: 3, 5, 6.
A smaller one, no std:
pub fn pick(x: anytype) @TypeOf(x) {
return x;
}
test "pick" {
_ = pick(@as(u32, 3));
}
gives 9, 2, 10 in a 7-line file.
Cause: in src/Sema.zig zirFunc, when ret_ty.is_generic is set
the return type's trailing data (one Ref, or a body of body_len
instructions) is not skipped:
const ret_ty: Type = if (extra.data.ret_ty.is_generic)
.generic_poison
else switch (extra.data.ret_ty.body_len) {
1 => blk: { ...; extra_index += 1; ... },
else => blk: { ...; extra_index += ret_ty_body.len; ... },
...
};
...
extra_index += extra.data.body_len;
src_locs = sema.code.extraData(Zir.Inst.Func.SrcLocs, extra_index).data;
so src_locs is read ret_ty.body_len words early, from the end of the
function body, and lbrace_line/rbrace_line are two consecutive
instruction indices (21/22 above, after the decl line). That fits what is
seen: the bogus lines move with the declaration but not with the body's
length, are always one apart, both backends agree, and a function whose
return type does not name a parameter is fine.
The line numbers themselves are fine: AstGen records them correctly
(relative to the declaration, so moving a function does not change its
ZIR), and the body's dbg_stmt rows come out right. Only the read of the
Func payload is off.
Why it could happen: the trailing data of func/func_inferred
(Inst.Func) and func_fancy (Inst.FuncFancy) is walked by hand in six
places, each with its own extra_index arithmetic: Zir.getFnInfo,
Zir.getAssociatedSrcHash, Zir.findTrackableInner, Sema.zirFunc,
Sema.zirFuncFancy, and print_zir's writeFunc/writeFuncFancy. Five
get it right; zirFunc skips a step on one branch.
Fix: decode the payload in one place. A new Zir.getFnPayload walks
either layout once and returns every part of it:
pub const FnPayload = struct {
param_block: Inst.Index,
cc_ref: Inst.Ref, // .none when cc_body is used or the convention is omitted
cc_body: []const Inst.Index,
ret_ty_ref: Inst.Ref, // .void_type when omitted; .none when ret_ty_body is used
ret_ty_body: []const Inst.Index,
ret_ty_is_generic: bool,
is_inferred_error: bool,
is_var_args: bool,
is_noinline: bool,
noalias_bits: u32,
body: []const Inst.Index, // empty for a prototype
src_locs: ?Inst.Func.SrcLocs, // present exactly when body is not empty
proto_hash: ?std.zig.SrcHash, // likewise
};
and all six readers use it; none keeps an extra_index of its own. In
zirFunc the return type and the brace lines become:
const payload = sema.code.getFnPayload(inst);
...
const ret_ty: Type = if (payload.ret_ty_is_generic)
.generic_poison
else if (payload.ret_ty_body.len != 0) blk: {
const ret_ty_val = try sema.resolveGenericBody(block, ret_ty_src, payload.ret_ty_body, inst, .type, .{ .simple = .fn_ret_ty });
break :blk ret_ty_val.toType();
} else if (payload.ret_ty_ref == .void_type)
.void
else
try sema.resolveType(block, ret_ty_src, payload.ret_ty_ref);
const has_body = payload.body.len != 0;
const src_locs = payload.src_locs orelse undefined;
getAssociatedSrcHash's two hand-walks become return
zir.getFnPayload(inst).proto_hash, findTrackableInner's two branches
become one, and print_zir's two writers become one. Net: +169 / -328
lines over lib/std/zig/Zir.zig, src/Sema.zig and src/print_zir.zig;
the whole diff, against master 720939f8c:
zig-fnpayload.patch.
One visible change: print_zir now prints an omitted return type as
void_type for func_fancy too, as it already did for func. Nothing
compares printed ZIR.
Tested with a Debug build of the patched compiler (no LLVM): the repro
gives 3, 5, 6, the anytype one 1, 2, 3, and a function moved down or
given a longer body moves its rows with it; zig test test/behavior.zig
-fno-llvm -fno-lld: 2194 passed, 27 skipped, 0 failed, the same result
test by test as unpatched master.
Impact: addr2line, coverage and other line-table tools either drop
these rows or index past the end of the source.