Skip to content

Fix/enqueue block submission - #58

Open
secretnamebasis wants to merge 4 commits into
DEROFDN:community-devfrom
secretnamebasis:fix/enqueue-block-submission
Open

Fix/enqueue block submission#58
secretnamebasis wants to merge 4 commits into
DEROFDN:community-devfrom
secretnamebasis:fix/enqueue-block-submission

Conversation

@secretnamebasis

Copy link
Copy Markdown

Description

Fixes a real, reproducible bug in dero-miner's share-submission path: connection (the shared *websocket.Conn) can be reassigned by a reconnect in getwork() at any moment, including in the narrow window between a mining thread finding a valid hash and finishing its submission. When that happens, the submission goes out over the new session's connection but carries the old session's JobID, which the server has no reason to recognize — the share is silently lost with no error, no log line, and no counter. This exact bug class has been independently found and fixed in several sibling DERO miner implementations in other languages.

While building and verifying the fix (against the simulator, repeatedly killing/restarting it mid-mining to force real reconnects — see Testing below), also found and fixed a second, related bug: a failed reconnect attempt leaves connection nil without advancing the new staleness-detection counter, so a queued share could still pass the staleness check and then panic on a nil connection. Not theoretical — this fired over 100,000 times in a few seconds during testing before the fix.

What changed

  1. connectionEpoch — a counter bumped once per successful reconnect, under the same lock (connection_mutex) that guards the connection variable itself. (Also fixes a latent, unrelated data race: getwork()'s connection = ... assignment wasn't previously taking this lock at all, even though every other reader/writer of connection was.)
  2. enqueueShare() / submitQueue / submitWorker() — found-hash handling no longer blocks a mining thread on a mutex and a synchronous network write. Mining threads hand a found share off to a bounded channel and immediately return to hashing; one dedicated goroutine (submitWorker) owns all submission I/O, re-checking connectionEpoch immediately before WriteJSON and dropping (not sending) anything whose epoch has moved — i.e. a reconnect happened since the share's job was fetched.
  3. Nil-connection guard in submitWorker, alongside the epoch check — closes the second bug described above.
  4. staleSubmitsDropped surfaced in the live status line (next to Rejected), so the race being defended against is actually observable to an operator, not just an internal counter.

Deliberately out of scope

A smaller residual race some sibling miners close with a session-gate (workers keep mining the old job for a short window after reconnect until the new session's first job arrives), and separately-known gaps (missing read deadlines on the websocket connection, flat 10s retry backoff with no jitter/cap). Real, but different bugs — kept this PR scoped to the one race plus what testing surfaced along the way.

Testing

go build / go vet / gofmt / go build -race all clean throughout.

Live-verified against cmd/simulator (locally, with fix/simulator-get-work-server's getwork-server patch applied on top for testing — not part of this PR): pointed a real dero-miner binary at the simulator's getwork endpoint and mined normally end-to-end (job fetch → hash → share found → submitted → block accepted, height and block count climbing steadily) through the new enqueueShare/submitWorker path.

Then forced the actual race: repeatedly killed and restarted the simulator process while mining continued (8 threads, ~13 KH/s, simulator-trivial difficulty, roughly one reconnect every 1.5s for 8 cycles).

  • Before the nil-connection fix: submitWorker panicked (recovered, not a crash, but firing continuously) every time a reconnect attempt failed and left connection nil.
  • After the fix: 0 panics across all 8 cycles. StaleSubmits climbed into the hundreds of thousands (expected, given how aggressively the test was cycling reconnects against a low simulator difficulty) — real confirmation the race fires and is being caught, not just plausible from code review. The miner kept mining and successfully submitting new blocks throughout and after every cycle (BLOCKS kept climbing).

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • This will require a HardFork to be enabled

Which part is impacted ?

  • Wallet
  • Daemon
  • Miner
  • Explorer
  • Simulator
  • Misc (documentation, comments, text...)

Checklist:

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings

License

I am contributing & releasing the code under DERO Research License (which can be found here).

secretnamebasis and others added 4 commits August 4, 2026 15:00
…ing it

connection (the package-level *websocket.Conn) is reassigned on every
reconnect in getwork()'s dial loop. mineblock() captures myjob before
hashing and, on finding a valid hash, later writes a SubmitBlock_Params
carrying that job's JobID over whatever "connection" currently is. If a
reconnect lands in the window between finding the hash and finishing the
submit (not instantaneous -- there's a mutex acquisition and a network
write in between), the submission goes out over the new session's
connection but references the old session's JobID, which the server has
no reason to recognize. The share is silently lost with no error, no log
line, and no counter -- indistinguishable from a share that was simply
never found.

This exact bug class has been independently found and fixed in at least
four sibling DERO miner implementations (Go, Rust, C++, Zig). Go/Rust fix
it by draining a submit queue at the reconnect boundary; this dero-miner
has no queue to drain (submission is synchronous, straight from the
hashing goroutine), so this ports the C++/Zig shape instead: a per-item
epoch re-check immediately before the write.

connectionEpoch is a counter bumped once per successful reconnect, under
connection_mutex, in the same critical section as the connection
assignment itself -- getwork()'s dial call wasn't taking that lock before
this change, which was a plain data race on top of the staleness bug
(mineblock's submit closures already read/wrote connection under
connection_mutex; getwork()'s assignment wasn't synchronized with that at
all). mineblock() captures the epoch alongside the job when it starts
hashing, and the new submitShare() helper (replacing two identical inline
closures, one per side of the MAJOR_HF2_HEIGHT branch) re-checks it inside
connection_mutex immediately before WriteJSON -- if the epoch moved, a
reconnect happened in between, so the share is dropped instead of sent
into the void, and counted via the new staleSubmitsDropped so the race is
observable rather than silent.

Deliberately scoped to just this bug: a smaller residual race some sibling
miners close with a session-gate (workers keep mining the old job for a
short window after reconnect until the new session's first job arrives)
and separately-known gaps (missing read deadlines, flat retry backoff) are
not addressed here.

go build/go vet/gofmt/go build -race all clean. No live reconnect-race
reproduction attempted -- would need a test harness that can force a
mid-submit reconnect deterministically; correctness argued from code
review plus the fail-safe property that a bug in this logic can only ever
cause an occasional over-cautious skip, the same risk category as the bug
being fixed, never a wrong or corrupted submission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review feedback on the previous commit: submitShare ran inline on
whichever mining goroutine found a share, blocking that goroutine on
connection_mutex plus a synchronous network write (WriteJSON) before it
could get back to hashing. Under lock contention or a slow/stalling
connection, that's real hash time lost on the thread that just did the
work to find a share -- worse than just "a mutex is briefly held," since
a network write's latency is not bounded the way a plain in-memory
critical section's is.

Replaced with a channel (submitQueue) and one dedicated submitWorker
goroutine, started once from main() alongside getwork(). Mining threads
now call enqueueShare(), a non-blocking channel send, and go straight back
to hashing -- all real submission I/O (the mutex, the network write)
happens on submitWorker alone. The connectionEpoch staleness check from
the previous commit moves with it: submitWorker re-checks the epoch
immediately before WriteJSON, same correctness property as before (a
reconnect between finding a hash and the actual write means the share
gets dropped, not sent with a stale JobID), just off the hot path now.

submitQueueSize (64) is generous relative to realistic share-find rates,
so the new submitQueueOverflowed counter (distinct from
staleSubmitsDropped -- different failure mode, a stuck submitter rather
than a merely-late one) should stay at 0 in practice; a full queue would
mean the submitter itself is stuck, not normal load.

go build/go vet/gofmt/go build -race all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous two commits added the counter but never displayed it
anywhere -- an operator had no way to actually see the race being
defended against without attaching a debugger. Adds it next to Rejected
in the readline prompt, the same place every other live mining stat
already surfaces.

Verified live against the simulator (with the not-yet-merged
fix/simulator-get-work-server getwork-server patch applied locally, for
testing only -- not part of this branch): normal mining end-to-end
through the new enqueueShare/submitWorker path works cleanly (job fetch,
hashing, share submission, blocks accepted, height climbing steadily,
StaleSubmits at 0 under normal operation). Restarting the simulator
mid-mining to force real reconnects is the next step, watching this exact
field to confirm it moves instead of the miner silently losing shares.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live, not by inspection: ran the fixed miner against the simulator
(with fix/simulator-get-work-server's getwork patch applied locally for
testing) and repeatedly killed/restarted the simulator mid-mining to force
real reconnects. submitWorker panicked -- recovered by the existing
globals.Recover(1), so no crash, but firing on every queued share for the
whole 10s retry-sleep window after a failed dial (100k+ times in a few
seconds at 8 threads' hash rate).

Root cause: connectionEpoch only advances on a *successful* dial, but
getwork's dial call assigns connection = nil on a *failed* one regardless
(websocket.Dial's return value on error) without bumping the epoch. So a
share whose epoch still matches connectionEpoch isn't actually guaranteed
to have a live connection to submit over -- it only guarantees no
*successful* reconnect happened since the job was fetched. A failed
reconnect attempt leaves epoch unchanged while connection sits nil for the
whole retry-sleep window, and submitWorker's epoch check alone let those
through to a WriteJSON call on a nil *websocket.Conn.

Fixed with a nil check inside connection_mutex, alongside the epoch check,
counted through the same staleSubmitsDropped -- a nil connection is the
same kind of "can't submit this right now" as a stale epoch, not a
different failure class worth its own counter.

Re-ran the same repeated-kill/restart test after this fix: 0 panics, all
8 reconnect cycles survived, StaleSubmits climbed into the hundreds of
thousands (expected at this aggressiveness -- restarting roughly every
1.5s against 8 threads hashing at simulator-trivial difficulty), and the
miner kept mining and successfully submitting blocks throughout (BLOCKS
counter kept climbing across and after every cycle). This is the
strongest evidence in this branch that the actual race is being handled
correctly, not just plausibly-argued from code review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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