Conversation
This branch has not been deployed
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.
libdict — Code Review
Date: 2026-09-08
Revision reviewed:
15e8303(branchmaster, clean tree)Scope: all of
src/(8 containers + shared code),include/,demo.c,anagram.c,benchmark.c,unit_tests.c,util.h, build files,README.md,TODO— ~8,600 lines.Method: six independent line-by-line reviews (one per file group), each cross-checked against the shared
tree_common.c/dict_private.hcontracts; plus empirical verification with:gcc -Wall -Wextra -Wshadow -Wcast-qual -Wstrict-prototypes -Wmissing-prototypes -Wsign-compare -Wpointer-arithsearch_le|lt|ge|gt/ iterator-remove / traverse (incl. early stop) /nextn/prevn/itor_compare/ forward+reverse iteration /selectat every rank /clear+reuse /free, under ASan+UBSan+LSan across all 14 type configurations and many seedsFindings are marked CONFIRMED where a repro was executed, CODE-GROUNDED where the defect is evident from the source but no repro was run, and UNCONFIRMED where a repro attempt failed.
STATUS: all findings addressed
Every finding in this document has been fixed in the working tree, except §9, which could not be reproduced and was therefore left alone rather than "fixed" speculatively.
This document is kept as the record of what was found and why each change was made. It describes the code as reviewed, not as it now stands — the line numbers refer to the pre-fix revision
15e8303.Two defects of the same class were found while fixing and are also resolved:
sp_tree_verify/tr_tree_verifyas well, not just the path-length helpers. Confirmed:dict_verifyon a 500,000-node degenerate splay tree segfaulted. Both are now iterative (tree_verify_common).dict_key_functo returnconst void*(§4.3) exposed two callers that were silently discardingconston a live key (demo.c,unit_tests.c).Verification of the fixed tree: library and programs compile clean under
-Wall -Wextra -Wshadow -Wcast-qual -Wstrict-prototypes -Wmissing-prototypes -Wsign-compare -Wpointer-arith; the test suite passes (20 tests, ~7.46M assertions, 0 failures) under ASan + UBSan + LeakSanitizer and under-DNDEBUG -O3; the randomized differential stress harness passes across all 14 container configurations and many seeds. Purpose-built regression checks confirm that each reproduced crash is gone and that the repaired verifiers now reject corruption they previously accepted.Baseline health
The library itself compiles clean under all the warning flags above, with a single exception (
dict_private.h:105, a non-prototype declaration). The CUnit suite passes clean under ASan/UBSan/LSan.demo.c,anagram.candbenchmark.ccompile clean under-Wall -Wextra -Wformat=2.⚠ Context that amplifies everything below
.github/workflows/release.ymlandpackage.ymlbuild withCMAKE_BUILD_TYPE=Release, which makes CMake defineNDEBUG.src/dict_private.h:61degradesASSERT(expr)to(void)(expr):Every
ASSERT-only guard in this library is therefore inert in the shipped packages. Onlytest.ymlusesDebug. Several findings below (5, and the alignment preconditions in §5) are held together solely by anASSERT.1. Crashes (all reproduced)
1.1
dict_traverse()on anyhb_treesegfaults — two keys is enoughsrc/hb_tree.c:463· CONFIRMED · severity: criticalhb_nodepacks the AVL balance factor into the low 2 bits of the parent pointer:Every parent access must go through
PARENT(). Buthb_tree_traversedelegates straight to the generic routine:tree_traverse(src/tree_common.c:252) walks withtree_node_next, which readsnode->parentraw and immediately dereferences it (src/tree_common.c:97-98). Any node with a nonzero balance yields a misaligned bogus pointer.Repro:
verify=1, count=2immediately beforehand — the tree itself is fine. A perfectly balanced 3-node tree survives (all balances 0), which is why this was never noticed. It is reachable through the public interface: thehb_tree_vtabletraverseslot (src/hb_tree.c:71) ishb_tree_traverse.rb_treeuses the identical tagging scheme and correctly supplies its ownrb_tree_traverse(src/rb_tree.c:385).hb_treeoverridesclear,selectanditor_next/prev/nextn/prevn—traverseis the single one that was missed.Fix: implement
hb_tree_traversewith hb's existingnode_next(src/hb_tree.c:531), exactly asrb_tree_traversedoes.Punning audit (the reason this is the only leak-through): every other
tree_commonentry point reachable fromhb_tree.candrb_tree.c—tree_search,tree_search_{le,lt,ge,gt}[_node],tree_node_min/max,tree_count,tree_{min,max,total}_path_length,tree_iterator_{valid,invalidate,free,first,last,search*,key,datum,compare}— touches onlykey/datum/llink/rlink/root/count/cmp_func, which are value-compatible. The routines that readparent(tree_traverse,tree_select,tree_clear/tree_free,tree_iterator_next/prev/nextn/prevn,tree_node_rot_left/right) are correctly re-implemented locally by both trees — except this one.sp_,pr_,tr_andwb_all begin withTREE_NODE_FIELDSand are genuinely compatible.1.2
dict_select(dct, n, NULL, &datum)segfaults on sp_tree and tr_treesrc/tree_common.c:274-276, 289-290· CONFIRMED · severity: hightree_selectwrites both out-parameters unconditionally:hb_tree_select,rb_tree_select,pr_tree_selectandwb_tree_selectall guard withif (key)/if (datum).tree_selectis the vtableselectfor sp_tree (src/sp_tree.c:79) and tr_tree (src/tr_tree.c:77).So the same public API call is safe on four of six sorted dicts and fatal on two. The
n >= countpath (line 274) crashes the same way on an empty sp_tree/tr_tree. Neithertree_common.h:100-103nordict.hdocuments a non-NULL requirement.Fix: add the
if (key)/if (datum)guards totree_select, matching the four per-tree implementations, and document the contract.1.3
dict_itor_compare()on any hashtable calls a NULL function pointerinclude/dict.h:195· CONFIRMED · severity: highdict_itor_removeanddict_itor_search_le/lt/ge/gtall short-circuit on an unpopulated slot:Both hash tables leave that slot NULL:
unit_tests.c:480masks this by only comparing whendict_is_sorted(dct).Fix: guard the macro like its siblings, or implement
hashtable{,2}_itor_compare.1.4
demo.ccrashes on an argument-lesssearchdemo.c:123, 133, 147, 161, 175· CONFIRMED · severity: medium (example program)Each of
search/searchle/searchlt/searchge/searchgtrejects a third token but never tests for a missing key:Only
removegets it right (demo.c:189:if (!ptr || ptr2)).Fix:
if (!ptr || ptr2)in all five branches.2. Silent data corruption (all reproduced)
2.1
skiplist_dict_new(cmp, 1)loses every element and leaks them allsrc/skiplist.c:685· CONFIRMED · severity: criticalWith
max_link == 1,count >= 1is always true, so the result is alwaysmax_link - 1 == 0.node_new(key, 0)produces a zero-link node (itsASSERT(link_count >= 1)is compiled out underNDEBUG), andnode_insert's linking loop never executes:skiplist_new(src/skiplist.c:114) only assertsmax_link > 0, so 1 is an accepted public API value.Repro (
-DNDEBUG -fsanitize=address, 4 inserts):Every element is invisible:
countreports 4, everysearchreturns NULL,verifyfails, andclear/freewalkhead->link[0] == NULLand free nothing.max_link == 0is worse:skiplist_newallocates a head node with zero links and the first insert reads out of bounds (heap-buffer-overflow, READ of size 8 immediately past a 32-byte region). Both stem from the same root cause —skiplist_newclamps the upper bound (> MAX_LINK) but never enforces a usable lower bound.Fix: reject
max_link < 2at the API boundary, and make the clampMIN(count, max_link - 1)with a floor of 1.2.2
wb_treerebalancing breaks above ~5M elements (32-bit weight overflow)src/wb_tree.c:196(also162-163,167,200,402-403) · CONFIRMED · severity: highweightisuint32_t, and the balance tests compute the products in 32 bits:n->weight * 707Uwraps oncen->weight >= 6,075,626;weight * 1000Uwraps at>= 4,294,968. When the right-hand side wraps to a small value the "left subtree far too heavy" branch fires spuriously andfixupperforms an unjustified rotation at that node on every insert.tree->countissize_t, so these sizes are well within documented capacity.Repro (sequential
wb_tree_insert(0..N-1)):At root weight 7,000,001:
7000001 * 707 = 4,949,000,707wraps to654,033,411, whilelweight * 1000 ≈ 3.5e9— so the branch fires at the root on every insert.Fix: cast the products to
uint64_t. Thenode_verifychecks at402-403overflow identically, so the verifier is equally unreliable at that scale and must be fixed too.2.3
hb_itor_search()is a lower-bound search, not an exact searchsrc/hb_tree.c:646· CONFIRMED · severity: mediumThe two lines are identical — that's the tell. Every sibling uses exact
tree_iterator_search(wb_tree.c:458,rb_tree.c:628,sp_tree.c:477,pr_tree.c:525,tr_tree.c:315), and so does hb's own vtable slot (src/hb_tree.c:89), so the two disagree on the same iterator.Repro, tree containing {1, 3}:
Caller code that treats
trueas "the key exists" silently operates on the wrong element.unit_tests.conly ever exercisesdict_itor_search.Fix:
return tree_iterator_search(itor, key);3. Verifiers that do not check what they claim
These matter disproportionately: they are the safety net for §1 and §2, and
dict_verifyis what the test suite leans on.3.1
rb_tree_verifydoes not check the black-height invariantsrc/rb_tree.c:533-536,:541· CODE-GROUNDED (repro reported by reviewer) · severity: highThe comparison runs only at nodes with no children. Every root-to-NIL path that terminates at the missing child of a one-child node is never validated.
Counterexample: insert 10, then 5 → root 10 black, left child 5 red,
10->rlink == NULL. Flip node 5 to black. Path10→NULLhas 1 black node, path10→5→NULLhas 2 — genuinely invalid — yetrb_tree_verify()returns true.Fix: perform the black-count comparison in the
node == NULLcase, i.e. treat NIL as the leaf.3.2 No tree verifies BST ordering globally
src/rb_tree.c:518-521,src/hb_tree.c:549-553,src/wb_tree.c:389-392· CODE-GROUNDED · severity: mediumAll three compare each node only against its immediate parent:
Local parent-child ordering does not imply a valid search tree. Counterexample all three accept: root 5,
5->llink= 3,3->rlink= 7. Both checks pass (cmp(5,3) > 0✓,cmp(3,7) < 0✓) — yet key 7 sits in the root's left subtree, sotree_search(7)descends right from the root and returns NULL. A rotation bug that misplaces a whole subtree would pass verification.Fix: thread
(lower, upper)key bounds through the recursion.3.3
skiplist_verify— wrong comparison, and two missing checkssrc/skiplist.c:470-474· CODE-GROUNDED · severity: mediumA node occupying level
kmust have at leastk+1links, i.e.link_count > k. As written the check is a tautology atk == 0and accepts exactly the corruption that would tripskiplist_remove'sASSERT(update[k]->link_count > k).It also never checks
list->countagainst the number of nodes reachable vialink[0]— which is precisely finding 2.1's symptom — and never callslist->cmp_funcat all, so it cannot detect a mis-ordered list.3.4 Other verifier gaps
src/hb_tree.c:550usesASSERT(parent->rlink == node)where the wb equivalent (src/wb_tree.c:383) is a properVERIFY(parent->llink == node || parent->rlink == node). UnderNDEBUGthis silently accepts a node whose claimed parent has no link back to it — only the child→parent direction (PARENT(node) == parent, line 566) is checked.rb_tree_verifyandpr_tree_verifynever checktree->countagainst the actual node count; they only distinguishcount > 0fromcount == 0.pr_treecould do it cheaply:root->weight == count + 1.src/hb_tree.c:586-588—hb_tree_verifyrunsVERIFY(tree->count == count)unconditionally afternode_verifybailed out, so a structural failure is followed by a spurious second count-mismatch message on stderr.4. Undefined behaviour
4.1
__builtin_ctz(dict_rand())is UB whenrandom()returns 0src/skiplist.c:684,src/dict_private.h:105· CODE-GROUNDEDdict_rand()is(unsigned) random(), whose range includes 0, and__builtin_ctz(0)is documented-undefined in both GCC and Clang. On x86-64 it compiles to a barebsf, whose destination register is left unmodified when the source is zero — solink_countbecomes whatever stale value the register held. Themax_link - 1clamp on the next line contains the damage (no OOB), so the observable effect is a silently corrupted level distribution rather than a crash.tzcntreturns 32 instead, so codegen changes behaviour. Probability ~2⁻³¹ per insert.Also:
__builtin_ctzhas no non-GCC/Clang fallback in this file, unlike the guarded macros indict_private.h.4.2 Indeterminate pointer value read after
FREEsrc/tree_common.c:313-315,src/rb_tree.c:160-162,src/hb_tree.c:154-155and:376/385,src/sp_tree.c:364-368· CODE-GROUNDEDnodeis compared after its lifetime has ended. Per C11 6.2.4p2 the pointer's value becomes indeterminate on deallocation, and this is exactly the pattern a compiler is entitled to fold. No freed memory is dereferenced, so ASan/UBSan report nothing today — it is a latent miscompilation hazard, not a live bug.src/wb_tree.c:294-299andsrc/tr_tree.c:197-203get the ordering right (splice first,FREElast). Fix: hoist the comparison above theFREEin the other five sites.4.3 Calls through incompatible function-pointer types
include/dict.h:116, 124, 128· CODE-GROUNDEDdict_key_funcvoid* (*)(void*)const void* f(const T*)dict_icompare_funcint (*)(void*, void*)int f(const T*, const T*)dict_valid_funcbool (*)(const void*)void*andconst void*are not compatible types, so calling through the cast pointer is UB (C11 6.5.2.2p9) and traps underclang -fsanitize=functionor CFI — everydict_itor_key()anddict_itor_compare()call would abort. The API also silently launders awayconst, handing callers a writable pointer to a live key. This is pervasive across all six tree vtables.Fix: make the typedefs match the real signatures — which is the intended contract anyway.
4.4
charinto<ctype.h>, and an out-of-bounds array writeanagram.c:36, 45;demo.c:99· CODE-GROUNDED · severity: high for anagramWith
*pa byte ≥ 0x80 (any UTF-8 or Latin-1 input file),charsign-extends to a negativeint. That is UB in the ctype call and an out-of-bounds write atfreq[-61]etc. — stack corruption on a non-ASCII dictionary file.isupper(buf[0])(line 36) andisspace(*p)(demo.c:99) have the read-side version.Fix: cast to
unsigned charat every ctype call site.4.5 Division by zero
benchmark.c:240· CODE-GROUNDEDA blank line in the input becomes
""afterstrtok(buf, "\n")(benchmark.c:107) → SIGFPE. Reachable with any word list containing a trailing or embedded blank line.4.6
%uprintingsize_tbenchmark.c:166, 183, 201, 290· CODE-GROUNDEDn(line 164) andnwords(line 95) aresize_t; on LP64 this is a varargs type mismatch.quit("bad count (%u - should be %u)!", n, nwords)prints nonsense exactly when the diagnostic matters. Lines 125 and 265 use%dforunsigned.4.7
strcpywith overlapping source and destinationdemo.c:101-103· CODE-GROUNDEDstrcpyexplicitly forbids overlap;memmoveis required. With a vectorized libcstrcpy, a line like" insert a 1"can produce a corrupted command string.4.8
dict_str_cmpcompares as signedcharsrc/dict.c:85-87· CODE-GROUNDEDstrcmpis specified overunsigned char. On x86 the byte0xFFcompares as-1. Notedict_str_hashtwo lines below deliberately usesconst uint8_t*, so the two functions disagree about byte signedness. Consequence: ordering of non-ASCII keys does not matchstrcmp, and it flips between x86 (signedchar) and ARM/PowerPC (unsignedchar) — sodict_search_ge/_leand iteration order differ across targets for identical inputs.Fix:
unsigned char p = *a++, q = *b++;4.9 Unbounded recursion in the path-length helpers
src/tree_common.c:331-337, 346-352, 361-367· CODE-GROUNDEDnode_min_path_length,node_max_path_lengthandnode_path_lengthrecurse once per level with no depth limit, while everything else in the file (notablytree_clear) is carefully iterative. Atr_treewith a constantdict_prio_funcdegenerates to a linked list; ~500k keys then overflows an 8 MB stack. Impact confined to these three diagnostic functions (used bybenchmark.c:144-146).4.10
dict_ptr_cmprelational-compares unrelated pointerssrc/dict.c:75· CODE-GROUNDED · pedantic(k1 > k2) - (k1 < k2)on pointers into different objects is UB per C11 6.5.8p5 (only==/!=are defined). Casting both touintptr_tfirst makes it well-defined.4.11
util.hproblemsutil.h:10-23· CODE-GROUNDEDshuffle,is_prime,next_permutationare non-static, non-inlinefunction definitions in a header → duplicate-symbol link error from two translation units. The header also usesbool,size_tandrand()while including only<assert.h>.shuffle()underflows onsize == 0—i < size - 1becomesi < SIZE_MAX, thenrand() % (0 - i)and wild writes.benchmark.cis safe only via itsif (!nwords) quit(...)at line 100.if (n <= 0)on anunsignedis tautological, andis_prime(1)returnstrue.unit_tests.c:793-798tests 2..7 and never 1.4.12 Falling off the end of a non-
voidfunctionbenchmark.c:354-356· CODE-GROUNDEDThe
default:case relies onquit()beingNORETURN, but line 27 guardsNORETURNwith#ifdef __GNUC__only. On any other compiler it expands to nothing and control reaches the closing brace of adict*-returning function.demo.c:20guards correctly (__GNUC__ || __clang__).5. Iterator invalidation — undocumented, unprotected
5.1
hashtable: remove / clear / free dangle live iteratorssrc/hashtable.c:228, 251-267· CODE-GROUNDED · severity: highstruct hashtable_itorstores a rawhash_node* node.remove_nodecallsFREE(node)andhashtable_clearfrees every node, with nothing tracking or invalidating iterators.5.2
hashtable: resize silently corrupts in-flight iterationsrc/hashtable.c:167, 318-341· CODE-GROUNDED · severity: highhashtable_resizekeeps the node allocations (so no crash) but re-links every node into the new table atnode->hash % new_sizeand replacestable->table. The iterator caches{node, slot}andhashtable_itor_nextresumes scanning from the staleslotin the new table — so some elements are visited twice and others never. The resize happens insidehashtable_insert, so any insert during iteration hits this.hashtable_itor_prev(line 441) likewise.Note
src/hashtable.c:167also ignores thehashtable_resizereturn value, wheresrc/hashtable2.c:191checks it.5.3
hashtable2_itor_validaborts on a removed elementsrc/hashtable2.c:451· CODE-GROUNDED · severity: mediumThe iterator stores only an
int slot, andhashtable2_removeboth empties slots and — viaremove_cleanup— relocates other elements to different slots. After removing the element under the iterator,hashtable2_itor_validabort()s in a debug build; underNDEBUGit returnstrueanditor_keyhands back the stale/NULL key of an empty slot, or the key of a different element that was shifted in.Related:
remove_cleanupmoves elements to earlier slots, so a forward iterator at slotkhas elements moved from> kto< kbehind it — skipping and double-visiting.hashtable2_itor_remove(line 567) sidesteps this only by settingslot = -1, which makes remove-while-iterating impossible at all.5.4 All trees except tr_tree: removing key K can free the node holding
successor(K)src/hb_tree.c:369,src/wb_tree.c:288,src/rb_tree.c:276,src/pr_tree.c:303,src/sp_tree.c:353-360· CODE-GROUNDED · severity: mediumIn the two-child case,
out = tree_node_min(node->rlink), the key/datum are swapped intonode, andout— the node object other iterators may be positioned on — is freed. An iterator parked on the successor is left dangling even though its key was never removed, anditor_validstill returnstrue(itsnodepointer is non-NULL).tr_treeis immune because it rotates the node down and frees the node itself.Fix: at minimum document "remove invalidates iterators positioned at the removed key's successor" — no header says so today.
5.5 Pointer-tagging alignment is guarded only by
ASSERTsrc/hb_tree.c:504,src/rb_tree.c:458· CODE-GROUNDEDThis is the sole guard that the tagged-pointer scheme is safe, and
dict_malloc_funcis user-replaceable (src/dict_private.h:88). AnNDEBUGbuild with a custom allocator returning under-aligned blocks silently corrupts every parent pointer in the tree, with no diagnostic — a hard-to-diagnose variant of finding 1.1. There is also no_Static_assertthatsizeof(intptr_t) == sizeof(void*)or on the field offsets; on a platform where they differ, thellink/rlinkoffsets shift and even plaintree_searchreturns garbage.Fix: static assertions on the node field offsets, plus an unconditional (non-
ASSERT) alignment check, plus a comment stating the "no generic routine may touchparent" contract.6. Memory leaks in the example programs
anagram.c:62rb_tree_insertreturns the existing node, so the just-allocatedxstrdup(name)is never stored and never freed.anagram.c:92rb_tree_free(tree, NULL)— NULL delete func, so even the keys the tree does own leak. ~1 MB+ against/usr/share/dict/words.anagram.c:9, 60-64xmallocbut never installs it asdict_malloc_func, so library allocations go unchecked; on failurerb_tree_insertreturns{NULL,false}and line 63 dereferences NULL. (demo.c:48andbenchmark.c:77do install it.)benchmark.c:263-268, 292result.key/result.datum, sodict_free(dct, key_str_free)runs on an empty dict andkey_str_freeis never invoked. The whole input file leaks every run.FREE(words)frees only the pointer array.demo.c:113-121dict_insert(dct, xstrdup(ptr))— ownership transfers only whenresult.inserted; theelsebranch drops the allocation, leaking on every duplicate insert.demo.c:117, 120*result.datum_ptrdereferenced without a NULL check.hashtable_insertreturns{NULL,false}on allocation failure (src/hashtable.c:183), so!inserteddoes not imply a validdatum_ptr.dict.hdocuments none of this.benchmark.c:306FREE(words)releasesxmalloc'd memory through the library'sdict_free_func— works only becausedict_free_funcstill defaults tofreewhiledict_malloc_funcwas replaced. Becomes a real wrong-allocator free the momentdict_free_funcis set.Note: every
MALLOCinsidesrc/is correctly NULL-checked, and thedict/dict_itorwrappers free the outer object on inner-allocation failure. The leaks are confined to the example programs.7. Test-suite gaps that let these ship
unit_tests.cnever callsdict_traverse. That alone is why finding 1.1 — a segfault on two keys through the public API — survived. Zero occurrences, verified by grep.Also entirely untested:
dict_itor_nextn/dict_itor_prevn(theirwhile (count--)implementations have distinct early-return semantics fromnext),dict_itor_invalidate,dict_int_cmp,dict_uint_cmp,dict_long_cmp,dict_ulong_cmp,dict_ptr_cmp,dict_str_hash. Anddict_free/dict_clearare only ever called with a NULLdelete_func(lines 616, 667), so the delete-callback plumbing — the main source of user-facing double frees — is never exercised.Structural weaknesses:
unit_tests.c:315—if (nkeys < NUM_SORTED_KEYS) continue;disables the entirele/lt/ge/gtassertion block.test_basicruns forn = 0..38, so 38 of 39 invocations exercise none of the near-search assertions — exactly the small and one-element cases where boundary bugs live.unit_tests.c:279, 318, 323, 338, 343, 358, 363, 378, 383— assertions wrapped inif (dct->_vtable->select)/if (itor->_vtable->search_le). The suite can never fail for a regression that NULLs outselect,search_le,search_lt,search_georsearch_gton a sorted container, and there is no complementary assertion that sorted containers must provide them.unit_tests.c:306—CU_ASSERT_FALSE(dict_select(dct, i, &key, &datum))sits insideif (!dict_is_sorted(dct)), where_vtable->selectis NULL anddict_selectexpands to(vtable->select && ...)— a compile-time-constantfalse. It can never fail. Lines 307-308 then re-test the local initializers from lines 304-305, not library behaviour.unit_tests.c:296-303— eight vtable assertions re-evaluated once perclosest_lookup_infoinside the loop. They assert properties that cannot change between iterations; they inflate the reported assert count (6,430,319) without adding coverage.unit_tests.c:81-94— the custom allocator usesassert(p)for its malloc-failure check (vanishes underNDEBUG, thenp[0] = nwrites to NULL) and returns&p[1], onlysizeof(size_t)-aligned — weaker thanmallocguarantees, though fine for the pointer-only payloads libdict stores.benchmark.c:152-158—num_countsis the maximum link count, not a count, andi <= num_countsreads one past the array. In bounds only by luck withcounts[16]and a 12-link skiplist; raise the link count to ≥16 and line 155 reads OOB whileASSERT(count_sum == nwords)starts failing.Recommendation: add a
dict_traversetest across all containers (with and without early stop), adelete_functest, and drop theif (vtable->...)guards in favour of asserting the expected slot set per container class.8. Correctness-adjacent, dead code, and documentation
Behavioural oddities (no data corruption)
src/sp_tree.c:255-256—sp_tree_insertreturns early on a duplicate key without splaying, the only accessor that doesn't (search,_le,_lt,_ge,_gtand even the not-found path at line 296 all splay). Repeatedly re-inserting a deep existing key stays O(depth) forever instead of amortized O(lg n).src/sp_tree.c:156+:166—rotationsis double-incremented in the left-child zig case only; the right-child branch has no such increment. A terminal single rotation costs 2 for a left zig and 1 for a right zig.src/pr_tree.c:203doesrotations += 1for a double rotation where the mirror branch (:243) correctly does+= 2. Diagnostics only —rotation_counthas no accessor and nothing in the tree reads it.src/rb_tree.c:149—rb_tree_cleardoes not resettree->rotation_count, unlikerb_tree_new.dict_rand()yields only 31 bits (random()returns[0, 2³¹)). Consequences:tr_tree.c:156stores a 31-bit value intouint32_t prio, halving the priority space and doubling the tie rate the treap's analysis assumes;ctzcan never exceed 30, sorand_link_countcaps at 16 —MAX_LINK = 32and the top half ofskiplist_new(cmp, 32)'s head levels are unreachable by design. Nothing in the library callssrandom(), so every process gets the identical priority/level sequence.src/hashtable2.c:467—while (++itor->slot < (int) itor->table->size)convertsunsignedtoint; forsize > INT_MAXthe iterator reports an empty table. Practically unreachable;hashtable.cusesunsigned slotand is unaffected. Similarlyhashtable_common.c:47—dict_prime_geqsilently returns a value smaller thannforn > 4294967291, with no way for a caller to detect it.src/hashtable.c/hashtable2.c—new_size * sizeof(...)multiplies anunsignedbysizeof. Cannot overflow with a 64-bitsize_t; on ILP32 aSIZE_MAX / sizeofguard in*_resizewould close the theoretical hole regardless.src/skiplist.c:657-661—skiplist_itor_removechecks the removal only withASSERT(result.removed)and then returnstrueunconditionally, so a failed removal is reported as success in release builds.src/tr_tree.c:230-242—node_newleavespriouninitialized; the single caller assigns it at line 156, so it is safe today, but a second caller would inherit heap garbage as a priority.src/rb_tree.c:374-375—if (node)with an unindented, unbracedSET_BLACK(node)body, where the color macros are bare expressions withoutdo {} while (0). Correct as written, one edit from a bug.benchmark.c:453—> 1000000should be>= 1000000in the microsecond carry;tv_usecmay reach exactly 1000000, leaving a non-normalizedtimevaland a total time 1 s low.Dead code
src/hashtable2.c:222-241—#if 0'dindex_of_node_to_shiftcontaining its own bugs (int last_indexassigned fromunsigned index; the loop terminates onwhile (index != truncated_hash)while the scan starts at the caller-suppliedindex). Delete rather than leave as a misleading reference implementation.src/hashtable.c:427-429—return itor->node != NULL;immediately afteritor->node = NULL. Unconditionally false; the three sibling functions writereturn false;.src/hashtable2.c:551—return NULL;from abool-returning function. Converts tofalse, so behaviour is right; still a type confusion that-Wint-conversion-class checks should flag.tree_common.h:40-52—tree_node_baseandtree_baseare declared and never referenced;src/tree_common.c:33-39defines private duplicates. Misleads readers about which type the punning targets.src/tr_tree.c:45,src/wb_tree.c:34— unused#include <limits.h>.src/rb_tree.c:34— unused#include <string.h>.src/wb_tree.c:420—unsigned root_weight;is passed tonode_verifyand never examined.benchmark.c:57—bool shuffle_keys = true;is never assigned, soif (shuffle_keys)at lines 205 and 259 is always taken (an unimplemented option).benchmark.c:37-40, 416-428—ptr_hashandmy_ptrcmpare defined, non-static, and never called; they escape-Wunused-functiononly because they have external linkage.demo.c:27—void *xrealloc(void *ptr, size_t size);declared, never defined or used.anagram.c:40-41—int freq[256] = { 0 };immediately followed by a redundantmemset. Line 43'sASSERT(buf[0] != '\0')can never fail (fgetsnever returns a zero-length string).anagram.c:52-56—ASSERT(freq[i] < 10)is a correctness precondition enforced only by an assertion. UnderNDEBUG, a word with ten or more of the same letter writes'0' + 10 == ':'intoname, so distinct letter-multisets collide and are reported as anagrams.anagram.c:90— a straywhile (rb_itor_next(itor));after theforloop, leftover from afor→do/whileconversion. Harmless only becauserb_itor_nextreturns false on an invalidated iterator; ifnextever wrapped, this becomes an infinite loop over already-freedWordListnodes.src/wb_tree.c:404/407—node_verifywrites*weightunconditionally where hb's guards withif (height). Harmless with today's callers.Wrong or misleading documentation
README.md:22inverts the insert API. "an insert call returns a boolean indicating whether or not the key was already present in the dictionary" —dict_insert_result.insertedistruewhen the key was not already present. A reader following the README writesif (result.inserted) { /* collision */ }, exactly backwards.src/hashtable2.c:2— the file header reads "chained hash-table, with chains sorted by hash, implementation", a verbatim copy ofhashtable.c:2. This is the open-addressing table;include/hashtable2.h:2gets it right.src/tr_tree.c:36-38documents a min-heap — "the priority of any node is less than the priority of either of its child nodes" — but the code is a max-heap (insert:169sifts whileparent->prio < node->prio;remove_node:190promotes the larger-prio child;node_verify:255assertsprio <= parent->prio). Someone writing adict_prio_funcfrom this comment gets the inverted tree shape, andinclude/dict.h:63gives no direction either.src/pr_tree.c:150-153documents the right-rotation triggers with the comparison inverted relative to the code at:210/:217. Reading the comment as spec produces a tree that failsnode_verify.src/wb_tree.c:167labels a single left rotation/* LL */(should beRR, per the/* RL */convention two lines later) and:200labels a single right rotation/* RR */;src/pr_tree.c:170/:210have the identical swap, while their adjacent/* RL */and/* LR */labels do follow the usual convention.src/pr_tree.c:159-162— "For single rotations… we tail recurse. For double, we make one recursive call and then tail recurse." Every rotating branch makes two non-tail calls (:173+176,:204+205,:216,:244+245), andfixupis not tail-recursive at all since results are summed intorotations. The stale comment hides thatfixup's recursion depth is bounded only by tree height.src/hashtable.c:106— stale comment "hashtable_itor_removenot implemented yet" sits next to the slot that now is implemented (line 542).hashtable2.c:101has the same slot without the bogus comment.tree_common.hdoc errors:nodeif it has no left child.tree_node_min/tree_node_max, which explicitly return NULL for NULL (tree_common.c:109-110, 120-121) — and internal callers rely on that (tree_common.c:440, 447;hb_tree.c:367).tree_search_le/_ltare described as returning "the first key less than or equal to" — they return the greatest such key. "First" is correct only for thege/gtvariants.node_min_path_length(tree_common.c:331-337): the doc says "the depth of the leaf with minimal depth", but the code returns1 + MIN(l, r)with 0 for a missing child — the minimal path to a NULL link, not to a leaf.node_max_path_lengthis leaf-based, so the two are defined inconsistently.tree_select's doc omits thatkeyanddatummust be non-NULL (finding 1.2).include/dict.h:52-53— the comparison-function contract mislabels its own terms: "reflexive (k1>k2 implies k1<k2, etc.)" describes antisymmetry and the parenthetical is backwards (should be "k1>k2 implies k2<k1"); "symmetric (k1=k1)" describes reflexivity. Only "transitive" is labelled correctly.include/dict.h:70— "Forward declarations for transparent typedict_itor" — one declaration, not several, and the full definition appears at line 174.dict_selectgets no documentation at all.src/wb_tree.c:40-41— "the number of nodes in its left subtree divided by the number of nodes in either subtree" — the denominator is the whole subtree (p(n) = weight(n->llink) / weight(n), which is what lines 162-163 compute).src/wb_tree.c:293,src/pr_tree.c:308— "Splice in the successor, if any" where the code splices in the node's only child.src/pr_tree.c:395-429— both ASCII diagrams have swallowed a row and render misaligned; line 406 reads "Only the weights of B and B's right child to be readjusted" (missing "need").src/dict.c:94— "FNV 1-a string hash" → "FNV-1a". (The implementation is a correct 32-bit FNV-1a.)include/dict.h:117, 134— the vtable field isinvalidwhile the operation everywhere else isinvalidate.include/dict.h:41-43—<stddef.h>,<stdint.h>,<stdbool.h>are included insideBEGIN_DECL, i.e. insideextern "C" {in C++. These headers can pull in namespace-scoped templates, which are ill-formed with C language linkage.include/dict.h:32-36—BEGIN_DECL/END_DECLare unprefixed, unguarded public macros; any other header defining the same names collides.benchmark.c:214—quit("lookup failed for '%s'", buf)printsbuf(the last line read at line 106) instead ofwords[i], the key that actually failed, making a genuine bug undebuggable.benchmark.c:355— "type must be one of h, p, r, t, s, w or H" omitsS(skiplist) and2(hashtable2), both advertised in the usage block at lines 68-70.demo.c:81has the correct list.demo.c:138, 152, 166, 180—printf("dict does not support that operation!")with no\n, so the next>prompt is glued on. Every other message in the file terminates with\n.src/tr_tree.c:38"lexigraphical" → "lexicographical";src/tr_tree.c:33"each node of tree";src/sp_tree.c:34"will runs in";README.md:20"support the selecting the nth element".README.md:16-17— both hashtable links point atwikipedia.org/wiki/Hashtable#…. The article isHash_table;Hashtableis a redirect, and a redirect discards the#Separate_chaining/#Open_addressingfragment, so both links land at the top of the page.include/{sp,tr,hb,wb,pr,rb}_tree.h:63-64— all declarevoid *_itor_free(*_itor* tree);. The parameter is an iterator, not a tree.include/skiplist.h:68leaves it unnamed (skiplist_itor_free(skiplist_itor* );) where every neighbour names ititor.include/skiplist.h:57-60— documents the histogram range as "For 0 < x < |ncounts|" wheresrc/skiplist.c:493fillscounts[x]for0 <= x < ncounts.src/dict_private.h:105—static inline unsigned dict_rand()is a non-prototype declaration (the only warning in the library build); should be(void). The hand-writtenextern long random(void);duplicates the<stdlib.h>declaration while breaking on toolchains withoutrandom()(MSVC).src/hashtable.c:136,src/hashtable2.c:131—*_dict_newasserthash_funcandsizebut notcmp_func, althoughhashtable_new/hashtable2_newdo. A caller passingcmp_func == NULLgets no diagnostic at the API boundary and crashes later atsrc/hashtable.c:175.hashtable2_freealso lacks theASSERT(table != NULL)thathashtable_freehas.TODO:4— "[X] Reformat to 80 columns" is marked done butinclude/dict.h:160-163runs ~120 columns andunit_tests.c:116/:321exceed 100.TODO:3— "[ ] Implement incomplete functionality, e.g. iterator remove & compare" is still open and accurate; nothing in the README warns that iteratorcompareis unavailable on hash tables (finding 1.3).src/rb_tree.c:645— no trailing newline.9. One claim that could not be reproduced
src/hashtable2.c:243-265(remove_cleanup) termination bound. The argument: the backward-shift loop stops onwhile (node != first)—firstbeing the home slot of the deleted key — rather than on the first empty slot. On a 100%-full table the hole can end up behindfirst, so the element sitting atfirstis never shifted into it and becomes unfindable whilecountstill includes it.hashtable2_verifywould not catch it (it only counts occupied slots).The state is reachable:
hashtable2_resize(:363) only rejectscount > new_size, permitting exactly 100%; and the OOM path at:189-191explicitly comments "No memory for a bigger table, but let the insert proceed anyway", so inserts keep filling tosize.Attempted repro: 11 elements in a size-11 table forced to exactly 100% via the public
hashtable2_resize(t, 11), with both a clustered hash (v % 3 + 1) and a spread hash (v + 1), removing each of the 11 elements in turn and re-searching all others — all 22 cases passed,verify=1, nothing lost. The reasoning is not obviously wrong, but there is no repro. Treat as unconfirmed; a wider search over table sizes and hash distributions would be needed to settle it.10. What held up under testing
Reported so this ground is not re-covered.
Differential stress harness (insert / remove / search /
search_le|lt|ge|gt/ iterator-remove / traverse with early stop /nextn/prevn/itor_compare/ forward+reverse iteration /selectat every rank /clear+reuse /free, all checked against a reference model, under ASan+UBSan+LSan): clean across all 14 configurations —hashtable/{1,64},hashtable2/{1,64},hb_tree,pr_tree,rb_tree,sp_tree,tr_tree/{rand,prio},wb_tree,skiplist/{2,4,32}— at 100k ops × 5 seeds for the hash tables and pr/rb, and 25–60k ops × 3+ seeds for the rest. No leaks, no double frees, no out-of-bounds. (skiplist/1andhb_tree-traverse are findings 2.1 and 1.1.)Specifically verified correct:
sp_node,tr_node,pr_node,wb_node(all begin withTREE_NODE_FIELDS, extras appended after) and — byoffsetofinspection — forhb_node/rb_nodeon this platform (0/8/16/24/32, size 40). See §1.1 and §5.5 for the contract this rests on.src/sp_tree.c:172-228) traced pointer by pointer: zig-zig rotates the grandparent then the parent, zig-zag the node then the grandparent, and everyparentback-pointer including subtree roots and the great-grandparent relink is fixed up. Thepp == NULLzig case correctly setst->rootandn->parent = NULL. Splaying preserves in-order sequence and frees nothing, so an iterator surviving a search still yields the correct successor.tr_tree.c:169-177,remove_node:188-194), including the re-latch ofparentafter each rotation.rb_treeinsert_fixupmatches CLRS exactly, anddelete_fixup's four cases, NULL-xhandling,left-direction tracking across the move-up (:320-322) and thew->llink/w->rlinknon-NULL preconditions (:325,:356) are all sound — checked against an independent black-height verifier written from CLRS (NIL = 1), 40 seeds × 20,000 random ops over 600 keys, plus ascending / descending / zigzag insert-then-delete of 2,000 keys. All OK. (Necessary becauserb_tree_verifyitself is unreliable — finding 3.1.)pr_treeweight arithmetic — the manual double-rotation updates (:199-201,:239-241) are correct, and the statement ordering is load-bearing and right: each line consumes the old value of the weight the next line overwrites. Theunsignedexpressions do underflow, but unsigned wraparound is well-defined and the sum wraps back correctly.pr_tree_selectis correct for the weight = subtree-size + 1 convention including thellink == NULLcase, andpr_tree_verify's balance condition (:469-475) is exactly the negation offixup's two triggers — so unlike the rb verifier it is complete for the invariant it claims.hb_tree— all four rotation balance formulas (:164-275), insert balance-factor propagation andq-tracking (:293), and the delete propagation loop (:394-445). TheASSERT(nr != NULL)/ASSERT(nrl != NULL)/ASSERT(nlr != NULL)preconditions infixupare implied by the branch conditions.wb_tree's four rotations' weight recomputations likewise.hb_tree_selectandwb_tree_selectagree and are correct for N = 1..200.pr_tree_insert(:281-285) andpr_tree remove_node(:318-323): saving the grandparent beforefixupis correct rather than lucky, since a rotation atnodereplaces it with a descendant in the same slot of the same parent.node_newallocates andmemsets exactlysizeof(*node) + sizeof(link[0]) * link_count, no off-by-one); no OOB on level/link arrays for validmax_link; theupdate[]level-skipping in insert (:195) and remove (:377); level shrinking (:401-402) back to exactly the new maximum.hashtable2's probe loops all terminate even on a full table with no unsigned wraparound;remove_cleanup's core re-insert-from-home algorithm is the correct no-tombstone deletion;nonzero_hashcollapsinghash == 0to~0uis sound becausecmp_funcresolves the aliasing;countbookkeeping is consistent (remove_cleanup's internal inserts deliberately do not touch it);hashis cached in the node and reused for rehash in both implementations rather than recomputed;hashtable2_resize's failure paths correctly restoretable/size/countand free only the new table; no division by zero is possible sincedict_prime_geqnever returns less than 11.dict_prime_geq's table — all 30 entries verified prime and strictly ascending by trial division;kNumPrimesand the loop bounds are correct with no off-by-one.src/— everyMALLOCis NULL-checked; insert-failure paths mutate nothing and return{NULL, false}without leaking or linking;*_dict_new/*_dict_itor_newfree the outer wrapper on inner failure;skiplist_freeclears then freesheadthenlistwith no double free;dict_free/dict_itor_freeare clean.Suggested order of work
hb_tree_traverse(crash on 2 keys, public API) and add adict_traversetest, whose absence is what let it ship.skiplistmax_link < 2(silent total data loss + leak).uint64_tin thewb_treeweight comparisons,node_verifyincluded.tree_select,dict_itor_compare, and the fivedemo.cbranches.hb_itor_searchexact-match.skiplist_verify), since they gate everything else.anagram.c/benchmark.canddict_str_cmp's signedchar.FREEcomparisons and the vtable function-pointer signatures.