Repository navigation
fix(common/countdown): fix data race between SetParams and GetTimeoutDuration - #2831
Merged
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
gzliudan
requested review from
AnilChinchawale,
anunay-xin,
benjamin202410,
liam-lai and
wanwiset25
October 20, 2026 09:32
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 20, 2026 09:43
e19e2ba to
d2cc2d1
Compare
benjamin202410
approved these changes
Oct 20, 2026
gzliudan
force-pushed
the
fix-countdown-race
branch
2 times, most recently
from
October 20, 2026 10:30
a637636 to
d1b3fce
Compare
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 20, 2026 12:07
d1b3fce to
6fa4a4a
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 20, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base it already overflowed int64 and wrapped around to a negative timeout, which masked the truncation above once the difference outgrew the int64 budget. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 20, 2026 15:09
6fa4a4a to
50987f5
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 20, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base it already overflowed int64 and wrapped around to a negative timeout, which masked the truncation above once the difference outgrew the int64 budget. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 21, 2026 00:11
50987f5 to
8636b24
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 21, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 22, 2026 09:05
8636b24 to
30c3b4e
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 22, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 22, 2026 14:59
30c3b4e to
93b0673
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 24, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
wanwiset25
approved these changes
Oct 26, 2026
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 26, 2026 04:10
38720cf to
1069a32
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 26, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
anunay-xin
approved these changes
Oct 26, 2026
…Duration ExpTimeoutDuration stored duration/base/maxExponent without synchronisation while the countdown goroutine read them, so a concurrent SetParams could be observed torn and a rejected parameter set could be partially visible. Guard the three fields with a sync.RWMutex: GetTimeoutDuration copies them under the read lock, SetParams validates the candidate set before taking the write lock and publishes all three at once. Rollback is no longer needed because an invalid set is never written. The signatures are unchanged. NewExpTimeoutDuration now returns a nil helper along with the error instead of a helper holding the rejected values; its only caller already drops the helper on error. Add a concurrent SetParams/GetTimeoutDuration test that reports the data race on the old code under -race, plus a test pinning that a rejected set leaves the timeout unchanged. The latter covers both rejection branches and keeps all three parameters observable, so a partial write cannot slip through. Also correct the pre-existing ")= >=" typo in the validation message now owned by checkExpParams, and assert the previously discarded SetParams errors in TestExpDuration. Fixes XinFinOrg#2828
validateV2ExpTimeoutConfig printed "max_exponent (%d)= >= max_exponent_upperbound (%d)". Drop the stray "=" so the message matches the one emitted by checkExpParams in common/countdown for the same bound. Message text only; the validation behaviour is unchanged.
CountdownTimer.isInitilised took the write lock to read a single bool, while the read path of ExpTimeoutDuration takes the read lock. Use RLock for the read so both helpers in the package follow the same rule. No behaviour change: initilised is only written by setInitilised, and the two helpers are never called while the other's lock is held. Pre-existing (blame 521b703), not introduced by the SetParams and GetTimeoutDuration race fix.
gzliudan
force-pushed
the
fix-countdown-race
branch
from
October 26, 2026 05:27
1069a32 to
a0c266e
Compare
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 26, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
13 of 25 tasks
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 27, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. Also saturate the duration*base^exp product at the maximum time.Duration: with a large base the product already overflowed int64 and wrapped around to a negative timeout, so the timer retriggered immediately. This is a separate defect from the truncation above: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. Both defects are pre-existing since the package was introduced (XinFinOrg#764) and are independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 27, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. This defect is pre-existing since the package was introduced (XinFinOrg#764) and is independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 27, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. This defect is pre-existing since the package was introduced (XinFinOrg#764) and is independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 27, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. This defect is pre-existing since the package was introduced (XinFinOrg#764) and is independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
added a commit
to gzliudan/XDPoSChain
that referenced
this pull request
Oct 28, 2026
…exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. This defect is pre-existing since the package was introduced (XinFinOrg#764) and is independent of XinFinOrg#2831. close XinFinOrg#2835
gzliudan
added a commit
that referenced
this pull request
Oct 29, 2026
…tic and validate its bounds (#2836) * fix(common/countdown): keep the full round difference in the timeout exponent The exponent was derived from a uint8 conversion of the round difference, which truncates it modulo 256. A difference of 257 therefore collapsed the timeout to its minimum instead of saturating at maxExponent, and the window recurs every 256 rounds. Compute the difference in uint64 instead. This defect is pre-existing since the package was introduced (#764) and is independent of #2831. close #2835 * fix(common/countdown): saturate the timeout at the maximum time.Duration duration*base^exp can overflow int64 for a long round difference and a large base, wrapping around to a negative timeout; the timer then retriggers immediately. Saturate at math.MaxInt64 instead. This is a separate defect from the truncation in the previous commit: truncation recurs every 256 rounds, while the overflow depends on the exponent, and each can occur without the other. It is pre-existing since the package was introduced (#764). The saturated value never fires in practice; a chain whose own maximum exponential timeout is unrepresentable is rejected by the chain config validation at startup. * fix(params): reject XDPoS v2 exp timeouts that overflow time.Duration common/countdown arms the pacemaker timer with timeout_period * base^exp. The engine converts the period with time.Duration(TimeoutPeriod) * time.Second first, then common/countdown multiplies by base^exp; when either step leaves the int64 range the timer saturates at math.MaxInt64 (~292 years) and never fires, so the node silently stops producing timeout messages. validateV2ExpTimeoutConfig only bounded max_exponent and base^max_exponent, which admits such a genesis: with base=2, maxExponent=30 and the 30s mainnet timeout period the product already overflows from round difference 30 on. Bound both steps at the time.Duration range in whole seconds, so a chain whose own maximum exponential timeout is unrepresentable fails at startup instead of running until the round difference grows. The per-step bound matters: with base < 1, base^max_exponent shrinks the multiplier, so a single combined check would let an overflowing period through. No network shipped in this repository is affected: all 19 built-in V2Config entries and every genesis file use base=1 and maxExponent=0, where the product is just timeout_period seconds. Also correct the TimeoutPeriod comment, which claims milliseconds while every consumer multiplies by time.Second. go-ethereum has no common/countdown package and no equivalent chain-config check. The closest upstream precedent is geth ethereum#31407 (eth/catalyst: sanitize simulated beacon period to avoid overflowing time.Duration), which clamps the period with an integer bound instead; this commit rejects the config instead, because a saturated pacemaker stalls silently while a rejected config fails loudly at startup. Checking the largest exponent covers every smaller one only while base >= 1; with base < 1 the largest timeout is at exponent 0, which the period conversion check above bounds on its own.
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.
Proposed changes
ExpTimeoutDurationincommon/countdownstored its three parameters (duration,base,maxExponent) without synchronisation while the countdown goroutine read them throughGetTimeoutDuration. In production the two sides run concurrently: the checkpoint loop callsengine.UpdateParams(cmd/XDC/main.go:389) →XDPoS_v2.UpdateParams(consensus/XDPoS/engines/engine_v2/engine.go:167) →SetParams, while the timer goroutine reads the same fields instartTimer(common/countdown/countdown.go:67/87/91).SetParamswrote the three fields one by one and rolled them back one by one when the sanity check failed, so a concurrent reader could observe a mix of the old and the new set and compute a timeout that belongs to neither configuration. That timeout drives XDPoS v2 round progression, so a wrong value makes a node time out earlier or later than the rest of the network.This PR guards the three fields with a
sync.RWMutex:GetTimeoutDurationtakes the read lock and computes from a consistent snapshot of the three fields.SetParamsvalidates the candidate set first, then publishes all three fields at once under the write lock. An invalid set is never written, so the per-field rollback path is gone and a rejected set can no longer become partially visible.The signatures are unchanged.
NewExpTimeoutDurationnow returns a nil helper together with the error instead of a helper holding the rejected values; its only caller (NewExpCountDown) already drops the helper on error.The upstream go-ethereum has no
countdownpackage, so there is no upstream fix to port.Types of changes
Put an
✅in the boxes that applyImpacted Components
Which parts of the codebase does this PR touch?
Verification
make allgo run build/ci.go lint ./common/countdown/...→ 0 issuesgo test -race -count=1 ./common/countdown/...→ okgofmt -l/goimports -lon the touched files → cleanTestExpTimeoutDurationConcurrentSetParams, run against the pre-fixexp_duration.gothroughgo test -overlay, reportsWARNING: DATA RACE— write atexp_duration.go:56(SetParams) and read atexp_duration.go:48(GetTimeoutDuration) — the same pair the issue reports. Against the fixed file the same test passes under-race.-race: the CI test job runsgo run build/ci.go test -coveragewithout-race, so a plain CI run would not catch this race. The race job comes from ci: run the short suite under the race detector, close #2700 #2797 (ci: run the short suite under the race detector); this test is not skipped in-short, so it is covered once that PR lands.go test -race -count=1 -run 'TestIsYourTurnConsensusV2CrossConfig|TestTimeoutPeriodAndThreadholdConfigChange' ./consensus/tests/engine_v2_tests/passes on this branch (33.6s). Withexp_duration.goreplaced by the pre-fix version through-overlay, both tests fail withWARNING: DATA RACE: the write atexp_duration.go:56throughcountdown.go:52(engine.UpdateParams,engine.go:167) races the read atexp_duration.go:48throughcountdown.go:67andcountdown.go:87.maxExponentalone is invisible withexp = min(1, maxExponent)), the rejected-set test covers both validation branches and makes all three parameters observable, the nil-on-error contract ofNewExpTimeoutDurationis asserted, the pre-existing)= >=typo in both copies of the validation message is fixed, andTestExpDurationno longer discardsSetParamserrors.CountdownTimer.isInitilised(common/countdown/countdown.go) now readsinitilisedunderRLockinstead of the write lock, matching the read path ofExpTimeoutDuration. No behaviour change: the field is only written bysetInitilised, and neither helper is called while the other lock is held. This is pre-existing (blame521b703207) and unrelated to theSetParams/GetTimeoutDurationrace.Checklist
Put an
✅in the boxes once you have confirmed below actions (or provide reasons on not doing so) thatFixes #2828