Summary
On a steady-state 4-node cluster (hours of uptime, no restarts), a subset of GET /eth/v1/validator/attestation_data requests from the Nimbus validator client are never answered by Charon. They hang until the VC's own client-side timeout cancels them (~4.0s, occasionally ~10.0s for aggregate_beacon_committee_selections), rather than Charon responding or erroring on its own. A packet capture of the relevant beacon-node call in one such window shows the beacon node itself responding in ~30ms, so the request is not waiting on the beacon node. Two pprof goroutine dumps, taken ~16 minutes apart during separate live hangs, both show the request parked inside DutyDB.AwaitAttestation. This reproduces repeatedly (roughly 15-25% of attester duties in the observed windows) and is not limited to a startup window — the two goroutine dumps were both taken well after the cluster had settled.
Charon version
v1.10.3 (obolnetwork/charon/v1.10.3-e60c838, amd64-linux)
Environment
- 4-node DV cluster
- Consensus protocol: QBFT v2.0
- Beacon node behind Charon: Nimbus BN
v26.7.0 and v26.8.0
- Validator client: Nimbus VC
v26.7.0 and v26.8.0
- CPU, memory, and disk I/O all nominal throughout the observation window
(see Metrics below)
Expected behavior
attestation_data should be answered within tens of milliseconds, as it is for the majority of duties — for example:
11:57:15.000 DEBG qbft QBFT consensus instance starting {"duty": "15119384/attester", ...}
11:57:15.017 DEBG qbft QBFT upon rule triggered {"rule": "justified_pre_prepare", "round": 1, "duty": "15119384/attester"}
11:57:15.020 DEBG qbft QBFT upon rule triggered {"rule": "quorum_prepares", "round": 1, "duty": "15119384/attester"}
11:57:15.046 DEBG qbft QBFT upon rule triggered {"rule": "quorum_commits", "round": 1, "duty": "15119384/attester"}
11:57:15.046 DEBG qbft QBFT consensus decided {"duty": "attester", "slot": 15119384, "round": 1, ...}
11:57:15.080 INFO bcast Successfully submitted v2 attestations to beacon node {"delay": "80.726095ms", "duty": "15119384/attester", ...}
Actual behavior
For the affected duties, no qbft component log line appears at all for that duty — no consensus start, no round activity — while the duties immediately before and after it in the same log complete normally as above. The corresponding attestation_data request instead sits until the VC cancels it:
DEBG vapi Validator api 4xx response {"status_code": 408, "message": "client cancelled request",
"error": "api error[status=408,msg=client cancelled request]: context canceled",
"duration": "4.003078158s", "vapi_endpoint": "attestation_data"}
Evidence
General timing pattern
Across every observed instance (failing and succeeding duties alike), Charon issues GET /eth/v1/beacon/blocks/head/root to the upstream beacon node a consistent ~2.00s after the SSE head event for that slot. For failing duties, this call is the last thing logged before the VC's request is cancelled 4.00-4.05s later; for succeeding duties, consensus has already decided before this call fires. Three concrete, timestamp-correlated instances are below.
Instance 1 — packet capture
Charon debug log (all times CEST, UTC+2):
Sep 01 11:33:26 eth charon[40072]: 11:33:26.650 DEBG vapi Proxying request to beacon node {"url": "http://127.0.0.1:5052", "vapi_proxy_method": "GET", "vapi_proxy_path": "/eth/v1/beacon/blocks/head/root"}
Sep 01 11:33:30 eth charon[40072]: 11:33:30.652 DEBG vapi Validator api 4xx response {"status_code": 408, "message": "client cancelled request", "error": "api error[status=408,msg=client cancelled request]: context canceled", "duration": "4.00201019s", "vapi_endpoint": "attestation_data"}
tcpdump of the same call (frame times are UTC, i.e. 2 hours behind the CEST log timestamps above — 09:33:26.65 in the
capture is the same instant as 11:33:26.650 in the log):
No. Time Source Destination Protocol Length Info
459 09:33:26.650408 127.0.0.1 127.0.0.1 TCP 74 36352 -> 5052 [SYN] ...
460 09:33:26.650419 127.0.0.1 127.0.0.1 TCP 74 5052 -> 36352 [SYN, ACK] ...
461 09:33:26.650427 127.0.0.1 127.0.0.1 TCP 66 36352 -> 5052 [ACK] ...
462 09:33:26.650540 127.0.0.1 127.0.0.1 HTTP 217 GET /eth/v1/beacon/blocks/head/root HTTP/1.1
463 09:33:26.650547 127.0.0.1 127.0.0.1 TCP 66 5052 -> 36352 [ACK] ...
464 09:33:26.680164 127.0.0.1 127.0.0.1 TCP 235 5052 -> 36352 [PSH, ACK] [TCP PDU reassembled in 466]
465 09:33:26.680172 127.0.0.1 127.0.0.1 TCP 66 36352 -> 5052 [ACK] ...
466 09:33:26.680180 127.0.0.1 127.0.0.1 HTTP/JSON 199 HTTP/1.1 200 OK , JSON (application/json)
467 09:33:26.680183 127.0.0.1 127.0.0.1 TCP 66 36352 -> 5052 [ACK] ...
468 09:33:26.680192 127.0.0.1 127.0.0.1 TCP 66 5052 -> 36352 [FIN, ACK] ...
469 09:33:26.680276 127.0.0.1 127.0.0.1 TCP 66 36352 -> 5052 [FIN, ACK] ...
470 09:33:26.680292 127.0.0.1 127.0.0.1 TCP 66 5052 -> 36352 [ACK] ...
The SYN at frame 459 (09:33:26.650408) lines up with the Proxying request log line at 11:33:26.650. The full TCP exchange — connect, request, 200 OK JSON response, teardown — completes by 09:33:26.680292, roughly 30ms after it started. The attestation_data request is not cancelled until 11:33:30.652, exactly 4.00201019s after the Proxying request log line.
Instance 2 — pprof dump correlated with log, duty 15119321
Charon debug log:
11:44:38.056 DEBG app-start SSE head event {"slot": 15119321, ...}
11:44:40.058 DEBG vapi Proxying request to beacon node {"url": "http://127.0.0.1:5052", "vapi_proxy_method": "GET", "vapi_proxy_path": "/eth/v1/beacon/blocks/head/root"}
11:44:44.061 DEBG vapi Validator api 4xx response {"status_code": 408, "duration": "4.003078158s", "vapi_endpoint": "attestation_data"}
11:44:45.009 DEBG vapi Validator api 4xx response {"status_code": 408, "duration": "10.000526116s", "vapi_endpoint": "aggregate_beacon_committee_selections"}
A pprof goroutine dump was taken at 11:44:41 — i.e. during the hang, between the Proxying request line and the eventual 408:
$ date && curl http://127.0.0.1:3700/debug/pprof/goroutine?debug=2
Tue Sep 1 11:44:41 CEST 2026
...
goroutine 3642 [select]:
github.com/obolnetwork/charon/core/dutydb.(*MemDB).AwaitAttestation(0x3c0fc45c3b00, {0x21f49c8, 0x3c0fc4c8fa40}, 0xe6b3d9, 0x0)
/workspace/core/dutydb/memory.go:198 +0x527
github.com/obolnetwork/charon/core/validatorapi.Component.AttestationData({...}, ...)
/workspace/core/validatorapi/validatorapi.go:266 +0x24f
github.com/obolnetwork/charon/core/validatorapi.NewRouter.attestationData.func5(...)
/workspace/core/validatorapi/router.go:524 +0xb9
...
0xe6b3d9 = decimal 15119321, i.e. this goroutine is the exact request shown timing out in the log above.
The same dump also shows a second request, for aggregate_beacon_committee_selections, blocked the same way in a different
component:
goroutine 3424 [select]:
github.com/obolnetwork/charon/core/aggsigdb.(*MemDB).Await(0x3c0fc4a1cba0, {0x21f49c8, 0x3c0fc5501950}, {0x3c0fc52631f0?, 0x0?}, {0x3c0fc52631f0, 0x62})
/workspace/core/aggsigdb/memory.go:106 +0x308
github.com/obolnetwork/charon/core/validatorapi.Component.BeaconCommitteeSelections({...}, ...)
/workspace/core/validatorapi/validatorapi.go:793 +0x827
...
And two further goroutines, for earlier slots, blocked in the same AggSigDB.Await call but with no live HTTP request attached — these were spawned via the async-retry wrapper:
goroutine 1056 [select, 1 minutes]:
github.com/obolnetwork/charon/core/aggsigdb.(*MemDB).Await(...)
/workspace/core/aggsigdb/memory.go:106 +0x308
github.com/obolnetwork/charon/core/fetcher.(*Fetcher).fetchAggregatorData(...)
/workspace/core/fetcher/fetcher.go:291 +0x2ed
github.com/obolnetwork/charon/core/fetcher.(*Fetcher).Fetch(...)
/workspace/core/fetcher/fetcher.go:159 +0xa67
github.com/obolnetwork/charon/app.wireCoreWorkflow.WithAsyncRetry.func12.1.1(...)
/workspace/core/retry.go:17 +0x32
github.com/obolnetwork/charon/app/retry.(*Retryer[...]).DoAsync(...)
/workspace/app/retry/retry.go:135 +0x72e
...
goroutine 2668 [select]:
(identical stack, different slot)
Instance 3 — second pprof dump, ~16 minutes later, duty 15119385
Charon debug log:
11:57:26.421 DEBG app-start SSE head event {"slot": 15119385, ...}
11:57:28.422 DEBG vapi Proxying request to beacon node {"url": "http://127.0.0.1:5052", "vapi_proxy_method": "GET", "vapi_proxy_path": "/eth/v1/beacon/blocks/head/root"}
11:57:32.425 DEBG vapi Validator api 4xx response {"status_code": 408, "duration": "4.003295409s", "vapi_endpoint": "attestation_data"}
pprof dump taken at 11:57:30, again during the hang:
$ date && curl http://127.0.0.1:3700/debug/pprof/goroutine?debug=2
Tue Sep 1 11:57:30 CEST 2026
...
goroutine 34489 [select]:
github.com/obolnetwork/charon/core/dutydb.(*MemDB).AwaitAttestation(0x3c0fc45c3b00, {0x21f49c8, 0x3c0fc576b7a0}, 0xe6b419, 0x0)
/workspace/core/dutydb/memory.go:198 +0x527
github.com/obolnetwork/charon/core/validatorapi.Component.AttestationData({...}, ...)
/workspace/core/validatorapi/validatorapi.go:266 +0x24f
...
0xe6b419 = decimal 15119385, again matching the request shown timing out above. Duties 15119384 and 15119386 (immediately before and after) both completed normally, as shown in the "Expected behavior" section.
Metrics reviewed
/metrics on the beacon node, captured during an active hang window:
ticks_delay (time to run the beacon node's onSecond loop; nominal is
~1.0): 1.000878542
- Per-block processing fields logged alongside each
Block resolved line
(sigVerifyDur, stateVerifyDur, stateDataDur, putBlockDur):
consistently ~10-30ms across both successful and failing slots, with no
correlation to which duties fail
process_cpu_seconds_total, nim_gc_mem_bytes,
sqlite3_memory_used_bytes, db_checkpoint_seconds_total: nothing
anomalous in the captured snapshot
What we've ruled out
- Slow response from the upstream beacon node — Instance 1's packet
capture shows the specific call Charon makes to the beacon node completing
in ~30ms.
- Network/loopback issues — same packet capture: clean SYN/ACK, request,
200 OK, and teardown with no retransmissions.
- Host resource exhaustion — CPU, disk I/O, and memory were all normal
during the observation windows; the beacon node's own event-loop-lag metric
(ticks_delay) stayed at its nominal ~1.0 value.
- Other VC — Seems to only happen using Nimbus VC
Additional info
- We have a live, currently-affected v1.10.3 cluster and can gather further
debug logs, additional pprof dumps (CPU, block, or mutex profiles), or test
a patched/instrumented build if that would help.
- Happy to attach the complete raw logs and full goroutine dumps (trimmed
above for readability) if useful.
- If this is already addressed in a later release, a pointer to
the relevant change would be appreciated so we can upgrade and confirm.
Summary
On a steady-state 4-node cluster (hours of uptime, no restarts), a subset of
GET /eth/v1/validator/attestation_datarequests from the Nimbus validator client are never answered by Charon. They hang until the VC's own client-side timeout cancels them (~4.0s, occasionally ~10.0s foraggregate_beacon_committee_selections), rather than Charon responding or erroring on its own. A packet capture of the relevant beacon-node call in one such window shows the beacon node itself responding in ~30ms, so the request is not waiting on the beacon node. Twopprofgoroutine dumps, taken ~16 minutes apart during separate live hangs, both show the request parked insideDutyDB.AwaitAttestation. This reproduces repeatedly (roughly 15-25% of attester duties in the observed windows) and is not limited to a startup window — the two goroutine dumps were both taken well after the cluster had settled.Charon version
v1.10.3(obolnetwork/charon/v1.10.3-e60c838,amd64-linux)Environment
v26.7.0andv26.8.0v26.7.0andv26.8.0(see Metrics below)
Expected behavior
attestation_datashould be answered within tens of milliseconds, as it is for the majority of duties — for example:Actual behavior
For the affected duties, no
qbftcomponent log line appears at all for that duty — no consensus start, no round activity — while the duties immediately before and after it in the same log complete normally as above. The correspondingattestation_datarequest instead sits until the VC cancels it:Evidence
General timing pattern
Across every observed instance (failing and succeeding duties alike), Charon issues
GET /eth/v1/beacon/blocks/head/rootto the upstream beacon node a consistent ~2.00s after the SSEheadevent for that slot. For failing duties, this call is the last thing logged before the VC's request is cancelled 4.00-4.05s later; for succeeding duties, consensus has already decided before this call fires. Three concrete, timestamp-correlated instances are below.Instance 1 — packet capture
Charon debug log (all times CEST, UTC+2):
tcpdumpof the same call (frame times are UTC, i.e. 2 hours behind the CEST log timestamps above —09:33:26.65in thecapture is the same instant as
11:33:26.650in the log):The SYN at frame 459 (
09:33:26.650408) lines up with theProxying requestlog line at11:33:26.650. The full TCP exchange — connect, request, 200 OK JSON response, teardown — completes by09:33:26.680292, roughly 30ms after it started. Theattestation_datarequest is not cancelled until11:33:30.652, exactly4.00201019safter theProxying requestlog line.Instance 2 — pprof dump correlated with log, duty 15119321
Charon debug log:
A
pprofgoroutine dump was taken at11:44:41— i.e. during the hang, between theProxying requestline and the eventual408:0xe6b3d9= decimal15119321, i.e. this goroutine is the exact request shown timing out in the log above.The same dump also shows a second request, for
aggregate_beacon_committee_selections, blocked the same way in a differentcomponent:
And two further goroutines, for earlier slots, blocked in the same
AggSigDB.Awaitcall but with no live HTTP request attached — these were spawned via the async-retry wrapper:Instance 3 — second pprof dump, ~16 minutes later, duty 15119385
Charon debug log:
pprofdump taken at11:57:30, again during the hang:0xe6b419= decimal15119385, again matching the request shown timing out above. Duties15119384and15119386(immediately before and after) both completed normally, as shown in the "Expected behavior" section.Metrics reviewed
/metricson the beacon node, captured during an active hang window:ticks_delay(time to run the beacon node'sonSecondloop; nominal is~1.0):
1.000878542Block resolvedline(
sigVerifyDur,stateVerifyDur,stateDataDur,putBlockDur):consistently ~10-30ms across both successful and failing slots, with no
correlation to which duties fail
process_cpu_seconds_total,nim_gc_mem_bytes,sqlite3_memory_used_bytes,db_checkpoint_seconds_total: nothinganomalous in the captured snapshot
What we've ruled out
capture shows the specific call Charon makes to the beacon node completing
in ~30ms.
200 OK, and teardown with no retransmissions.
during the observation windows; the beacon node's own event-loop-lag metric
(
ticks_delay) stayed at its nominal ~1.0 value.Additional info
debug logs, additional pprof dumps (CPU, block, or mutex profiles), or test
a patched/instrumented build if that would help.
above for readability) if useful.
the relevant change would be appreciated so we can upgrade and confirm.