Skip to content

Two correctness fixes in setup: a range that overshoots and a leak - #19

Open
Roxxik wants to merge 2 commits into
mist64:masterfrom
Roxxik:correctness-fixes
Open

Roxxik wants to merge 2 commits into
mist64:masterfrom
Roxxik:correctness-fixes

Conversation

@Roxxik

@Roxxik Roxxik commented Jul 27, 2026 •

Copy link
Copy Markdown

Two unrelated defects in netlist_sim.c setup, found with valgrind while
benchmarking. Neither is observable in cbmbasic output.

A third turned up in the same pass, the uninitialised groupcount read. #17
already fixes it, so this PR does not touch it. The analysis is at the end.

The dependant ranges overshoot the data actually written

The offsets into dependent_block are prefix sums over the gate counts, so they
reserve one slot per gated transistor. add_nodes_dependant() then refuses to
insert a node twice and uses fewer, but nothing ever narrowed the ranges to match.

So nodes_dependant[i+1] and nodes_left_dependant[i+1] overshoot the entries
belonging to node i, and changeNodeValue() reads past the real data into
what the calloc left behind, which is zero, and so queues node 0 for
recalculation. On the shipped netlist that is 20 slots across 6 nodes in
nodes_dependant and 8 more across 5 nodes in nodes_left_dependant, 28 bogus
reads in all. It is harmless only by luck: recalculating node 0 is a no-op, so
the bogus work is invisible rather than wrong.

Fixed by compacting both lists once the fill is done. Entries only ever move
towards the front, so copying forward in place is safe.

nodes_dependant and nodes_left_dependant are never freed

setupNodesAndTransistors allocates them and destroyNodesAndTransistors does
not free them, leaking 6904 bytes per chip instance. Harmless for cbmbasic,
which builds one chip. Not harmless for measure, which rebuilds chip state
thousands of times via resetChip_test.

Verification

measure's 256-opcode characterisation is byte-identical. cbmbasic still
reaches READY. in 33155 half-cycles with the same final state.

Over repeated initAndResetChip/destroyChip cycles, valgrind's leak report
for three instances goes from 20,712 bytes definitely lost in 6 blocks to none.
It still reports the uninitialised read in group_clear(), which is the one
#17 fixes.

Not fixed here: groupcount is read before it is written (#17)

Left to #17, which was filed first. The analysis is here because the one-line
change buys more than it appears to.

state_t is malloc'd and groupcount is never assigned during setup, but
group_clear() reads it before writing it:

for (count_t i = 0; i < state->groupcount; i++)
        gb[grp[i]>>BITMAP_SHIFT] &= ~(ONE << (grp[i] & BITMAP_MASK));
state->groupcount = 0;

The very first call, reached from setNode() during initAndResetChip, walks a
garbage number of entries. Only the first, because it resets the count on the way
out, which is why this survives at all.

It is not merely a stale read. groupcount is a uint16_t, so the loop can run
to 65535 while group holds 1725 entries, reading roughly 127 KB past that
allocation. The values it picks up out there are then used to index
groupbitmap, which is 27 words, and the loop body writes to it, so a
garbage count can clear bits up to 8 KB beyond a 216-byte buffer. Both arrays are
calloc'd, so the in-bounds part of the walk is harmless; only the tail past the
end of group can do damage.

That matches the crashes reported in #17: whether it does damage depends on what
the allocator left behind, which is why it is intermittent. On this machine it
never crashed, and valgrind reports the read on an unmodified tree.

I used an LLM to help me out in this work, but I manually reviewed all changes made.

Roxxik added 2 commits July 27, 2026 20:47
setupNodesAndTransistors allocates these two arrays but
destroyNodesAndTransistors never freed them, leaking 6904 bytes per
chip instance. Harmless for cbmbasic, which builds one chip, but
measure rebuilds chip state thousands of times via resetChip_test.

Verified with valgrind over repeated initAndResetChip/destroyChip
cycles: previously "definitely lost: 20,712 bytes in 6 blocks" for
three instances, now "All heap blocks were freed -- no leaks are
possible". measure's 256-opcode output is byte-identical.
The offsets into dependent_block are prefix sums over the gate counts,
so they reserve one slot per gated transistor. add_nodes_dependant()
then refuses to insert a node twice and uses fewer, but nothing ever
narrowed the ranges to match.

So nodes_dependant[i+1] and nodes_left_dependant[i+1] overshoot the
entries belonging to node i, and changeNodeValue() reads past the real
data into whatever the calloc left there - zero, i.e. node 0 - and
queues it for recalculation. On the shipped netlist that is 20 slots
across 6 nodes in nodes_dependant and 8 more across 5 nodes in
nodes_left_dependant, 28 bogus reads in all. It is harmless only by
luck: recalculating node 0 is a no-op, so the bogus work is invisible
rather than wrong.

Compact both lists once the fill is done. Entries only ever move
towards the front, so copying forward in place is safe.

measure's 256-opcode characterisation is byte-identical, cbmbasic still
reaches READY. in 33155 half-cycles with the same end state, and
valgrind reports no errors.
@Roxxik
Roxxik force-pushed the correctness-fixes branch from 0dbef6c to 698338b Compare July 27, 2026 19:26
@Roxxik Roxxik changed the title Three correctness fixes in setup: an out-of-bounds access, a range that overshoots, and a leak Two correctness fixes in setup: a range that overshoots and a leak Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant