Skip to content

Commit d1d6d68

Browse files
fix(ci): recover Nix shell downloads rejected on resume
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
1 parent 76cfd0e commit d1d6d68

8 files changed

Lines changed: 267 additions & 1 deletion

File tree

‎.agents/skills/watch-github-actions/SKILL.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ gh run list --json databaseId,status,headBranch,url --jq '.[] | {id: .databaseId
125125

126126
## View Job Logs
127127

128+
For Nix setup failures, distinguish dependency fetching from compilation.
129+
`setup-rust` retries shell preparation only for an HTTP 416 following a resume
130+
of the same NAR download, with three total attempts maximum. Cargo commands are
131+
not retried. Inspect the `nix-shell-*` artifacts for every attempt and its exit
132+
status; they are retained when preparation fails or recovers after a retry.
133+
Skipped dependent E2E suites are blocked coverage, not additional failing tests.
134+
128135
For `Trivy Changes`, inspect the `Resolve PR baseline` step for the base and head
129136
SHAs. PR runs compare the tested merge commit with its
130137
first parent; change detection and scans must use the same pair. On reruns, do

‎.github/actions/setup-rust/action.yml‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,21 @@ runs:
2424
echo "hash=$(nix hash file --type sha256 --base16 "$shell_drv")" >> "$GITHUB_OUTPUT"
2525
2626
- name: Realize Nix development shell
27+
id: realize
2728
shell: bash
28-
run: nix develop -c true
29+
run: |
30+
log_dir="$(mktemp -d "$RUNNER_TEMP/nix-shell.XXXXXX")"
31+
echo "logs=$log_dir" >> "$GITHUB_OUTPUT"
32+
bash tasks/scripts/realize-nix-dev-shell.sh "$log_dir"
33+
34+
- name: Upload Nix shell diagnostics
35+
if: ${{ always() && !cancelled() && steps.realize.outputs.logs != '' && (steps.realize.outcome == 'failure' || steps.realize.outputs.retried == 'true') }}
36+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
37+
with:
38+
name: nix-shell-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-key }}-${{ github.run_attempt }}
39+
path: ${{ steps.realize.outputs.logs }}
40+
retention-days: 5
41+
if-no-files-found: warn
2942

3043
- name: Cache Rust target and registry
3144
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2

‎.github/workflows/branch-checks.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ jobs:
219219
steps:
220220
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
221221

222+
- name: Test Nix shell recovery
223+
shell: bash
224+
run: bash tasks/scripts/test-nix-shell-recovery.sh
225+
222226
- uses: ./.github/actions/setup-nix
223227
with:
224228
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}

‎CI.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,29 @@ nix develop --command actionlint -shellcheck= -pyflakes=
176176
nix develop --command zizmor --offline --persona=regular --min-severity=high --no-exit-codes .
177177
```
178178

179+
## Nix development-shell download recovery
180+
181+
`setup-rust` realizes the Nix development shell before restoring Rust caches.
182+
If a NAR download fails with HTTP 416 after resuming that same archive, setup
183+
retries `nix develop -c true` with a fresh transfer, up to three total attempts.
184+
It waits roughly 10 and 30 seconds between attempts and keeps successfully
185+
realized store paths. Nix's internal download retries remain enabled.
186+
187+
Other fatal errors, including builder failures, hash mismatches, authentication
188+
failures and unknown diagnostics, stop setup immediately. Cargo build, test and
189+
verification commands run once. The existing job timeout bounds preparation.
190+
191+
When preparation fails or needs recovery, the action uploads `nix-shell-*`
192+
diagnostic artifacts containing each attempt's output and exit status. A warning
193+
identifies recovered downloads. A failed preparation still fails the job and
194+
blocks dependent E2E suites; those skipped suites do not indicate test failures.
195+
196+
Run the focused recovery tests without fetching any dependencies:
197+
198+
```shell
199+
bash tasks/scripts/test-nix-shell-recovery.sh
200+
```
201+
179202
## Run the security scans together
180203

181204
`Security Scan` (`.github/workflows/security-scan.yml`) calls CodeQL, Trivy,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
warning: unable to download 'https://cache.nixos.org/nar/0gb22ka922pww95cjpldc54zkz3pj997b3i2z3kmpw3pf3k086d3.nar.zst': HTTP error 200 (curl error: Failed sending data to the peer); retrying from offset 12352024 in 8 ms (attempt 1/5)
2+
error: unable to download 'https://cache.nixos.org/nar/0gb22ka922pww95cjpldc54zkz3pj997b3i2z3kmpw3pf3k086d3.nar.zst': HTTP error 416
3+
error: path '/nix/store/c8gn0q3c5zs5l2faj74s92vqhbyfnvcq-perl-5.42.0' is required, but there is no substituter that can build it
4+
error: some references of path '/nix/store/6r0ivf0hsm8lmcs0cf38m2xsp9hb45jk-dpkg-1.23.7' could not be realised
5+
error: some references of path '/nix/store/hiw6ii978dc8s153v60fzy6myzqxymm7-git-2.55.0' could not be realised
6+
error: some references of path '/nix/store/4k5q96f6rpsczkvl8qbmlw2d2mkd8sxp-nix-shell-env' could not be realised
7+
error: some substitutes for the outputs of derivation '/nix/store/6vccxdfvhxs4v6vqpg9zqnwgijc4z37z-nix-shell-env.drv' failed (usually happens due to networking issues); try '--fallback' to build derivation from source
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# Recover only a resumed NAR download rejected with HTTP 416. Never replay
6+
# Cargo commands, rebuild from source with --fallback, or discard the store.
7+
set -euo pipefail
8+
trap 'exit 130' INT
9+
trap 'exit 143' TERM
10+
11+
log_dir="${1:?Usage: realize-nix-dev-shell.sh LOG_DIRECTORY}"
12+
mkdir -p "$log_dir"
13+
14+
retryable_resume_failure() {
15+
python3 - "$1" <<'PY'
16+
import re
17+
import sys
18+
19+
url = r"https://(?:cache\.nixos\.org|openshell\.cachix\.org)/nar/[a-z0-9]+\.nar\.(?:zst|xz)"
20+
resume = re.compile(r"unable to download '(" + url + r")': .*; retrying from offset [1-9][0-9]* ")
21+
fatal = re.compile(r"error: unable to download '(" + url + r")': HTTP error 416$")
22+
propagation = [
23+
re.compile(r"error: path '/nix/store/[^']+' is required, but there is no substituter that can build it$"),
24+
re.compile(r"error: some references of path '/nix/store/[^']+' could not be realised$"),
25+
re.compile(r"error: some substitutes for the outputs of derivation '/nix/store/[^']+\.drv' failed \(usually happens due to networking issues\); try '--fallback' to build derivation from source$"),
26+
]
27+
resumed = set()
28+
found = False
29+
with open(sys.argv[1]) as log:
30+
for line in log:
31+
line = line.rstrip("\n")
32+
match = resume.search(line)
33+
if match:
34+
resumed.add(match[1])
35+
if not line.startswith("error:"):
36+
continue
37+
match = fatal.fullmatch(line)
38+
if match:
39+
if match[1] not in resumed:
40+
sys.exit(1)
41+
found = True
42+
elif not any(pattern.fullmatch(line) for pattern in propagation):
43+
# Unknown, deterministic, integrity, auth, disk and builder errors
44+
# veto retry even if another download in this attempt failed.
45+
sys.exit(1)
46+
sys.exit(0 if found else 1)
47+
PY
48+
}
49+
50+
for attempt in 1 2 3; do
51+
echo "Realizing Nix development shell (attempt $attempt/3)"
52+
if nix --log-format raw develop -c true 2>&1 | tee "$log_dir/attempt-$attempt.log"; then
53+
printf '%s\t%s\t%s\n' "$attempt" 0 success >> "$log_dir/results.tsv"
54+
if [ "$attempt" -gt 1 ]; then
55+
echo "::warning::Nix development shell recovered after $attempt attempts; see Nix shell diagnostics."
56+
fi
57+
exit 0
58+
else
59+
statuses=("${PIPESTATUS[@]}")
60+
fi
61+
nix_status="${statuses[0]}"
62+
# A broken diagnostic sink must not hide command output or report success.
63+
if [ "${statuses[1]}" -ne 0 ]; then
64+
exit "${statuses[1]}"
65+
fi
66+
printf '%s\t%s\t%s\n' "$attempt" "$nix_status" failed >> "$log_dir/results.tsv"
67+
if [ "$nix_status" -ge 128 ] || [ "$attempt" -eq 3 ] || ! retryable_resume_failure "$log_dir/attempt-$attempt.log"; then
68+
exit "$nix_status"
69+
fi
70+
echo "::warning::Nix rejected a resumed NAR download with HTTP 416; retrying shell preparation with a fresh transfer."
71+
if [ -n "${GITHUB_OUTPUT:-}" ]; then
72+
echo "retried=true" >> "$GITHUB_OUTPUT"
73+
fi
74+
# Small jitter spreads simultaneous cold-runner recoveries. Nix's own
75+
# transfer retries remain enabled inside each preparation attempt.
76+
delay=$((10 + RANDOM % 5))
77+
[ "$attempt" -eq 1 ] || delay=$((30 + RANDOM % 5))
78+
sleep "$delay"
79+
done
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
set -euo pipefail
6+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7+
test_dir="$(mktemp -d)"
8+
trap 'rm -rf "$test_dir"' EXIT
9+
mkdir -p "$test_dir/bin"
10+
export TEST_FIXTURE="$script_dir/fixtures/nix-resumed-416.txt"
11+
# Fixture excerpt: NVIDIA/OpenShell job 110463784656, October 1, 2026.
12+
13+
cat > "$test_dir/bin/nix" <<'SH'
14+
#!/usr/bin/env bash
15+
set -euo pipefail
16+
[ "$*" = '--log-format raw develop -c true' ] || exit 99
17+
count=0
18+
[ ! -f "$TEST_CASE/count" ] || count="$(cat "$TEST_CASE/count")"
19+
count=$((count + 1))
20+
echo "$count" > "$TEST_CASE/count"
21+
case "$TEST_SCENARIO" in
22+
success) echo 'shell ready'; exit 0 ;;
23+
warnings) sed -n '1p' "$TEST_FIXTURE" >&2; exit 0 ;;
24+
recover) [ "$count" -lt 2 ] || exit 0 ;;
25+
recover-third) [ "$count" -lt 3 ] || exit 0 ;;
26+
changed-failure)
27+
if [ "$count" -gt 1 ]; then
28+
echo "error: builder for '/nix/store/example.drv' failed with exit code 7" >&2
29+
exit 7
30+
fi
31+
;;
32+
standalone-416) sed -n '2p' "$TEST_FIXTURE" >&2; exit 23 ;;
33+
other-resume) sed '1s/0gb22ka/1gb22ka/' "$TEST_FIXTURE" >&2; exit 23 ;;
34+
dependency-only) sed -n '3,7p' "$TEST_FIXTURE" >&2; exit 23 ;;
35+
other-host) sed 's/cache.nixos.org/example.invalid/g' "$TEST_FIXTURE" >&2; exit 23 ;;
36+
terminated) cat "$TEST_FIXTURE" >&2; exit 143 ;;
37+
esac
38+
cat "$TEST_FIXTURE" >&2
39+
case "$TEST_SCENARIO" in
40+
builder) echo "error: builder for '/nix/store/example.drv' failed with exit code 1" >&2 ;;
41+
hash) echo 'error: hash mismatch in fixed-output derivation' >&2 ;;
42+
disk) echo 'error: No space left on device' >&2 ;;
43+
unknown) echo 'error: unexplained fatal error' >&2 ;;
44+
auth) echo "error: unable to download 'https://cache.nixos.org/test': HTTP error 401" >&2 ;;
45+
missing) echo "error: unable to download 'https://cache.nixos.org/test': HTTP error 404" >&2 ;;
46+
esac
47+
exit 23
48+
SH
49+
cat > "$test_dir/bin/sleep" <<'SH'
50+
#!/usr/bin/env bash
51+
echo "$1" >> "$TEST_CASE/delays"
52+
if [ "$TEST_SCENARIO" = cancel-backoff ]; then
53+
kill -TERM "$PPID"
54+
fi
55+
SH
56+
chmod +x "$test_dir/bin/nix" "$test_dir/bin/sleep"
57+
export PATH="$test_dir/bin:$PATH"
58+
59+
fail() { echo "FAIL: $*" >&2; exit 1; }
60+
check_case() {
61+
export TEST_SCENARIO="$1" TEST_CASE="$test_dir/$1"
62+
export GITHUB_OUTPUT="$TEST_CASE/outputs"
63+
mkdir -p "$TEST_CASE"
64+
local status=0
65+
bash "$script_dir/realize-nix-dev-shell.sh" "$TEST_CASE/logs" > "$TEST_CASE/console" 2>&1 || status=$?
66+
[ "$status" -eq "$2" ] || fail "$1: expected exit $2, got $status"
67+
[ "$(cat "$TEST_CASE/count")" -eq "$3" ] || fail "$1: incorrect invocation count"
68+
local attempt
69+
for ((attempt=1; attempt<=$3; attempt++)); do
70+
[ -f "$TEST_CASE/logs/attempt-$attempt.log" ] || fail "$1: missing attempt $attempt log"
71+
done
72+
[ "$(wc -l < "$TEST_CASE/logs/results.tsv" | tr -d ' ')" -eq "$3" ] || fail "$1: missing result statuses"
73+
if [ "$3" -gt 1 ]; then
74+
grep -q 'retried=true' "$GITHUB_OUTPUT" || fail "$1: missing retry marker"
75+
grep -q 'HTTP error 416' "$TEST_CASE/logs/attempt-1.log" || fail "$1: original failure lost"
76+
local delay lower=10
77+
while read -r delay; do
78+
[ "$delay" -ge "$lower" ] && [ "$delay" -lt "$((lower + 5))" ] || fail "$1: unbounded delay"
79+
lower=30
80+
done < "$TEST_CASE/delays"
81+
[ "$(wc -l < "$TEST_CASE/delays" | tr -d ' ')" -eq "$(( $3 - 1 ))" ] || fail "$1: incorrect sleep count"
82+
else
83+
[ ! -e "$TEST_CASE/delays" ] || fail "$1: unexpected delay"
84+
[ ! -e "$GITHUB_OUTPUT" ] || fail "$1: unexpected retry marker"
85+
fi
86+
if [ "$2" -eq 0 ] && [ "$3" -gt 1 ]; then
87+
grep -q 'recovered after' "$TEST_CASE/console" || fail "$1: recovery not reported"
88+
fi
89+
echo "PASS: $1"
90+
}
91+
92+
check_case success 0 1
93+
check_case warnings 0 1
94+
check_case recover 0 2
95+
check_case recover-third 0 3
96+
check_case exhaust 23 3
97+
check_case changed-failure 7 2
98+
for scenario in standalone-416 other-resume dependency-only other-host builder hash disk unknown auth missing; do
99+
check_case "$scenario" 23 1
100+
done
101+
check_case terminated 143 1
102+
103+
export TEST_SCENARIO=cancel-backoff TEST_CASE="$test_dir/cancel-backoff"
104+
export GITHUB_OUTPUT="$TEST_CASE/outputs"
105+
mkdir -p "$TEST_CASE"
106+
status=0
107+
bash "$script_dir/realize-nix-dev-shell.sh" "$TEST_CASE/logs" > "$TEST_CASE/console" 2>&1 || status=$?
108+
[ "$status" -eq 143 ] || fail "cancellation during backoff hidden"
109+
[ "$(cat "$TEST_CASE/count")" -eq 1 ] || fail "cancellation during backoff retried"
110+
echo 'PASS: cancellation during backoff'
111+
112+
# Pipeline status must report a failed log sink even when Nix succeeds.
113+
cat > "$test_dir/bin/tee" <<'SH'
114+
#!/usr/bin/env bash
115+
cat >/dev/null
116+
exit 9
117+
SH
118+
chmod +x "$test_dir/bin/tee"
119+
export TEST_SCENARIO=success TEST_CASE="$test_dir/log-sink-failure"
120+
mkdir -p "$TEST_CASE"
121+
status=0
122+
bash "$script_dir/realize-nix-dev-shell.sh" "$TEST_CASE/logs" > "$TEST_CASE/console" 2>&1 || status=$?
123+
[ "$status" -eq 9 ] || fail "log sink error hidden"
124+
[ "$(cat "$TEST_CASE/count")" -eq 1 ] || fail "log sink error retried"
125+
echo 'PASS: log sink failure'
126+
echo 'All Nix shell recovery tests passed.'

‎tasks/test.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ depends = [
1212
"test:sbom",
1313
"test:install-sh",
1414
"test:build-env",
15+
"test:nix-shell-recovery",
1516
"test:gateway-pull-policy",
1617
"test:e2e-image-overrides",
1718
"test:gateway-config",
@@ -49,6 +50,12 @@ run = "tasks/scripts/test-build-env.sh"
4950
run_windows = "echo Skipping test:build-env: the Unix build-env.sh helper does not apply on Windows."
5051
hide = true
5152

53+
["test:nix-shell-recovery"]
54+
description = "Test bounded Nix development-shell download recovery"
55+
run = "bash tasks/scripts/test-nix-shell-recovery.sh"
56+
run_windows = "echo Skipping test:nix-shell-recovery: Unix Nix setup does not apply on Windows."
57+
hide = true
58+
5259
["test:gateway-pull-policy"]
5360
description = "Test development gateway image pull-policy normalization"
5461
run = "tasks/scripts/test-gateway-pull-policy.sh"

0 commit comments

Comments
 (0)