Skip to content

Instantly share code, notes, and snippets.

@simbo1905
Created October 12, 2026 12:55
Show Gist options
  • Select an option

  • Save simbo1905/dc52f607d4e0c1dbff1d898ff97bc9ed to your computer and use it in GitHub Desktop.

Select an option

Save simbo1905/dc52f607d4e0c1dbff1d898ff97bc9ed to your computer and use it in GitHub Desktop.
The copy canary: proving a cross-language memcpy ABI is what you think it is (Zig extern struct / Rust repr(C) / C ABI / disk bytes — one 64-byte object)

The copy canary: proving a cross-language memcpy ABI is what you think it is

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

1. The layout has to be earned, not hoped for

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 u64 at 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 Rust const _: () = 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.

2. Reading padding bytes is the soundness cliff

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.

3. Endianness is now a property, not a prayer

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.

4. The C ABI rules that keep it true

  • 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/u64 everywhere; signed values appear only as the ABI's errno-style return codes. This kills the sign-extension confusion class before it exists.

5. The canary: prove interpretation, not just transport

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:

  1. 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.
  2. @memcpy requires non-overlapping ranges — disjoint slots, asserted; copyForwards is for overlap and is not for here.
  3. By-value struct passing is ABI-variable — the pointer-only rule above.
  4. Padding reads are UB — the padding-free layout, asserted.

6. What it bought

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment