b+tree: three front-gap overruns — a node's entries do not start at zero - #169
Merged
psiha merged 1 commit intoSep 11, 2026
Merged
Conversation
All three are one mistake in three places: a path sizes its write from num_vals and then addresses through key_at, which is keys[ start + i ] - so a node carrying a front gap is written past the end of its array by exactly 'start'. Together they close the devector fault: bp_tree. nonunique and bp_tree.playground both go from SIGSEGV to passing. append_and_free moves the source's entries to key_at( target, num_vals ) under a bound that only covers num_vals. Witnessed with a trap on the invariant: start=29, num_vals=123, max_values=123 - 29 entries past the end. relieve_into_sibling computes both siblings' room as max - num_vals, while its give-to-LEFT branch receives at the sibling's TAIL, where a front gap supplies nothing. With max=123, num_vals=100 and start=20 it hands over 11 entries into 3 free slots. The give-to-RIGHT branch is already correct for a different reason: it receives at the FRONT, where the gap IS the supply, and spends it before shifting the deficit - so the two sides genuinely count room differently, and the comment now says so. bp_tree_impl::merge has it twice over: available_space is again max - num_vals, and the move_backward that opens the merge point targets tgt_keys[ num_vals + copy_size ], where tgt_keys is &keys[ start ]. Witnessed: start=1, num_vals=123, max_values=123. The new recentre() closes the gap. It is safe for inner nodes and cheap, because it preserves every logical index - entry i sits at start + i before and at i after - so a child's parent_child_idx still names the same child. Each fix is necessary and none is sufficient: nonunique needs the first two, playground needs the third as well. Established in isolated clean builds, which is the only way any of it was decidable - testing one candidate while another site was still corrupting made it look like a no-op, twice. Found by giving verify() a defaulted std::source_location so the first trap names its own caller. Worth keeping in mind for the next one of these: the gap-touching paths call verify_min_max(), which checks num_vals against min/max and cannot see start + num_vals at all, so a violation created in one of them surfaces far away at whatever reader trips over it next. Full suite: 2049 passed, 1 pre-existing skip, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
psiha
force-pushed
the
fix/160-append-and-free-overruns-the-front-gap
branch
from
September 11, 2026 17:48
2b21db6 to
961894a
Compare
Base automatically changed from
fix/linux-dependent-base-qualification
to
wip/devector-live
September 11, 2026 21:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On #167 (which is what makes
wip/devector-livebuildable off Windows at all).Closes #160's devector fault —
bp_tree.nonuniqueandbp_tree.playgroundbothgo from SIGSEGV to passing.
One mistake, three places
Each is: size the write from
num_vals, then address throughkey_at, which iskeys[ start + i ]— so a node carrying a front gap is written past the end of itsarray by exactly
start.append_and_freeWitnessed:
start=29, num_vals=123, max_values=123— 29 entries past the end.relieve_into_sibling, give-to-leftleft_room = max - p_left->num_valsignoresstart, while that branch receives atthe sibling's tail. With
max=123, num_vals=100, start=20it hands over 11entries into 3 free slots.
The give-to-right branch is already correct, for a different reason: it receives
at the front, where the gap is the supply, and it spends that before shifting
the deficit. So the two sides genuinely count room differently — the comment now says
so rather than leaving it to be rediscovered.
bp_tree_impl::merge— twice overavailable_spaceismax - num_valsagain, and themove_backwardthat opensthe merge point targets
tgt_keys[ num_vals + copy_size ]wheretgt_keysis&keys[ start ]. Witnessed:start=1, num_vals=123, max_values=123.recentre()Closes the gap. Safe for inner nodes and cheap, because it preserves every logical
index — entry
isits atstart + ibefore and atiafter — so a child'sparent_child_idxstill names the same child. (Contrastopen_slot_from_front, whichis leaves-only for the opposite reason.)
Each necessary, none sufficient
append_and_freeonlyleft_roommergeEstablished in isolated clean builds, which is the only way it was decidable. Worth
recording why: testing one candidate while another site was still corrupting made it
look like a no-op — I wrote
left_roomoff as refuted on exactly that basis — andonce a
build EXIT=1left a stale binary in place, a passing run looked like a fix.How it was found, and the suggestion that falls out
Giving
verify()a defaultedstd::source_locationso the first trap names its owncaller. That, plus trapping on
start + num_vals > max_values, took it from "somewherein the bulk lanes" (the WIP probe commit's reading, which none of 14 placed probes
supported) to three named sites.
⭐ The reason it was hard: the gap-touching paths call
verify_min_max(), whichchecks
num_valsagainst min/max and cannot seestart + num_valsat all. Aviolation created in one of them therefore surfaces far away, at whatever reader trips
over it next —
keys(),insert's entry check, orupdate_separator. Teaching thatcheck the addressable-range invariant would localize this whole class at creation, and
is worth doing independently of these fixes.
Gate
Full suite, Release, Linux/macOS clang: 2049 passed / 1 pre-existing skip / 0 failed.
🤖 Generated with Claude Code