Skip to content

Commit f23d8f5

Browse files
authored
Merge pull request #51 from imbue-ai/jacob/reliability-fix
Fix race condition in parallel sandbox downloads
2 parents f95db3e + 22b18dd commit f23d8f5

File tree

2 files changed

+103
-48
lines changed

2 files changed

+103
-48
lines changed

scripts/modal_sandbox.py

Lines changed: 10 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -77,59 +77,22 @@ def copy_dir_to_sandbox(sandbox, local_dir: str, remote_dir: str) -> None:
7777

7878

7979
def copy_from_sandbox(sandbox, remote_path: str, local_path: str) -> None:
80-
"""Copy a file or directory from the sandbox to local filesystem using tar."""
80+
"""Copy a file from the sandbox to local filesystem."""
8181
logger.info("Downloading %s to %s...", remote_path, local_path)
8282

83-
# Use tar on the sandbox to create an archive of the remote path
84-
# This handles both files and directories uniformly
85-
tar_remote_path = "/tmp/.download_transfer.tar"
83+
# Read file content directly from sandbox
84+
with sandbox.open(remote_path, "rb") as f:
85+
data = f.read()
8686

87-
# Create tar archive on sandbox
88-
# Use -C to change to parent directory and archive just the basename
89-
# This preserves the directory structure correctly
90-
remote_parent = os.path.dirname(remote_path.rstrip("/")) or "/"
91-
remote_basename = os.path.basename(remote_path.rstrip("/"))
92-
93-
logger.info("Creating tar archive on sandbox...")
94-
process = sandbox.exec(
95-
"tar", "-cf", tar_remote_path, "-C", remote_parent, remote_basename
96-
)
97-
process.wait()
98-
if process.returncode != 0:
99-
stderr = process.stderr.read()
100-
raise click.ClickException(f"Failed to create tar archive on sandbox: {stderr}")
101-
102-
# Read the tar archive from sandbox
103-
logger.info("Transferring tar archive from sandbox...")
104-
with sandbox.open(tar_remote_path, "rb") as f:
105-
tar_data = f.read()
106-
107-
logger.info("Received tar archive (%d bytes)", len(tar_data))
108-
109-
# Clean up tar file on sandbox
110-
sandbox.exec("rm", "-f", tar_remote_path).wait()
111-
112-
# Extract tar archive locally
113-
tar_buffer = io.BytesIO(tar_data)
87+
logger.info("Received %d bytes", len(data))
11488

11589
# Create parent directory if needed
11690
local_parent = os.path.dirname(local_path.rstrip("/")) or "."
11791
os.makedirs(local_parent, exist_ok=True)
11892

119-
logger.info("Extracting tar archive to %s...", local_parent)
120-
with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
121-
tar.extractall(path=local_parent)
122-
123-
# If local_path differs from the extracted name, rename
124-
extracted_path = os.path.join(local_parent, remote_basename)
125-
if extracted_path != local_path and os.path.exists(extracted_path):
126-
if os.path.exists(local_path):
127-
# Remove existing target to allow rename
128-
if os.path.isdir(local_path):
129-
shutil.rmtree(local_path)
130-
else:
131-
os.remove(local_path)
132-
os.rename(extracted_path, local_path)
93+
# Write to local file
94+
with open(local_path, "wb") as f:
95+
f.write(data)
13396

13497
logger.info("Download complete: %s -> %s", remote_path, local_path)
13598

@@ -353,12 +316,11 @@ def download(sandbox_id: str, paths: tuple[str, ...]):
353316
SANDBOX_ID is the Modal sandbox ID to download from.
354317
355318
PATHS are one or more path specifications in the format "remote_path:local_path".
356-
Each specification downloads the remote path to the local path.
357-
Both files and directories are supported.
319+
Each specification downloads the remote file to the local path.
358320
359321
Examples:
360322
361-
modal_sandbox.py download sb-abc123 "/app/results:/tmp/results"
323+
modal_sandbox.py download sb-abc123 "/tmp/junit.xml:./results/junit.xml"
362324
363325
modal_sandbox.py download sb-abc123 "/app/out:./out" "/app/logs:./logs"
364326
"""

src/provider/default.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,4 +621,97 @@ mod tests {
621621
"arg with space should be quoted: {result}"
622622
);
623623
}
624+
625+
/// Integration test for Modal sandbox download functionality via DefaultProvider.
626+
///
627+
/// This test requires Modal credentials (MODAL_TOKEN_ID and MODAL_TOKEN_SECRET).
628+
/// Skips automatically if credentials are not present.
629+
#[tokio::test]
630+
async fn modal_download_junit_xml() -> Result<(), Box<dyn std::error::Error>> {
631+
use crate::config::{DefaultProviderConfig, SandboxConfig};
632+
use crate::provider::SandboxProvider;
633+
use futures::StreamExt;
634+
635+
// Skip if Modal credentials are not available
636+
if std::env::var("MODAL_TOKEN_ID").is_err() || std::env::var("MODAL_TOKEN_SECRET").is_err()
637+
{
638+
eprintln!(
639+
"Skipping modal_download_junit_xml: MODAL_TOKEN_ID or MODAL_TOKEN_SECRET not set"
640+
);
641+
return Ok(());
642+
}
643+
644+
// Create a temp dir with a minimal Dockerfile
645+
let temp_dir = tempfile::tempdir()?;
646+
let dockerfile_path = temp_dir.path().join("Dockerfile.test");
647+
std::fs::write(&dockerfile_path, "FROM python:3.11-slim\n")?;
648+
649+
// Configure DefaultProvider to use modal_sandbox.py
650+
// Use @modal_sandbox.py notation which resolves to the bundled script
651+
let config = DefaultProviderConfig {
652+
prepare_command: Some("uv run @modal_sandbox.py prepare Dockerfile.test".to_string()),
653+
create_command: "uv run @modal_sandbox.py create {image_id}".to_string(),
654+
exec_command: "uv run @modal_sandbox.py exec {sandbox_id} {command}".to_string(),
655+
destroy_command: "uv run @modal_sandbox.py destroy {sandbox_id}".to_string(),
656+
download_command: Some(
657+
"uv run @modal_sandbox.py download {sandbox_id} {paths}".to_string(),
658+
),
659+
working_dir: Some(temp_dir.path().to_path_buf()),
660+
timeout_secs: 300,
661+
env: Default::default(),
662+
copy_dirs: vec![],
663+
};
664+
665+
let provider = DefaultProvider::from_config(config, &[]).await?;
666+
667+
// Create sandbox
668+
let sandbox_config = SandboxConfig {
669+
id: "test-download".to_string(),
670+
working_dir: None,
671+
env: vec![],
672+
copy_dirs: vec![],
673+
};
674+
675+
let sandbox = provider.create_sandbox(&sandbox_config).await?;
676+
677+
// Write test junit.xml content
678+
let test_content = r#"<?xml version="1.0" encoding="UTF-8"?>
679+
<testsuites>
680+
<testsuite name="test_suite" tests="2" failures="0">
681+
<testcase name="test_one" classname="tests.test_example" time="0.001"/>
682+
<testcase name="test_two" classname="tests.test_example" time="0.002"/>
683+
</testsuite>
684+
</testsuites>"#;
685+
686+
// Write the file to sandbox using exec
687+
let write_cmd = Command::new("sh").arg("-c").arg(format!(
688+
"cat > /tmp/junit.xml << 'EOF'\n{}\nEOF",
689+
test_content
690+
));
691+
692+
let mut stream = sandbox.exec_stream(&write_cmd).await?;
693+
while stream.next().await.is_some() {}
694+
695+
// Download the file
696+
let download_dir = tempfile::tempdir()?;
697+
let local_file = download_dir.path().join("downloaded.xml");
698+
let remote_path = std::path::Path::new("/tmp/junit.xml");
699+
700+
sandbox
701+
.download(&[(remote_path, local_file.as_path())])
702+
.await?;
703+
704+
// Verify content
705+
let downloaded = std::fs::read_to_string(&local_file)?;
706+
assert_eq!(
707+
test_content.trim(),
708+
downloaded.trim(),
709+
"Downloaded content should match"
710+
);
711+
712+
// Cleanup
713+
sandbox.terminate().await?;
714+
715+
Ok(())
716+
}
624717
}

0 commit comments

Comments
 (0)