Skip to content

Commit 6e4b81c

Browse files
ganehagclaude
andcommitted
fix: core.clone wrote through stale captured slices across a relocating allocation, risking memory corruption
Found while continuing the gc-rooting-hardening audit (5d38c29 and earlier on main). This is a write-side sibling of the read-side stale-slice class the 2026-07-25 sweep and 5d38c29 both targeted, and more serious: a stale *read* returns a wrong value; a stale *write* lands in whatever memory now occupies the old, relocated-away address, corrupting an unrelated live allocation. cloneObject's array_managed and struct_instance branches populate a freshly-allocated destination slice in a loop whose body (cloneValue) recurses arbitrarily and can allocate — which can, rarely, trigger compactManagedHeap and relocate the destination's own just-allocated backing block. Both branches wrote through a local slice variable captured once before the loop (`out[i] = ...`, `fields[i].value = ...`) instead of through the owning Object's own field, which is the only thing compaction's relocation bookkeeping (heap.zig's compactUpdateObj) actually keeps current. struct_instance also read its *source* fields through a similarly stale captured local. Fixed both to read and write through src/out_obj's own fields, re-derived fresh on every iteration — matching the pattern array.zig's itemAt and TempRootedManagedMap.set already use correctly. variant_value's shared_values/arm_fields had a deeper version of the same bug: it used a "publish empty, grow visible by one each iteration" scheme to keep the GC marker from tracing uninitialized memory, writing through a captured local exactly like the above. But compactManagedHeap sizes the block it relocates from the *currently visible* length (heap.zig's compactFillBlocks reads the object's own field length at scan time) — so a compaction mid-loop would only preserve the already-grown prefix, permanently losing the reserved but not-yet-written tail of the original allocation, on top of the same write-through-stale-local hazard. Fixed by adopting the safer pattern array_managed/struct_instance already use: pre-fill the whole destination with .null and publish the full length immediately (every slot is then always a valid, traceable Value, so no incremental visibility is needed), then read/write through out_obj's own field per iteration like the other branches. map's clone branch was already correct — TempRootedManagedMap.set() re-derives self.obj.map[i] fresh on every call. Verified: existing conformance tests 165_clone_variant_shared.gengo and 202_clone_variant_gc_window.gengo (the latter built specifically to stress this exact loop with GC firing mid-fill) both still pass via the rebuilt CLI. zig build compiler-test (native Debug, -Dgc_stress=true, -Dheap_paranoia=true) all 993/993. heap-test and chaos-spec-test pass. No dedicated fragmentation-triggering repro was engineered for the compaction-specific scenario (rare to hit deliberately; same tradeoff as validateNamedCollectionElements in 5d38c29) — CI's gc-stress-test job runs the full tests/spec/*.gengo conformance suite, including both tests above, under -Dgc_stress=true already. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3pMYWS2xy8zitUkpJaiZU
1 parent 5d38c29 commit 6e4b81c

1 file changed

Lines changed: 57 additions & 19 deletions

File tree

src/lang/native/core.zig

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -735,13 +735,21 @@ fn cloneObject(ctx: VMContext, src: *Object, visits: []CloneVisit, visit_len: *u
735735
const out = try vmgc.vmAllocManagedSlice(ctx, Value, items_len);
736736
for (out) |*slot| slot.* = .null;
737737
out_obj.* = .{ .array_managed = out[0..items_len] };
738-
// Re-derive src's slice fresh each iteration: cloneValue
739-
// recurses and can allocate, so a slice captured once before
740-
// (or even at the top of) this loop can go stale partway
741-
// through it.
738+
// Re-derive both src's AND out_obj's slices fresh each
739+
// iteration, and only through out_obj's own field (never the
740+
// captured `out` local): cloneValue recurses and can allocate,
741+
// which can trigger compactManagedHeap and relocate either
742+
// block. src's read side going stale is a wrong-value read;
743+
// out_obj's write side going stale (writing through the
744+
// captured `out` after ITS backing moved) is worse — a write
745+
// into freed/reused memory, corrupting whatever now lives
746+
// there. compactUpdateObj keeps out_obj.array_managed itself
747+
// correct at every step, which is why the write below goes
748+
// through it instead of `out`.
742749
for (0..items_len) |i| {
743750
const item = (try vms.asArraySlice(src))[i];
744-
out[i] = try cloneValue(ctx, item, visits, visit_len);
751+
const cloned = try cloneValue(ctx, item, visits, visit_len);
752+
out_obj.array_managed[i] = cloned;
745753
}
746754
return .{ .object = out_obj };
747755
},
@@ -789,12 +797,19 @@ fn cloneObject(ctx: VMContext, src: *Object, visits: []CloneVisit, visit_len: *u
789797
const out_obj = try vmgc.allocTempRooted(ctx, .{ .array = &[_]Value{} });
790798
defer ctx.vs.popTempRoot();
791799
try cloneRemember(src, out_obj, visits, visit_len);
792-
const fields = try vmgc.vmAllocManagedSlice(ctx, MapEntry, inst.fields.len);
800+
const field_count = inst.fields.len;
801+
const fields = try vmgc.vmAllocManagedSlice(ctx, MapEntry, field_count);
793802
for (fields) |*slot| slot.* = .{ .key = .null, .value = .null };
794803
out_obj.* = .{ .struct_instance = .{ .typ = inst.typ, .fields = fields } };
795-
for (inst.fields, 0..) |field, i| {
796-
fields[i].key = field.key;
797-
fields[i].value = try cloneValue(ctx, field.value, visits, visit_len);
804+
// Re-derive both src's AND out_obj's fields fresh each
805+
// iteration, and only through out_obj's own field for the
806+
// write side — same reasoning as the array_managed branch
807+
// above (`inst`/`fields` are stale-capture hazards; cloneValue
808+
// can allocate and trigger compactManagedHeap).
809+
for (0..field_count) |i| {
810+
const field = src.struct_instance.fields[i];
811+
const cloned = try cloneValue(ctx, field.value, visits, visit_len);
812+
out_obj.struct_instance.fields[i] = .{ .key = field.key, .value = cloned };
798813
}
799814
return .{ .object = out_obj };
800815
},
@@ -814,17 +829,40 @@ fn cloneObject(ctx: VMContext, src: *Object, visits: []CloneVisit, visit_len: *u
814829
try ctx.vs.pushTempRoot(.{ .object = out_obj });
815830
defer ctx.vs.popTempRoot();
816831
try cloneRemember(src, out_obj, visits, visit_len);
817-
var shared = try vmgc.vmAllocManagedSlice(ctx, Value, vv.shared_values.len);
818-
out_obj.variant_value.shared_values = shared[0..0]; // publish immediately
819-
for (vv.shared_values, 0..) |sv, i| {
820-
shared[i] = try cloneValue(ctx, sv, visits, visit_len);
821-
out_obj.variant_value.shared_values = shared[0 .. i + 1]; // grow visible
832+
// GC-audit 2026-09 (project_gc_rooting_hardening): this used to
833+
// publish an empty slice and "grow visible" by one element per
834+
// iteration, to keep the marker from tracing past-the-end
835+
// uninitialized memory. That's unsafe for a different reason:
836+
// compactManagedHeap sizes the block it relocates from the
837+
// *currently visible* length (see heap.zig's compactFillBlocks),
838+
// so a compaction mid-loop would only preserve the
839+
// already-grown prefix — the reserved-but-not-yet-written tail
840+
// of the original allocation is silently lost, and writing into
841+
// it afterward (which the old code also did through a captured
842+
// local slice, a second, independent hazard) would land in
843+
// freed/reused memory. Pre-fill with .null and publish the full
844+
// length immediately instead, matching the array_managed/
845+
// struct_instance branches above: every slot is always a valid,
846+
// traceable Value, so no incremental visibility is needed, and
847+
// writes go through out_obj's own (always-current) field rather
848+
// than a captured local.
849+
const shared_len = vv.shared_values.len;
850+
const shared = try vmgc.vmAllocManagedSlice(ctx, Value, shared_len);
851+
for (shared) |*slot| slot.* = .null;
852+
out_obj.variant_value.shared_values = shared[0..shared_len];
853+
for (0..shared_len) |i| {
854+
const sv = src.variant_value.shared_values[i];
855+
const cloned = try cloneValue(ctx, sv, visits, visit_len);
856+
out_obj.variant_value.shared_values[i] = cloned;
822857
}
823-
var arm = try vmgc.vmAllocManagedSlice(ctx, Value, vv.arm_fields.len);
824-
out_obj.variant_value.arm_fields = arm[0..0]; // publish immediately
825-
for (vv.arm_fields, 0..) |af, i| {
826-
arm[i] = try cloneValue(ctx, af, visits, visit_len);
827-
out_obj.variant_value.arm_fields = arm[0 .. i + 1]; // grow visible
858+
const arm_len = vv.arm_fields.len;
859+
const arm = try vmgc.vmAllocManagedSlice(ctx, Value, arm_len);
860+
for (arm) |*slot| slot.* = .null;
861+
out_obj.variant_value.arm_fields = arm[0..arm_len];
862+
for (0..arm_len) |i| {
863+
const af = src.variant_value.arm_fields[i];
864+
const cloned = try cloneValue(ctx, af, visits, visit_len);
865+
out_obj.variant_value.arm_fields[i] = cloned;
828866
}
829867
out_obj.variant_value.payload = try cloneValue(ctx, vv.payload, visits, visit_len);
830868
return .{ .object = out_obj };

0 commit comments

Comments
 (0)