A field note from building a Zig-on-disk / Rust-in-memory / Lua-on-the-loop
lock service (lunet-locks,
tbio-core). The problem: a
64-byte record must be the same 64 bytes whether it is a Rust struct, a
Zig extern struct, bytes on an SSD, or an argument in a C ABI call — and
the proof has to run on the build, on every architecture, not in a one-off
session. The evidence cited below is commit
74c2b10 on
lua-lunet/tbio-core (the engine, the ABI, the canary) and
9cc562e on lunet-locks-wal (the Rust driver tests and the integration).
extern struct (Zig) and #[repr(C)] (Rust) give deterministic C layout
— but deterministic is not the one you sketched. Two things bit us:
- Alignment inserts padding back. Our first field order put a
u64at offset 52 (4-mod-8); repr(C)/extern padded it to 56 and the record grew to 72 bytes with padding we never asked for. The fix was arithmetic: reorder so every field sits naturally aligned —stamp@0, expiry@8, lock_id@16, lease_id@24, holder[16]@32, lease_ms@48, tag u32@56, checksum u32@60— and the 64-byte record has zero padding, struct alignment 8,64 % 8 == 0. A bonus: the header-ring record (32 B of identity + tag + checksum) rounds to exactly 40 bytes because the checksum lives inside the struct. - Nobody checks your layout for you. So the check is compile-time and
permanent: Zig
comptime { assert(@sizeOf(T) == 64); assert(@offsetOf(T, "field") == n); ... }and Rustconst _: () = assert!(size_of::<T>() == 64)+offset_of!per field. A layout drift is now a compile error on every build, not a mystery corruption in a month.
The "REPL" that keeps this honest is the build itself: comptime asserts in Zig and const asserts in Rust, both run by every compile.
Byte round trips (struct → &[u8] → struct) are only sound if the struct
has no padding — reading padding bytes is undefined behavior in Rust's
model, and garbage in C. A padding-free layout by construction (above)
plus the size/offset asserts is the whole defence; we deliberately did
NOT reach for bytemuck — one auditable unsafe as-bytes helper guarded
by the const asserts beats a dependency for something this small, and the
Zig side needs nothing beyond std.mem.asBytes/bytesToValue.
memcpy round trips bake the host byte order into the format. Both release targets (x86_64, aarch64) are little-endian, so the on-disk format is "LE, enforced by memcpy" — and a big-endian host fails the round trip loudly, which is the correct failure.
- Pointers only, never structs by value. By-value struct passing
differs across ABIs (x86_64 SysV classifies fields into registers;
aarch64 AAPCS64 and Windows have their own rules). Every exchange is
(ptr, len); nothing to misclassify. - Caller-owned memory, end to end. The engine allocates nothing, frees nothing, and touches only the buffers handed to it, for the duration of the call. There is no ownership ambiguity because there is no shared ownership.
- Unsigned-only fields in the wire format.
u32/u64everywhere; signed values appear only as the ABI's errno-style return codes. This kills the sign-extension confusion class before it exists.
A memcpy proves bytes move; it does not prove the other side reads them
the same way. The canary does. Rust fills the real record struct with
wide-bit unique sentinels (e.g. 0x7B1D_5EED_0000_00nn, spaced low bits)
and hands it across the ABI; Zig memcpys it through three caller-owned
slots, incrementing a bump field by one per copy with checked
increments; Rust reads the last slot and demands every bumped field ==
sentinel + 2 exactly, every other field byte-equal, slot 0 untouched.
What it catches: endian flips, offset drift, sign misinterpretation, a field read at the wrong width — all as a +1 that shows up as something else. What it deliberately does not do: touch a disk. Bytes bounced off a page cache are indistinguishable from bytes that never left RAM; the disk round trip is a later integration. The canary is the insurance against the worst debugging scenario: logging the struct inside the Zig engine six months from now and seeing total junk.
It earned its keep before it shipped: our first sentinel scheme had
stamp + 2 == lock_id — the canary's own distinctness assert caught the
collision, and the fix (spaced low bits) is now pinned by a test.
Footguns it pins down, all found the hard way in the literature:
- Zig unsigned overflow wraps silently in release (traps only in
debug) — checked adds (
std.math.add) plus comptime headroom (max(sentinel) + bumps < maxInt(T)), so no increment can wrap in any build mode. @memcpyrequires non-overlapping ranges — disjoint slots, asserted;copyForwardsis for overlap and is not for here.- By-value struct passing is ABI-variable — the pointer-only rule above.
- Padding reads are UB — the padding-free layout, asserted.
The record format, the ABI, the on-disk bytes and the logging story are one object. The steady tick submits a whole generation as two contiguous pwrites; an array of records is an array of cache lines; the future "slab of locks written as one memcpy" needs no new format — the 64-byte layout-exact record already is the slab element. The cost of all of it: the field order had to be designed for alignment, and a handful of asserts that run forever.