Skip to content

Commit 830ff2d

Browse files
marc0oloclaude
andauthored
fix(ci): remove the three sources of flakiness in the weekly example runs (#1482)
* fix(ci): remove the three sources of flakiness in the weekly example runs - evm_block_explorer: query a post-Merge block. PublicNode routes across regional pools and some only retain history from block ~15500000, so the block 1 assertions failed with `pruned history unavailable` depending on which pool served the outcall. - unit_testable_rust_canister: prepare each test WASM once per test binary. Two tests downloaded the NNS governance WASM concurrently to the same path, and `fs::write` truncating under the peer's `fs::read` produced a partial gzip that PocketIC rejected as `CanisterInvalidWasm`. curl's exit status was also unchecked, so a failed transfer was cached as a valid artifact. - _run-example.yml: warm the mops toolchain cache. Projects with more than one Motoko canister run concurrent `mops build` invocations that race on the shared moc download and extraction, failing the build with no diagnostics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(ci): point the mops workaround at the upstream issue Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(unit_testable_rust_canister): make the download temp name process-unique Keeps the atomic-rename guarantee if the tests are ever run per-process (cargo nextest) or in two checkouts at once, rather than relying on libtest running tests as threads within one process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(unit_testable_rust_canister): validate the whole cached gzip stream The magic-prefix check accepted a truncated archive left behind by an earlier interrupted download, so a corrupt cache entry survived and kept failing as CanisterInvalidWasm. Decoding the stream covers the trailing CRC and length, and a corrupt entry is now replaced rather than reused. flate2 is already built for the test profile via pocket-ic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 42c474d commit 830ff2d

7 files changed

Lines changed: 115 additions & 32 deletions

File tree

‎.github/workflows/_run-example.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ jobs:
6060
uses: dfinity/pocketic@a980c334fab1b21b0b8a6bba38e1a10836e7258b # main
6161
with:
6262
pocket-ic-server-version: ${{ inputs.pocketic-version }}
63+
# Projects with more than one Motoko canister run one `mops build` per canister
64+
# concurrently, and those invocations race on the shared moc toolchain download and
65+
# extraction: whichever build loses reads a half-extracted moc and fails with no
66+
# diagnostics. Warming the cache first makes the download happen exactly once.
67+
# Remove once caffeinelabs/mops#818 is fixed and released.
68+
- name: Warm the mops toolchain cache
69+
working-directory: ${{ inputs.working-directory }}
70+
run: |
71+
if [ -f mops.toml ] && command -v mops > /dev/null; then
72+
mops install
73+
fi
6374
- name: Run
6475
working-directory: ${{ inputs.working-directory }}
6576
run: ${{ inputs.run }}

‎motoko/evm_block_explorer/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Both give the same interface as long as `icp.yaml` pins the release that is live
8383

8484
The example uses [PublicNode](https://ethereum-rpc.publicnode.com) by default — a free, no-registration provider that works out of the box locally and on mainnet. This is sufficient for getting started and automated testing.
8585

86+
PublicNode routes requests across regional node pools, and not all of them keep the full chain history — some only retain blocks from shortly before the Merge (block ~15500000). Queries for earlier blocks therefore fail with `pruned history unavailable` depending on which pool serves the outcall. `test.sh` queries a post-Merge block for that reason; an archive provider is needed to read early history reliably.
87+
8688
For production deployments requiring premium providers (Alchemy, Ankr, BlockPi), refer to the [EVM RPC canister documentation](https://github.com/dfinity/evm-rpc-canister) for how to configure API keys. Once configured, change `#EthMainnet(?[#PublicNode])` in `backend/EvmRpcApi.mo` to `#EthMainnet(null)` to use all configured providers for better consensus.
8789

8890
## Security considerations and best practices

‎motoko/evm_block_explorer/test.sh‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
#!/usr/bin/env bash
22
set -e
33

4-
echo "=== Test 1: get_evm_block returns correct data for Ethereum mainnet block 1 ==="
5-
result=$(icp canister call backend get_evm_block '(1)')
4+
# Block 20000000 is post-Merge. PublicNode serves several regional node pools and some of
5+
# them only retain history from shortly before the Merge (block ~15500000), so querying
6+
# early blocks such as block 1 fails depending on which pool the outcall lands in.
7+
BLOCK=20000000
8+
BLOCK_HASH=0xd24fd73f794058a3807db926d8898c6481e902b7edb91ce0d479d6760f276183
9+
BLOCK_MINER=0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5
10+
11+
echo "=== Test 1: get_evm_block returns correct data for Ethereum mainnet block $BLOCK ==="
12+
result=$(icp canister call backend get_evm_block "($BLOCK)")
613
echo "$result"
714
echo "$result" | grep -q "Ok" && echo "PASS (Ok variant)" || (echo "FAIL (expected Ok)" && exit 1)
8-
echo "$result" | grep -q "0x88e96d4537bea4d9c05d12549907b32561d3bf31f45aae734cdc119f13406cb6" && echo "PASS (hash)" || (echo "FAIL (wrong hash)" && exit 1)
9-
echo "$result" | grep -q "0x05a56e2d52c817161883f50c441c3228cfe54d9f" && echo "PASS (miner)" || (echo "FAIL (wrong miner)" && exit 1)
15+
echo "$result" | grep -q "$BLOCK_HASH" && echo "PASS (hash)" || (echo "FAIL (wrong hash)" && exit 1)
16+
echo "$result" | grep -q "$BLOCK_MINER" && echo "PASS (miner)" || (echo "FAIL (wrong miner)" && exit 1)

‎rust/evm_block_explorer/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ icp build backend && candid-extractor target/wasm32-unknown-unknown/release/back
6464

6565
The example uses [PublicNode](https://ethereum-rpc.publicnode.com) by default — a free, no-registration provider that works out of the box locally and on mainnet. This is sufficient for getting started and automated testing.
6666

67+
PublicNode routes requests across regional node pools, and not all of them keep the full chain history — some only retain blocks from shortly before the Merge (block ~15500000). Queries for earlier blocks therefore fail with `pruned history unavailable` depending on which pool serves the outcall. `test.sh` queries a post-Merge block for that reason; an archive provider is needed to read early history reliably.
68+
6769
For production deployments requiring premium providers (Alchemy, Ankr, BlockPi), refer to the [EVM RPC canister documentation](https://github.com/dfinity/evm-rpc-canister) for how to configure API keys. Once configured, change `RpcServices::EthMainnet(Some(vec![EthMainnetService::PublicNode]))` in `backend/src/lib.rs` to `RpcServices::EthMainnet(None)` to use all configured providers for better consensus.
6870

6971
## Security considerations and best practices

‎rust/evm_block_explorer/test.sh‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
#!/usr/bin/env bash
22
set -e
33

4-
echo "=== Test 1: get_evm_block returns correct data for Ethereum mainnet block 1 ==="
5-
result=$(icp canister call backend get_evm_block '(1)')
4+
# Block 20000000 is post-Merge. PublicNode serves several regional node pools and some of
5+
# them only retain history from shortly before the Merge (block ~15500000), so querying
6+
# early blocks such as block 1 fails depending on which pool the outcall lands in.
7+
BLOCK=20000000
8+
BLOCK_HASH=0xd24fd73f794058a3807db926d8898c6481e902b7edb91ce0d479d6760f276183
9+
BLOCK_MINER=0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5
10+
11+
echo "=== Test 1: get_evm_block returns correct data for Ethereum mainnet block $BLOCK ==="
12+
result=$(icp canister call backend get_evm_block "($BLOCK)")
613
echo "$result"
714
echo "$result" | grep -q "Ok" && echo "PASS (Ok variant)" || (echo "FAIL (expected Ok)" && exit 1)
8-
echo "$result" | grep -q "0x88e96d4537bea4d9c05d12549907b32561d3bf31f45aae734cdc119f13406cb6" && echo "PASS (hash)" || (echo "FAIL (wrong hash)" && exit 1)
9-
echo "$result" | grep -q "0x05a56e2d52c817161883f50c441c3228cfe54d9f" && echo "PASS (miner)" || (echo "FAIL (wrong miner)" && exit 1)
15+
echo "$result" | grep -q "$BLOCK_HASH" && echo "PASS (hash)" || (echo "FAIL (wrong hash)" && exit 1)
16+
echo "$result" | grep -q "$BLOCK_MINER" && echo "PASS (miner)" || (echo "FAIL (wrong miner)" && exit 1)

‎rust/unit_testable_rust_canister/backend/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ pocket-ic = "15.0.0"
2121
candid = "0.10"
2222
tokio = { version = "1.0", features = ["macros", "rt"] }
2323
walkdir = "2.0"
24+
flate2 = "1.1"

‎rust/unit_testable_rust_canister/backend/tests/integration_tests.rs‎

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
use candid::{decode_one, encode_one, CandidType, Principal};
2+
use flate2::read::GzDecoder;
23
use pocket_ic::{PocketIc, PocketIcBuilder};
34
use serde::Deserialize;
45
use std::io;
6+
use std::io::Read;
57
use std::path::{Path, PathBuf};
68
use std::process::Command;
9+
use std::sync::LazyLock;
710
use std::time::SystemTime;
811
use walkdir::WalkDir;
912

@@ -21,11 +24,19 @@ const IC_COMMIT_FOR_PROPOSALS: &str = "4b7cde9a0e3b5ad4725e75cbc36ce635be6fa6a8"
2124
const NNS_GOVERNANCE_CANISTER_ID: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
2225
const NNS_ROOT_CANISTER_ID: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
2326

24-
// WASM will be loaded dynamically with smart rebuilding
25-
fn get_backend_wasm() -> Vec<u8> {
27+
// Tests run concurrently on separate threads but share the files below, so each artifact is
28+
// prepared exactly once per test binary. Without this, two tests building or downloading the
29+
// same path at the same time can read a half-written file.
30+
static BACKEND_WASM: LazyLock<Vec<u8>> = LazyLock::new(|| {
2631
let wasm_path = ensure_wasm_built();
2732
std::fs::read(&wasm_path)
2833
.unwrap_or_else(|e| panic!("Failed to read WASM file at {:?}: {}", wasm_path, e))
34+
});
35+
36+
static GOVERNANCE_WASM: LazyLock<Vec<u8>> = LazyLock::new(load_governance_wasm);
37+
38+
fn get_backend_wasm() -> Vec<u8> {
39+
BACKEND_WASM.clone()
2940
}
3041

3142
/// Ensures the WASM is built and up-to-date, returns path to the WASM file
@@ -123,45 +134,87 @@ fn rebuild_wasm() {
123134
}
124135
}
125136

137+
/// Download to a temporary file and rename into place, so an interrupted or failed transfer
138+
/// never leaves a truncated file behind for the next run to pick up from the cache. The
139+
/// temporary name carries the process id so that concurrent runs of the test binary cannot
140+
/// clobber each other's transfer either.
126141
fn download_wasm_to(url: String, wasm_path: &Path) {
127-
// Ensure the target directory exists
128142
if let Some(parent) = wasm_path.parent() {
129143
std::fs::create_dir_all(parent)
130144
.unwrap_or_else(|e| panic!("Failed to create directory {:?}: {}", parent, e));
131145
}
132-
133-
let wasm = std::process::Command::new("curl")
134-
.args(["-L", "-f", &url])
146+
let download_path = wasm_path.with_extension(format!("gz.{}.partial", std::process::id()));
147+
148+
let output = Command::new("curl")
149+
.args([
150+
"--location",
151+
"--fail",
152+
"--silent",
153+
"--show-error",
154+
"--retry",
155+
"5",
156+
"--retry-all-errors",
157+
"--retry-delay",
158+
"2",
159+
"--output",
160+
&download_path.to_string_lossy(),
161+
&url,
162+
])
135163
.output()
136-
.expect("Failed to download NNS Governance WASM")
137-
.stdout;
164+
.expect("Failed to run curl");
165+
166+
if !output.status.success() {
167+
let _ = std::fs::remove_file(&download_path);
168+
panic!(
169+
"Failed to download {url}: curl exited with {:?}: {}",
170+
output.status.code(),
171+
String::from_utf8_lossy(&output.stderr)
172+
);
173+
}
138174

139-
std::fs::write(wasm_path, wasm).expect("Failed to write compressed WASM");
175+
std::fs::rename(&download_path, wasm_path).expect("Failed to move downloaded WASM into place");
140176
}
141177

142-
/// Get the NNS Governance WASM binary, downloading if necessary
143-
fn get_governance_wasm() -> Vec<u8> {
178+
/// Reads the archive only if the whole gzip stream decodes, which covers the trailing CRC and
179+
/// length. Checking just the magic prefix would accept a truncated download and install it,
180+
/// surfacing later as an opaque `CanisterInvalidWasm` rejection.
181+
fn read_gzip(wasm_path: &Path) -> Option<Vec<u8>> {
182+
let bytes = std::fs::read(wasm_path).ok()?;
183+
let mut decoded = Vec::new();
184+
GzDecoder::new(bytes.as_slice())
185+
.read_to_end(&mut decoded)
186+
.ok()?;
187+
Some(bytes)
188+
}
189+
190+
/// Get the NNS Governance WASM binary, downloading if necessary.
191+
/// Call [`get_governance_wasm`] instead — this runs once, behind `GOVERNANCE_WASM`.
192+
fn load_governance_wasm() -> Vec<u8> {
144193
let wasm_path = PathBuf::from("../../target/ic/governance-canister.wasm.gz");
145194

146-
// Check if we need to download
147-
if !wasm_path.exists() {
148-
let url = format!(
149-
"https://download.dfinity.systems/ic/{}/canisters/governance-canister.wasm.gz",
150-
IC_COMMIT_FOR_PROPOSALS
151-
);
152-
download_wasm_to(url, &wasm_path);
153-
} else {
195+
if let Some(wasm) = read_gzip(&wasm_path) {
154196
println!("NNS Governance WASM already exists, skipping download");
197+
return wasm;
198+
}
199+
if wasm_path.exists() {
200+
println!("Cached NNS Governance WASM is corrupt, downloading again");
155201
}
156202

157-
std::fs::read(&wasm_path).unwrap_or_else(|e| {
158-
panic!(
159-
"Failed to read governance WASM file at {:?}: {}",
160-
wasm_path, e
161-
)
203+
let url = format!(
204+
"https://download.dfinity.systems/ic/{}/canisters/governance-canister.wasm.gz",
205+
IC_COMMIT_FOR_PROPOSALS
206+
);
207+
download_wasm_to(url, &wasm_path);
208+
209+
read_gzip(&wasm_path).unwrap_or_else(|| {
210+
panic!("Downloaded governance WASM at {wasm_path:?} is not a readable gzip archive")
162211
})
163212
}
164213

214+
fn get_governance_wasm() -> Vec<u8> {
215+
GOVERNANCE_WASM.clone()
216+
}
217+
165218
/// Sets up a minimal NNS Governance canister for testing.
166219
///
167220
/// **Expected errors in test output (non-fatal):**

0 commit comments

Comments
 (0)