Repository navigation
QA: stratum_dupes: Test the parts of #18 that its tests do not reach - #31
Open
alphaminetech wants to merge 6 commits into
Open
alphaminetech wants to merge 6 commits into
alphaminetech wants to merge 6 commits into
Conversation
datum_stratum_add_new_dupe returns a pointer to the entry it has just written, and when that write fills the table it calls datum_stratum_dupes_cleanup before returning. Both things the cleanup can do invalidate that pointer: the prune path qsorts the array so every entry moves, and the expand path reallocs it. datum_stratum_check_for_dupe stores what it gets back straight into dupes->index[nonce_index], so from the first cleanup onwards the bucket index holds pointers to entries that have moved, or into a freed array, and the next share on that nonce follows one. Under ASan the expand path is a heap-use-after-free at the read of i->nonce in datum_stratum_check_for_dupe. The prune path frees nothing and so is quieter, and it is reached whenever anything in the table can be aged out: it leaves buckets pointing at slots past current_items, which are handed out again to later entries. In a 256 share test against a small table, 235 of them left the index pointing outside the live entries. The table is allocated once per stratum thread in datum_stratum_dupes_init and never re-initialised, so nothing clears this short of restarting the gateway. Reaching it is a function of accumulated shares rather than of anything unusual: with the shipped defaults the table is 32768 entries per thread. Move the capacity check to the top of datum_stratum_check_for_dupe, which is the last point in the call where nothing is holding a pointer into the array or an insertion point in a bucket. Each call inserts at most once, so checking there is enough to guarantee the insert has room, and the insert now refuses rather than writing past the end if it ever does not. Two further things in the same file: datum_stratum_dupes_cleanup's full_wipe branch zeroed the entries and left the index pointing at them, which is the reuse case above set up deliberately. Nothing calls it with full_wipe today, which is the only reason it has not bitten. stratum.max_clients_per_thread is range checked for an upper bound and not a lower one, and the table size is the product of three configured values, so a zero or a negative sized the table at nothing. Nothing is not a table that merely overflows quickly: the expand grows it by 25%, 25% of zero is zero, and the gateway takes a share it then has nowhere to put. Size it once, floor it, then allocate, so the array and max_items cannot disagree. The tests cover both cleanup paths, because they fail differently, and assert that every pointer reachable from the index names a live entry and that no chain loops.
…-fix Pull request 18, "Bugfix: stratum: Keep the dupe index valid across a table cleanup", as it is (commit 6ccfbe5). The commits that follow add tests for it and depend on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
datum_stratum_dupes_cleanup(dupes, true) zeroes every entry. Check that no bucket still points at one afterwards, and that the shares the table held are new again and are then remembered again. Nothing calls the full wipe today, so no other test reaches that branch. Without the index memset in it, this fails at full_wipe_left_no_bucket_behind and goes no further: a chain that starts at a zeroed slot is how an entry ends up linked to itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table is sized from stratum.max_clients_per_thread, which has no lower bound, times two values that have one. Size it from 0 and from -1 clients and check that it still has at least 16 entries, takes 64 shares on a live job (so it has to grow) and finds all 64 again. Without the floor in datum_stratum_dupes_init, this fails at table_has_at_least_16_entries and goes no further: a table without room cannot grow, and allocating a negative number of entries does not return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
datum_stratum_check_for_dupe makes room before it inserts, so datum_stratum_add_new_dupe never sees a full table through it, and no test that goes through it can reach the refusal. Call the insert directly on a full table, once as the first entry of a bucket and once linked in after an existing entry, and check that it returns NULL, counts nothing, links nothing and leaves the slots behind the table alone. The refusal logs its error line once when this test runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests so far check that the bucket index stays sound. This one checks what the table is for. Over 20000 rounds on a table that starts with 16 entries, with four jobs of which two have aged out, a share that was submitted before on a current job must be reported as a duplicate, and a share that was never submitted must not be. All shares fall into eight buckets, so the chains are long and entries go in at the head, in the middle and at the end of a chain, and every nonce is used by two shares that differ in ntime and extranonce. The run has to go through both kinds of cleanup, which is asserted. With the cleanup still inside the insert, this run does not get as far as its assertions: the share that becomes the new first entry of its bucket while the table grows is linked through the pointer into the freed array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
Static review identified no blocking issues in the inherited fix or added tests; tests were not run in this environment.
0 open findings
What changed in this PR
Extends regression coverage for stratum duplicate detection, building on the unchanged fix from #18.
Changes:
- Includes #18’s cleanup ordering and table safeguards.
- Adds tests for full wipes, minimum capacity, refused inserts, and duplicate detection across repeated cleanups.
| File | Description |
|---|---|
src/datum_stratum_dupes.c |
Includes #18’s fix to preserve valid indexes across cleanup. |
src/datum_stratum_dupes_tests.c |
Adds regression coverage for cleanup behavior and safeguards. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
This builds on #18 ("Bugfix: stratum: Keep the dupe index valid across a table cleanup") and does not replace it. The fix is #18's. Its commit 6ccfbe5 is included unchanged, through a merge of that pull request's head onto current master (08123fc), because the tests here need it. Nothing in this pull request changes
src/datum_stratum_dupes.c; it adds four test commits tosrc/datum_stratum_dupes_tests.c(+268 lines). If #18 is merged first, those four commits are all that is left here.Why
#18 was re-tested on current master by taking each part of it out again and running
./datum_gateway --test:datum_stratum_check_for_dupe, moved back into the insert (the bug restored)malloc; with ASan: heap-use-after-freememsetof the index in thefull_wipebranchdatum_dupe_table_full_wipe_testsdatum_dupe_table_min_size_testsdatum_dupe_table_full_insert_testsif (!p) return false;after a refused insert#18's tests prove its main change. The three smaller parts could be taken out again without a test noticing, and the first three commits here close that. The last row stays open on purpose: that line only runs after a refusal, and
datum_stratum_check_for_dupecannot get to a refusal as long as the cleanup at its top makes room, so no test reaches it without breaking something else first.Two more things came out of the same runs:
datum_dupe_table_fill_testspasses in a normal build (10 of 10 runs, none of its checks fail). It catches the bug only when built with AddressSanitizer. The prune and cycle tests fail in any build (235 and 46 checks, the same in every run).datum_stratum_check_for_dupereturns after a prune. The fourth commit checks that, against a list of what was submitted.The bug, and when a gateway gets to it
#18 describes it; briefly, from master's code.
datum_stratum_add_new_duperuns the cleanup after it has written the entry that fills the table, and then returns a pointer the cleanup has just invalidated: the prune sorts the array, the expand reallocates it. Two of the four insert sites indatum_stratum_check_for_dupeuse that pointer: line 301, when the share is the first in its bucket, and lines 316 to 318, when it becomes the new first entry of a bucket. The other two discard it, and for those the index that the cleanup rebuilt is correct.Each stratum thread has one table, created when the thread starts and not reset afterwards. It holds
max_clients_per_thread * vardiff_target_shares_min * (share_stale_seconds / 60) * 16entries, 32768 with the defaults, and every share that gets as far as the duplicate check and is not a duplicate adds one. At the vardiff target of 8 shares a minute the table is full after about 68 hours on a thread with a single client and after about half an hour on a thread with 128, and then again each time it has refilled. With 65536 buckets for 32768 entries most buckets are empty or hold one entry, so for evenly spread nonces the share that fills the table lands on one of the two faulty sites roughly four times in five (by a rough count 61 % first in its bucket, 18 % new first entry).What happens then depends on which way the cleanup went:
share_stale_seconds. This is the usual case: a table that took longer than that to fill is mostly entries on old jobs. The array is sorted in place and not moved. The bucket of the share that triggered the cleanup ends up pointing at the last slot of the array, which is now empty and pastcurrent_items, instead of at the share's entry. That share is no longer found, so submitting it again is not reported as a duplicate; later shares of that bucket are chained behind the empty slot; and the slot is handed out to a new entry when the table is full again. All of this stays inside the table's own memory. A chain that ends up linked to itself is possible from there on paper (a later share of the same bucket has to be given a slot that the bucket's chain already ends in). No run here produced one.share_stale_seconds: with the defaults more than about 31000 accepted shares on one thread in two minutes, some 15 times what the table is dimensioned for. That takes a small configured table or clients far above the vardiff target.reallocmay move the array, and then the same two sites go through a pointer into freed memory: line 318 (p->next = i) writes 8 bytes through it in the same call, and line 309 reads through the bucket on the next share in that bucket.Commits
datum_stratum_dupes_cleanup(dupes, true), and checks that no bucket is left, that the same shares are new again and that they are then found again. Nothing calls the full wipe today, so no other test reaches that branch.datum_stratum_add_new_dupedirectly on a full table, once as the first entry of a bucket and once linked in after an entry, and checks that it returns NULL, counts nothing, links nothing and leaves the two slots behind the table alone. The function is declared in the test file, not added to the header.Commits 2, 3 and 5 go through the three functions the header exports, so they can be carried over to another fix of this bug with little change (commit 3 expects the floor of 16; commits 2 and 5 also call
datum_dupe_index_is_soundfrom #18's tests). Commit 4 is tied to #18's refusal.Testing
One x86_64 host, Linux 6.8, Ubuntu 24.04 container without network, gcc 13.3.0, cmake 3.28.3. "Plain" is
-Wall -Werror; "sanitizers" is the CI's-fsanitize=address -fsanitize=undefined -fno-sanitize-recover=allwith-g, run with the CI'sASAN_OPTIONSandUBSAN_OPTIONSand with ASLR switched off for the process. The number of checks is counted by a counter ondatum_testthat exists only in the lab copy../datum_gateway --testbucket_points_at_a_live_entry(prune test), 46the_new_share_became_an_entry(cycle test)datum_stratum_dupes.c:309in the fill test; freed byreallocat line 138, reached from 231, 274, 301malloc(): mismatching next->prev_size (unsorted)and abort in the test of commit 5datum_stratum_dupes.c:318; freed byreallocat line 138, reached from 241, 274, 316Each of the four new tests was also run alone on master without the fix, 10 times plain and 5 times with the sanitizers, with the same result every time: 1, 1 and 5 checks fail for commits 2 to 4; the test of commit 5 aborts in glibc's
malloc(plain) or stops at the use-after-free write above (sanitizers).Further mutants of the fixed source, for completeness. With the cleanup taken out altogether, 149 checks fail (fill 49, cycle 1, min size 96, reference 3), plain and with the sanitizers alike, and the sanitizers report nothing: the refusal keeps every insert inside the array. Running the cleanup only when
current_items > max_itemsgives the same 149 (plain build). With the cleanup and the refusal both taken out, ASan reports a heap-buffer-overflow at the write of the new entry indatum_stratum_add_new_dupe.Not in the branch, lab only: the test of commit 5 at the default table size (32768 entries), over all 65536 buckets, for 1.5 million rounds. With the fix it passes plain and with the sanitizers (13 expands, 28 prunes). Without the fix the sanitizer build stops at the same use-after-free write, and the plain build runs to the end and fails
missed_duplicates == 0.Not done: no clang build, no musl, no macOS (the CI covers those), and no run of a gateway with miners attached; the section above on when a gateway gets to the bug is from reading the code.
What this does not change
src/datum_stratum_dupes_tests.cbeyond what Bugfix: stratum: Keep the dupe index valid across a table cleanup #18 itself changes, and no behaviour of the gateway.--testevaluates 13677 more checks and prints one more ERROR line: the refusal's own message, from the test of commit 4.Notes on #18 from this review
datum_stratum_dupes_initsays sixteen is the point below which the rounding stops the table growing.(n * 125) / 100is larger thannfor everynfrom 4 up, so that point is 4. The floor of 16 is fine as it is.datum_conf.c, a table size of zero or less needsstratum.max_clients_per_threadat zero or below, anddatum_sockets.cthen assigns no client to a thread; the other way is an overflow of theintproduct from a very largestratum.vardiff_target_shares_min, which has no upper bound. This is from reading, not from a run.🤖 Generated with Claude Code