Repository navigation
Conversation
… in order One stream from R2 through Cloudflare caps ~95 MB/s; 16 ranged workers measured 382 MB/s against a snapshot on the same edge. Chunks are retried until complete, so a dropped connection costs one 64 MiB chunk instead of the stream, and --max-filesize refuses a Range answered with 200 + the whole body, which a cold, cache-eligible object on Cloudflare returns and which curl -C treated as fatal (33). Verified by rendering this script and running it in alpine:3.19.1 against a 448 MB snapshot at 4 MiB chunks: the extracted tree's hash matches the source.
erigontech/erigon#24117 (merged 2026-09-22) added a QUIC transport to Caplin. Its port (`--sentinel.quic.port`) defaults to **4001**, the same port this package passes as `--discovery.port`. Caplin now exits at startup with: ``` Error: discovery and QUIC ports must differ: 4001 ``` Every run with a `caplin` participant fails, e.g. `mix-with-tools.yaml` on #1489, which doesn't touch Caplin at all. This PR gives QUIC its own port (4002, or the 4th published port when port publishing is on) and declares it as `quic-discovery`, the same way the lighthouse launcher does. Verified against `ethpandaops/caplin:main` (built 2026-09-26): - with the current flags it exits with the error above; - with `--sentinel.quic.port=4002` added, the QUIC listener starts. `kurtosis lint` is clean. https://claude.ai/code/session_01LMoaDSkP7fGBZGm25FZc5x
There was a problem hiding this comment.
Summary
Replaces the single resumable curl stream with 16 workers fetching 64 MiB ranges, emitted in order into the same tar -I zstd -x. I simulated the script under dash with stubbed ranged curl: output is byte-identical and in order, the 2×workers staging bound holds, the flow-control gate cannot deadlock, and a failed chunk makes tar fail on truncation so the step exits loudly (no /tmp/finished). I also confirmed the cold-Range 200 hazard exists on snapshots.ethpandaops.io today and that --max-filesize guards it. One latent regression: the chunk GETs dropped the -L the old loop had, so a redirecting (user-configurable) snapshot base would fail after ~42 min of retries.
Issues
- 🟡
src/network_launcher/shadowfork.star:34— chunk GETs dropped -L, so a redirecting snapshot base retries forever — see the thread on that line
Reviewed @ e2554623
"Don't drink the bear's skin before you've shot it." — Hungarian
| s=$(( $1 * C )); e=$(( s + C - 1 )); [ "$e" -lt "$TOTAL" ] || e=$(( TOTAL - 1 )) | ||
| want=$(( e - s + 1 )); tries=0 | ||
| while :; do | ||
| curl -sf --connect-timeout 20 --speed-limit 1024 --speed-time 120 --max-filesize "$want" \ |
There was a problem hiding this comment.
🟡 chunk GETs dropped -L, so a redirecting snapshot base retries forever
The old resume loop used curl -sfL, and the TOTAL probe still uses -sfIL, but the ranged chunk fetches use plain curl -sf: a 3xx is not an error for -f, so every chunk would fetch a redirect body, mismatch the size check, and retry 500×5 s before failing. The default endpoint returns 200 directly (verified), but network_sync_base_url is user-configurable, so re-adding -L (and ideally --proto-redir or -w '%{http_code}' to fail on 3xx) keeps the old behaviour.
There was a problem hiding this comment.
Fixed in b344b48: the chunk fetches are curl -sfL again. The default host is affected too when written as http://: it answers 301 → https. Measured on that redirect:
- without
-L: exit 0 and a 167-byte redirect body; - with
-L: 206 and exactly the requested bytes, since curl re-sends theRange.
End to end in alpine:3.19.1, a 256 MiB prefix fetched through the http:// URL with 8 workers and 32 MiB chunks has the same sha256 as a direct https range fetch. No --proto-redir: a redirect off-scheme still has to deliver exactly the requested length, or the chunk is retried and the step fails loudly.
There was a problem hiding this comment.
Addressed. Commit b344b48 re-adds -L to the ranged chunk fetch — src/network_launcher/shadowfork.star:34 now reads curl -sfL --connect-timeout 20 --speed-limit 1024 --speed-time 120 --max-filesize "$want" -r "$s-$e" ..., matching the old resume loop's -sfL behaviour, and curl re-sends the Range on the redirected request, so the size check still guards the delivered bytes.
Two notes on how the fix holds up, both grounded in the code as it is now:
- My original comment also suggested optionally failing fast on a 3xx with
--proto-rediror a-w '%{http_code}'check. That was deliberately not taken: the final defence is the exact-length check at src/network_launcher/shadowfork.star:38 — a redirect to another scheme or off-host still has to hand back exactlywantbytes or the chunk retries and eventuallytouch "$T/failed"fails the run, so the "retries forever with a redirect body" failure mode is closed regardless of where the redirect points. Reasonable trade-off; I'm fine with it. - The redirected-response path also interacts with
--max-filesize "$want"in a way worth noting: for a 3xx answered without following (i.e. no-L), curl 8.x applies--max-filesizeto the response it is about to write, so a small redirect body still passes and would have been size-checked as before — which is exactly the retry-500-times scenario from the original comment. With-sfLrestored that path is no longer reachable for the chunk fetches, so it's moot here; mentioning it only because theTOTALprobe at line 24 still relies on-I+ header parsing and remains correct for the same reason.
@ b344b481
The chunk GETs lost the -L the old resume loop had. curl -f treats a 3xx as success, so a redirecting base (e.g. http:// -> https://) saved the redirect body, failed the size check and retried until the attempt cap. curl re-sends the Range on the redirected request: http:// on shadowfork-images.ethpandaops.io now returns 206 with exactly the requested bytes. Claude-Session: https://claude.ai/code/session_01LMoaDSkP7fGBZGm25FZc5x
There was a problem hiding this comment.
The PR replaces the single resumable curl stream with 16 parallel ranged-GET workers feeding an in-order emitter into the same tar -I zstd -x pipeline. I re-traced the final script and empirically simulated the worker/emitter protocol in POSIX sh: ordered emission holds under heavy backpressure (N=100, window 2P=8), the failed flag propagates loudly through stream|tar, P>N is safe, and the size-only chunk check cannot be fooled by stale or poisoned .part files. The previously open finding — chunk GETs dropped -L, so a redirecting snapshot base would retry forever — is fixed by commit b344b48 (curl -sfL on the chunk fetch).
Reviewed 1 changed file(s) @ b344b481 — no blocking issues found.
"Don't drink the bear's skin before you've shot it." — Hungarian
|
@barnabasbusa could you take a look when you have a moment? Review of Checked, looks correct
Worth considering (non-blocking)
As the description says, this still needs a real mainnet (or hoodi/sepolia) shadowfork enclave run before merge. |
Two gaps in the downloader script: - The INT/TERM trap killed the workers but never exited, and sh defers traps while a foreground command runs anyway, so stopping the service (mid-download or while it idles on `tail`) waited out the grace period and was SIGKILLed. The tar pipeline and the keep-alive tail now run in the background under `wait`, which a trapped signal interrupts, and INT/TERM exit through the EXIT cleanup. - sh has no pipefail: if a worker gave up and the stream stopped on a zstd frame boundary, tar could exit 0 and /tmp/finished was written over an incomplete datadir. Check the failed marker before finishing. Rendered from the .star and run in alpine:3.19.1 against a throttled Range-serving nginx: stop mid-download 30s/SIGKILL -> 171ms/143, idle stop -> 193ms/143, a full download is still byte-identical with /tmp/finished written. kurtosis lint passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERPbqAZJorJTKnf2YBVyDy
Chunks are emitted into tar in order, so one connection trickling just above the old limit (1 KB/s over 120s) never timed out and stalled the whole stream. Seen on a 33-node glamsterdam shadowfork restore: one host sat on a single chunk for 6 minutes. A healthy connection runs ~6 MB/s; a throttled transfer now aborts and the chunk is retried on a fresh connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERPbqAZJorJTKnf2YBVyDy
Preserve validated partial ranges and relax worker speed thresholds after timeouts that make progress. Bound consecutive failures without progress, validate range responses, and make the restore timeout configurable with a 72-hour default. Add 11 Docker integration tests using Alpine curl and tar, and run them in PR CI. Validation: integration tests, kurtosis lint, and git diff --check pass.
There was a problem hiding this comment.
Summary
The shadowfork downloader now fetches the snapshot as 16 parallel ranged GETs (64 MiB chunks, resumed by absolute offset, emitted in order into the same tar stream) with a configurable plan.wait timeout. I verified all three previously-open findings are fixed: chunk GETs carry -L again; tar failure now fails the step (stream | tar … & + wait $! under set -eu, errexit on a failed wait confirmed in dash, with the frame-boundary case covered by the $T/failed check); and curl 8.5 (Alpine 3.19) enforces --max-filesize on every body write independent of Content-Length (lib/sendf.c cw_download_write), with the Content-Range equality check as a second gate. I rendered the script and ran it against a local Range server: 206 success, dropped-connection resume, backpressure with 12 chunks/window 4, and persistent-200 refusal (loud failure, no /tmp/finished) all behave correctly with byte-identical output. One remaining defect: the EXIT trap kills the wrapper subshells but not the inner worker subshells.
Issues
- 🟡
src/network_launcher/shadowfork.star:30— EXIT trap kills the wrapper subshells, not the worker subshells — see the thread on that line
Reviewed @ 9b05da6c
"Don't drink the bear's skin before you've shot it." — Hungarian
Remove the wrapper subshells so recorded PIDs identify the fetch workers. Interrupt worker waits on TERM, stop and reap active curl processes, and wait for workers before removing staged files. Keep failure markers for unsuccessful worker exits.
There was a problem hiding this comment.
Parallel ranged-GET snapshot downloader with in-order emission into tar, plus a configurable download timeout. All four previously open findings are fixed in the current head: -L is back on chunk GETs; tar/zstd failure now aborts via wait $! under set -e (verified empirically, /tmp/finished is not touched); non-206/mismatched responses are truncated back to validated length so a 200 without Content-Length cannot corrupt progress; the worker subshell is the $! the EXIT trap kills and its own trap kills the active curl. I ran the rendered script end-to-end against a local ranged-GET server (12 chunks, 4 workers, with mid-transfer connection drops): the extracted tree matched the source and /tmp/finished was only touched on success. No new issues found.
Reviewed 4 changed file(s) @ 9a986b36 — no blocking issues found.
"Don't drink the bear's skin before you've shot it." — Hungarian
Follows #1486 and #1487. The shadowfork snapshot downloader now fetches the archive as parallel ranged GETs, emitted in order into the same
tar -I zstd -x. Nothing changes about snapshot format or layout.Why
One HTTP stream from R2 through Cloudflare tops out around 95 MB/s. The link isn't the limit. Measured on a mainnet snapshot, from two different hosts:
At one stream, a 1.16 TB geth mainnet snapshot is a ~3.5 h download. At 16 workers it's under an hour.
How
/tmp, so the extracted datadir is still the only thing on the volume.206 Content-Rangeresponse is retained.--speed-limit 1048576 --speed-time 30. If a timeout made valid progress, the worker preserves those bytes and halves its threshold, retaining that threshold across subsequent chunks. The floor is--speed-limit 1024 --speed-time 120. On a 100 Mbit/s uplink shared by 16 workers, the first timeout preserves the partial chunk and reduces the threshold to 512 KiB/s, allowing the remaining bytes to complete on the slower connection.SNAPSHOT_DOWNLOAD_MAX_ATTEMPTSlimits consecutive attempts without validated progress; saved progress resets the counter.--max-filesizebounds each response, including bodies without Content-Length with the curl version supplied by Alpine 3.19.network_params.shadowfork_download_timeoutcontrols the overall restore wait and defaults to72h, allowing slower links and participants sharing one uplink more time.Two POSIX-sh traps are handled and commented in the script:
sh; this is what hung an earlier version.kill 0would also take down thetarthe stream is piped into.Verification
.starfile and ran it inalpine:3.19.1, with 4 MiB chunks so there were many of them. It downloaded and extracted a 448 MB snapshot, and the extracted tree's hash matches the source.alpine:3.19.1busybox:curlof the range;kurtosis lintpasses.I haven't run a full mainnet shadowfork enclave with this change yet.
https://claude.ai/code/session_01LMoaDSkP7fGBZGm25FZc5x
Supersedes #1488: that PR fixed failure modes of the single-stream resume loop this one replaces; see the mapping in its closing comment.