Skip to content

Commit df03cc6

Browse files
committed
test(pm): add downloader race repro workflow
1 parent ea51139 commit df03cc6

2 files changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: downloader-race-repro
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
paths:
7+
- ".github/workflows/downloader-race-repro.yml"
8+
- "crates/pm/src/util/downloader.rs"
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
linux-repro:
15+
name: linux downloader race repro
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 45
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Init git submodules
22+
run: git submodule update --init --recursive --depth 1
23+
24+
- name: Setup Node
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: 20
28+
cache: npm
29+
30+
- name: Install Rust
31+
uses: dtolnay/rust-toolchain@stable
32+
with:
33+
toolchain: nightly-2026-01-04
34+
35+
- name: Cache cargo
36+
uses: Swatinem/rust-cache@v2
37+
with:
38+
shared-key: downloader-race-repro-linux
39+
40+
- name: Install dependencies
41+
run: npm install
42+
43+
- name: Try to reproduce zero-length resolved cache
44+
shell: bash
45+
run: |
46+
set -uo pipefail
47+
48+
test_name='downloader::tests::repro_download_can_leave_zero_file_if_racing_process_dies_after_resolved'
49+
for attempt in $(seq 1 30); do
50+
echo "::group::attempt ${attempt}"
51+
cargo test -p utoo-pm "${test_name}" -- --ignored --nocapture
52+
status=$?
53+
echo "::endgroup::"
54+
55+
if [ "${status}" -eq 0 ]; then
56+
echo "::notice::Reproduced zero-length package.json in resolved downloader cache on attempt ${attempt}."
57+
exit 0
58+
fi
59+
60+
echo "attempt ${attempt} did not hit the zero-length window"
61+
done
62+
63+
echo "::error::Did not reproduce zero-length package.json after 30 attempts."
64+
exit 1

crates/pm/src/util/downloader.rs

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,17 @@ mod tests {
307307
use flate2::Compression;
308308
use flate2::write::GzEncoder;
309309
use mockito::Server;
310+
use std::env;
310311
use std::io::Write;
312+
use std::path::PathBuf;
313+
use std::process::{Child, Command};
314+
use std::sync::Arc;
315+
use std::sync::atomic::{AtomicUsize, Ordering};
316+
use std::time::{Duration, Instant};
311317
use tar::Builder;
312318
use tempfile::TempDir;
313319
use tokio::task;
320+
use tokio::time::sleep;
314321

315322
// Helper to create a simple tar.gz archive in memory
316323
fn create_tar_gz() -> Vec<u8> {
@@ -330,6 +337,80 @@ mod tests {
330337
encoder.finish().unwrap()
331338
}
332339

340+
fn create_package_tar_gz() -> (Vec<u8>, String) {
341+
let package_json = r#"{"name":"race-pkg","version":"1.0.0"}"#.to_string();
342+
let mut tar_data = Vec::new();
343+
{
344+
let mut tar = Builder::new(&mut tar_data);
345+
append_tar_file(&mut tar, "package/package.json", package_json.as_bytes());
346+
347+
for index in 0..128 {
348+
let content = format!(
349+
"file-{index}\n{}",
350+
"0123456789abcdefghijklmnopqrstuvwxyz".repeat(256)
351+
);
352+
append_tar_file(
353+
&mut tar,
354+
&format!("package/lib/file-{index}.txt"),
355+
content.as_bytes(),
356+
);
357+
}
358+
359+
tar.finish().unwrap();
360+
}
361+
362+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
363+
encoder.write_all(&tar_data).unwrap();
364+
(encoder.finish().unwrap(), package_json)
365+
}
366+
367+
fn create_large_package_json_tar_gz() -> (Vec<u8>, usize) {
368+
let package_json = format!(
369+
r#"{{"name":"race-pkg","version":"1.0.0","padding":"{}"}}"#,
370+
"x".repeat(64 * 1024 * 1024)
371+
);
372+
let package_json_len = package_json.len();
373+
let mut tar_data = Vec::new();
374+
{
375+
let mut tar = Builder::new(&mut tar_data);
376+
append_tar_file(&mut tar, "package/package.json", package_json.as_bytes());
377+
tar.finish().unwrap();
378+
}
379+
380+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
381+
encoder.write_all(&tar_data).unwrap();
382+
(encoder.finish().unwrap(), package_json_len)
383+
}
384+
385+
fn append_tar_file(tar: &mut Builder<&mut Vec<u8>>, path: &str, content: &[u8]) {
386+
let mut header = tar::Header::new_gnu();
387+
header.set_path(path).unwrap();
388+
header.set_size(content.len() as u64);
389+
header.set_mode(0o644);
390+
header.set_cksum();
391+
tar.append(&header, content).unwrap();
392+
}
393+
394+
fn spawn_gated_download_helper(
395+
test_binary: &Path,
396+
url: &str,
397+
dest: &Path,
398+
ready_dir: &Path,
399+
start_file: &Path,
400+
) -> Child {
401+
Command::new(test_binary)
402+
.arg("util::downloader::tests::download_process_helper")
403+
.arg("--ignored")
404+
.arg("--nocapture")
405+
.env("UTOO_DOWNLOAD_HELPER", "1")
406+
.env("UTOO_DOWNLOAD_HELPER_URL", url)
407+
.env("UTOO_DOWNLOAD_HELPER_DEST", dest)
408+
.env("UTOO_DOWNLOAD_HELPER_READY_DIR", ready_dir)
409+
.env("UTOO_DOWNLOAD_HELPER_START_FILE", start_file)
410+
.spawn()
411+
.unwrap()
412+
}
413+
333414
#[tokio::test]
334415
async fn test_download_idempotent() {
335416
let tar_gz = create_tar_gz();
@@ -369,4 +450,229 @@ mod tests {
369450
assert_eq!(content, "hello world");
370451
_m.assert();
371452
}
453+
454+
#[tokio::test]
455+
#[ignore]
456+
async fn test_download_shared_dest_across_processes() {
457+
let (tar_gz, package_json) = create_package_tar_gz();
458+
let body = Arc::new(tar_gz);
459+
let mut server = Server::new_async().await;
460+
let child_count = 8;
461+
let _m = server
462+
.mock("GET", "/pkg.tgz")
463+
.with_status(200)
464+
.with_header("content-type", "application/gzip")
465+
.with_chunked_body(move |writer| {
466+
std::thread::sleep(Duration::from_millis(200));
467+
for chunk in body.chunks(1024) {
468+
writer.write_all(chunk)?;
469+
std::thread::sleep(Duration::from_millis(2));
470+
}
471+
Ok(())
472+
})
473+
.expect_at_least(2)
474+
.expect_at_most(child_count)
475+
.create_async()
476+
.await;
477+
478+
let url = format!("{}/pkg.tgz", server.url());
479+
let temp_dir = TempDir::new().unwrap();
480+
let dest = temp_dir.path().join("pkg");
481+
let ready_dir = temp_dir.path().join("ready");
482+
let start_file = temp_dir.path().join("start");
483+
crate::fs::create_dir_all(&ready_dir).await.unwrap();
484+
let test_binary = env::current_exe().unwrap();
485+
let mut children = Vec::new();
486+
487+
for _ in 0..child_count {
488+
children.push(spawn_gated_download_helper(
489+
&test_binary,
490+
&url,
491+
&dest,
492+
&ready_dir,
493+
&start_file,
494+
));
495+
}
496+
497+
wait_for_ready_files(&ready_dir, child_count, Duration::from_secs(5)).await;
498+
crate::fs::write(&start_file, "").await.unwrap();
499+
500+
let deadline = Instant::now() + Duration::from_secs(20);
501+
let mut failures = Vec::new();
502+
for mut child in children {
503+
loop {
504+
match child.try_wait().unwrap() {
505+
Some(status) => {
506+
if !status.success() {
507+
failures.push(status.to_string());
508+
}
509+
break;
510+
}
511+
None if Instant::now() >= deadline => {
512+
let _ = child.kill();
513+
failures.push("timed out".to_string());
514+
break;
515+
}
516+
None => sleep(Duration::from_millis(25)).await,
517+
}
518+
}
519+
}
520+
521+
assert!(failures.is_empty(), "download helpers failed: {failures:?}");
522+
assert!(dest.join("_resolved").exists());
523+
524+
let content = crate::fs::read_to_string(dest.join("package/package.json"))
525+
.await
526+
.unwrap();
527+
assert_eq!(content, package_json);
528+
529+
for index in [0, 31, 63, 127] {
530+
let path = dest.join(format!("package/lib/file-{index}.txt"));
531+
let metadata = crate::fs::metadata(&path).await.unwrap();
532+
assert!(metadata.len() > 0, "{} was empty", path.display());
533+
}
534+
535+
_m.assert();
536+
}
537+
538+
#[tokio::test]
539+
#[ignore]
540+
async fn repro_download_can_leave_zero_file_if_racing_process_dies_after_resolved() {
541+
let (tar_gz, expected_package_json_len) = create_large_package_json_tar_gz();
542+
let body = Arc::new(tar_gz);
543+
let request_index = Arc::new(AtomicUsize::new(0));
544+
let mut server = Server::new_async().await;
545+
let _m = server
546+
.mock("GET", "/pkg.tgz")
547+
.with_status(200)
548+
.with_header("content-type", "application/gzip")
549+
.with_chunked_body(move |writer| {
550+
let index = request_index.fetch_add(1, Ordering::SeqCst);
551+
if index == 1 {
552+
std::thread::sleep(Duration::from_millis(500));
553+
}
554+
555+
for chunk in body.chunks(64 * 1024) {
556+
writer.write_all(chunk)?;
557+
}
558+
Ok(())
559+
})
560+
.expect(2)
561+
.create_async()
562+
.await;
563+
564+
let url = format!("{}/pkg.tgz", server.url());
565+
let temp_dir = TempDir::new().unwrap();
566+
let dest = temp_dir.path().join("pkg");
567+
let package_json_path = dest.join("package/package.json");
568+
let ready_dir = temp_dir.path().join("ready");
569+
let start_file = temp_dir.path().join("start");
570+
crate::fs::create_dir_all(&ready_dir).await.unwrap();
571+
let test_binary = env::current_exe().unwrap();
572+
let children = vec![
573+
spawn_gated_download_helper(&test_binary, &url, &dest, &ready_dir, &start_file),
574+
spawn_gated_download_helper(&test_binary, &url, &dest, &ready_dir, &start_file),
575+
];
576+
577+
wait_for_ready_files(&ready_dir, 2, Duration::from_secs(5)).await;
578+
crate::fs::write(&start_file, "").await.unwrap();
579+
580+
wait_for_path(&dest.join("_resolved"), Duration::from_secs(5)).await;
581+
wait_for_zero_len(&package_json_path, Duration::from_secs(15)).await;
582+
583+
for child in &children {
584+
if child.id() != 0 {
585+
let _ = Command::new("kill")
586+
.arg("-KILL")
587+
.arg(child.id().to_string())
588+
.status();
589+
}
590+
}
591+
592+
for mut child in children {
593+
let _ = child.wait();
594+
}
595+
596+
assert!(dest.join("_resolved").exists());
597+
assert_eq!(
598+
crate::fs::metadata(&package_json_path).await.unwrap().len(),
599+
0,
600+
"racing writer finished instead of being killed after truncating package.json; expected full size was {expected_package_json_len}"
601+
);
602+
603+
_m.assert();
604+
}
605+
606+
async fn wait_for_path(path: &Path, timeout: Duration) {
607+
let deadline = Instant::now() + timeout;
608+
while Instant::now() < deadline {
609+
if path.exists() {
610+
return;
611+
}
612+
sleep(Duration::from_millis(10)).await;
613+
}
614+
panic!("timed out waiting for {}", path.display());
615+
}
616+
617+
async fn wait_for_ready_files(path: &Path, count: usize, timeout: Duration) {
618+
let deadline = Instant::now() + timeout;
619+
while Instant::now() < deadline {
620+
let ready_count = std::fs::read_dir(path)
621+
.map(|entries| entries.count())
622+
.unwrap_or(0);
623+
if ready_count >= count {
624+
return;
625+
}
626+
sleep(Duration::from_millis(10)).await;
627+
}
628+
panic!(
629+
"timed out waiting for {count} ready files in {}",
630+
path.display()
631+
);
632+
}
633+
634+
async fn wait_for_zero_len(path: &Path, timeout: Duration) {
635+
let deadline = Instant::now() + timeout;
636+
while Instant::now() < deadline {
637+
if let Ok(metadata) = crate::fs::metadata(path).await
638+
&& metadata.len() == 0
639+
{
640+
return;
641+
}
642+
sleep(Duration::from_millis(10)).await;
643+
}
644+
panic!("timed out waiting for zero-length {}", path.display());
645+
}
646+
647+
#[test]
648+
#[ignore]
649+
fn download_process_helper() {
650+
if env::var("UTOO_DOWNLOAD_HELPER").ok().as_deref() != Some("1") {
651+
return;
652+
}
653+
654+
let url = env::var("UTOO_DOWNLOAD_HELPER_URL").unwrap();
655+
let dest = PathBuf::from(env::var("UTOO_DOWNLOAD_HELPER_DEST").unwrap());
656+
if let Ok(ready_dir) = env::var("UTOO_DOWNLOAD_HELPER_READY_DIR") {
657+
std::fs::create_dir_all(&ready_dir).unwrap();
658+
std::fs::write(
659+
PathBuf::from(ready_dir).join(std::process::id().to_string()),
660+
b"ready",
661+
)
662+
.unwrap();
663+
664+
let start_file = PathBuf::from(env::var("UTOO_DOWNLOAD_HELPER_START_FILE").unwrap());
665+
while !start_file.exists() {
666+
std::thread::sleep(Duration::from_millis(10));
667+
}
668+
}
669+
let runtime = tokio::runtime::Builder::new_multi_thread()
670+
.enable_all()
671+
.build()
672+
.unwrap();
673+
674+
runtime.block_on(async {
675+
download(&url, &dest).await.unwrap();
676+
});
677+
}
372678
}

0 commit comments

Comments
 (0)