test: extend td_init coverage + guard td_free(NULL) (follow-up to #41) - #44
Conversation
gabsow
left a comment
There was a problem hiding this comment.
One test assertion issue to address.
| mu_assert_double_eq(10000.0, td_max(t)); | ||
| mu_assert_long_eq(10000, td_size(t)); | ||
| mu_assert(td_centroid_count(t) <= t->cap, "centroid count must stay within cap"); | ||
| mu_assert_double_eq_epsilon(5000.5, td_quantile(t, 0.5), 50.0); |
There was a problem hiding this comment.
mu_assert_double_eq_epsilon does not fail for NAN: fabs(expected - NAN) is NAN, and NAN > epsilon is false. As written, this end-to-end test can pass even if td_quantile() returns NAN. Please store the result and assert isfinite(median) before the epsilon comparison.
There was a problem hiding this comment.
Fixed in f2beaa2. The end-to-end test now stores the result and asserts isfinite(median) before the epsilon compare, with a comment explaining that mu_assert_double_eq_epsilon does not fail for NaN.
gabsow
left a comment
There was a problem hiding this comment.
Blocking merge-target issue: #41 has already been squash-merged into master, but this PR still targets codex/td-init-capacity-guard. The current head has diverged from master (3 commits ahead, 1 behind). Please rebase/cherry-pick only 9428653 onto current master, update the PR branch, and retarget the PR to master; retargeting alone would expose the duplicate #41 history/diff.
| // just allocate but actually work end to end. | ||
| MU_TEST(test_td_init_large_success_is_usable) { | ||
| td_histogram_t *t = NULL; | ||
| mu_assert_long_eq(0, td_init(100000, &t)); |
There was a problem hiding this comment.
This adds a useful ~9.6 MB end-to-end case, but it does not complete the follow-up called out on #41: the exact accepted side, (INT_MAX - 10) / 6, remains untested, while the existing td_init(100000000) expectation (~9.6 GB) is still overcommit-dependent. Please replace that fragile allocation test and cover the accepted boundary through a factored/testable capacity helper or allocator hook that avoids allocating tens of GiB.
There was a problem hiding this comment.
Fixed in f2beaa2. Factored the validation + capacity math out of td_init() into an allocation-free helper capacity_from_compression(), and added td_capacity_test (includes the TU) that drives it directly at the exact boundary: floor((INT_MAX-10)/6)=357913939 is accepted (cap=2147483644 <= INT_MAX) and +1 is rejected with *capacity left untouched — no allocation. Dropped the overcommit-dependent td_init(100000000) (~9.6 GB) case.
| // This guard is required by the capacity validation added in this PR: td_new() | ||
| // now returns NULL for invalid compression (non-finite / <= 0 / cap > INT_MAX), | ||
| // so the idiomatic td_free(td_new(bad)) cleanup would otherwise dereference NULL. | ||
| if (!histogram) { |
There was a problem hiding this comment.
Since this makes NULL tolerance part of the public API, please update tdigest.h alongside it: td_new() can return NULL for invalid compression or allocation failure; td_init() can return 1 for either case and leaves *result untouched on rejection; and td_free(NULL) is supported. Also, the nearby comment should not say the validation was added “in this PR”—that landed in #41.
There was a problem hiding this comment.
Fixed in f2beaa2. tdigest.h now documents that td_new() returns NULL for invalid compression or allocation failure, td_init() returns 1 and leaves *result untouched on rejection, and td_free(NULL) is a no-op. Also corrected the td_free comment — it no longer claims the validation was added in this PR (it landed in #41).
… docs Follow-up to gabsow's review on RedisBloom#44: - Factor the compression validation + capacity computation out of td_init() into capacity_from_compression() (no allocation). Add td_capacity_test that drives it directly to cover the exact accepted/rejected boundary floor((INT_MAX-10)/6) without allocating the ~17 GB that compression needs, and drop the overcommit-dependent td_init(100000000) (~9.6 GB) case. - test_td_init_large_success_is_usable: store td_quantile() and assert isfinite() before the epsilon compare; mu_assert_double_eq_epsilon does not fail for NaN, so the check alone could pass on a NaN median. - Document the NULL/rejection contract in tdigest.h (td_new returns NULL, td_init returns 1 and leaves *result untouched, td_free(NULL) is a no-op), and correct the td_free comment: td_new()'s validation landed in RedisBloom#41. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed f2beaa2 addressing all three review comments:
Verified locally under ASan+UBSan: |
gabsow
left a comment
There was a problem hiding this comment.
Two follow-up items remain outside the inline findings:
- The merge target is still
codex/td-init-capacity-guard. This head is now diverged from currentmaster(4 commits ahead / 1 behind). Please replay both PR commits (9428653andf2beaa2), or squash them, ontomasterand retarget the PR. - #43 is still open, and its sanitizer job manually compiles/runs only
td_test; it will not execute the newtd_capacity_test. Please include that target in the sanitizer workflow (or run a sanitizer-enabled CMake/CTest build) before claiming these tests run automatically under sanitizers.
|
|
||
| int main(void) { | ||
| /* The largest integer compression whose capacity still fits in an int. */ | ||
| const long long max_ok = (long long)((INT_MAX - 10) / 6); |
There was a problem hiding this comment.
This assumes INT_MAX is always the binding capacity limit, but production also checks SIZE_MAX / sizeof(double) and SIZE_MAX / sizeof(long long). On a 32-bit size_t target, this produces cap_ok = 2147483644 while the per-array element limit is only 536870911, so capacity_from_compression() correctly rejects max_ok and this new test fails. Please derive max_capacity = min(INT_MAX, SIZE_MAX / sizeof(double), SIZE_MAX / sizeof(long long)), then compute max_ok = (max_capacity - 10) / 6 and test that boundary plus one. Also, the nearby memory estimate is low by 2×: two 8-byte arrays at cap ≈ INT_MAX total about 34.4 GB (~32 GiB), not ~17 GB.
There was a problem hiding this comment.
Fixed in 2fb6b5a. The test now derives max_capacity = min(INT_MAX, SIZE_MAX/sizeof(double), SIZE_MAX/sizeof(long long)) and takes max_ok = (max_capacity - 10) / 6 from that, so it stays correct on a 32-bit size_t target where the SIZE_MAX/8 element limit binds instead of INT_MAX. On 64-bit size_t, INT_MAX still binds so the tested boundary is unchanged (357913939 -> cap 2147483644). Also corrected the memory estimate to ~34 GB (~32 GiB) here and in the related comments.
| target_include_directories(td_capacity_test PRIVATE ${CMAKE_SOURCE_DIR}/src) | ||
| target_link_libraries(td_capacity_test m) | ||
| add_test(td_capacity_test td_capacity_test) | ||
| endif() |
There was a problem hiding this comment.
CMAKE_SOURCE_DIR refers to the outermost project, so this include path is wrong when t-digest-c is consumed via add_subdirectory() (and could even select an unrelated parent src/tdigest.c). Please use ${PROJECT_SOURCE_DIR}/src or, more robustly here, ${CMAKE_CURRENT_LIST_DIR}/../src.
There was a problem hiding this comment.
Fixed in 2fb6b5a — switched the include path to ${CMAKE_CURRENT_LIST_DIR}/../src so it resolves correctly when t-digest-c is consumed via add_subdirectory().
…estimate Follow-up to gabsow's second review on RedisBloom#44: - td_capacity_test assumed INT_MAX is always the binding capacity limit, but the helper also rejects on SIZE_MAX/sizeof(double) and SIZE_MAX/sizeof(long long). On a 32-bit size_t target the per-array element limit (SIZE_MAX/8) is smaller than INT_MAX, so max_ok would be rejected and the test would fail. Derive max_capacity = min(INT_MAX, SIZE_MAX/sizeof(double), SIZE_MAX/sizeof(long long)) and take the boundary from that. On 64-bit size_t INT_MAX still binds, so the tested boundary (357913939 -> cap 2147483644) is unchanged. - Fix the tests/CMakeLists.txt include path: CMAKE_SOURCE_DIR points at the outermost project and breaks when t-digest-c is used via add_subdirectory(); use CMAKE_CURRENT_LIST_DIR/../src instead. - Correct the memory estimate: two 8-byte node arrays at cap ~ INT_MAX total ~34 GB (~32 GiB), not ~17 GB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — I rechecked
|
gabsow
left a comment
There was a problem hiding this comment.
Thanks — I rechecked 2fb6b5a, and the two latest inline findings are fixed correctly. Two blockers remain:
This PR still targets codex/td-init-capacity-guard, although #41 has already been squash-merged. Please replay or squash the three #44 commits onto current master and retarget the PR; simply changing the base would expose duplicate #41 history in the diff.
#43 is still open, and its sanitizer job compiles and runs only td_test, so the new td_capacity_test is not covered by ASan/UBSan. Please add that target to the sanitizer job, or use a sanitizer-enabled CMake/CTest build, before claiming automatic sanitizer coverage.
This PR makes td_new() return NULL for invalid compression (non-finite, <= 0,
or capacity > INT_MAX), so the idiomatic td_free(td_new(bad)) cleanup would
dereference NULL. Add the standard `if (!histogram) return;` guard to td_free.
Extend the unit tests to cover the accept side and the new NULL paths (the added
tests previously only asserted rejection):
- td_free(NULL) and td_free(td_new(bad)) do not crash
- td_new() rejects the same inputs as td_init() (NaN/INF/-1/0/overflow -> NULL)
- td_init() leaves *result untouched on failure (sentinel, not just == NULL)
- cap == 6*compression + 10 and determinism at safe sizes; pins the current
sub-1/fractional-compression behavior (accepted, floored to cap 10)
- a large valid digest (compression 100000) actually works end to end
Verified: 25 tests / 5.01M assertions pass; clean under ASan+UBSan+LSan; clang-format OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… docs Follow-up to gabsow's review on RedisBloom#44: - Factor the compression validation + capacity computation out of td_init() into capacity_from_compression() (no allocation). Add td_capacity_test that drives it directly to cover the exact accepted/rejected boundary floor((INT_MAX-10)/6) without allocating the ~17 GB that compression needs, and drop the overcommit-dependent td_init(100000000) (~9.6 GB) case. - test_td_init_large_success_is_usable: store td_quantile() and assert isfinite() before the epsilon compare; mu_assert_double_eq_epsilon does not fail for NaN, so the check alone could pass on a NaN median. - Document the NULL/rejection contract in tdigest.h (td_new returns NULL, td_init returns 1 and leaves *result untouched, td_free(NULL) is a no-op), and correct the td_free comment: td_new()'s validation landed in RedisBloom#41. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estimate Follow-up to gabsow's second review on RedisBloom#44: - td_capacity_test assumed INT_MAX is always the binding capacity limit, but the helper also rejects on SIZE_MAX/sizeof(double) and SIZE_MAX/sizeof(long long). On a 32-bit size_t target the per-array element limit (SIZE_MAX/8) is smaller than INT_MAX, so max_ok would be rejected and the test would fail. Derive max_capacity = min(INT_MAX, SIZE_MAX/sizeof(double), SIZE_MAX/sizeof(long long)) and take the boundary from that. On 64-bit size_t INT_MAX still binds, so the tested boundary (357913939 -> cap 2147483644) is unchanged. - Fix the tests/CMakeLists.txt include path: CMAKE_SOURCE_DIR points at the outermost project and breaks when t-digest-c is used via add_subdirectory(); use CMAKE_CURRENT_LIST_DIR/../src instead. - Correct the memory estimate: two 8-byte node arrays at cap ~ INT_MAX total ~34 GB (~32 GiB), not ~17 GB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2fb6b5a to
53f9e69
Compare
|
Both blockers from the last review are addressed:
|
|
gabsow
left a comment
There was a problem hiding this comment.
Verified the latest fixes: capacity validation and exact boundary coverage are correct, td_free(NULL) is guarded, the full sanitizer suite passes, and all CI checks are green.



Builds on #41 (targets its branch so the diff here is only the delta; retarget to
masteronce #41 merges).Two follow-ups the review of #41 called out, kept out of #41 to keep it small:
td_free(NULL). Guard TDigest capacity calculation before integer narrowing #41 makestd_new()return NULL for invalid compression (non-finite /<= 0/ capacity> INT_MAX), so the idiomatictd_free(td_new(bad))cleanup would dereference NULL. Adds the standardif (!histogram) return;.td_free(NULL)/td_free(td_new(bad))don't crashtd_new()rejects the same inputs astd_init()(NaN/INF/-1/0/overflow → NULL)td_init()leaves*resultuntouched on failure (sentinel, stronger than "== NULL")cap == 6*compression + 10and determinism at safe sizes; pins current sub-1/fractional behaviorVerified locally
25 tests / 5.01M assertions / 0 failures; clean under ASan + UBSan + LSan; clang-format OK.
(With #43's CI in place, these run automatically under sanitizers.)
🤖 Generated with Claude Code
Note
Low Risk
Defensive cleanup and test/refactor around existing validation; no changes to digest merge/quantile logic.
Overview
Follow-up to compression validation in #41:
td_free(NULL)is now a no-op sotd_free(td_new(bad))is safe whentd_newreturns NULL for invalid compression.Compression sizing is refactored into allocation-free
capacity_from_compression()(same rules as before);td_initdelegates to it. Public docs intdigest.hspell out NULL returns, failure leaving*resultuntouched, and safetd_free(td_new(...)).Tests: new
td_capacity_testhits the largest accepted compression boundary without ~34 GiB allocations;td_testadds NULL-free paths,td_newparity withtd_init, sentinel*resulton failure, cap formula/determinism, fractional compression behavior, and a large-but-usable digest (compression 100000). The oldtd_init(100000000)case is dropped in favor of the capacity test.Reviewed by Cursor Bugbot for commit caadf02. Bugbot is set up for automated code reviews on this repo. Configure here.