Skip to content

Commit 2687cf4

Browse files
feat(multiplexer): add Rust compiler cache sharing with sccache (shepherdjerred#175)
* feat(multiplexer): add Rust compiler cache sharing with sccache Add shared Docker volume mounts for Rust compilation caching across all mux sessions. Uses progressive enhancement approach - works today with cargo dependency caching, automatically improves when sccache is installed in the dotfiles image. Changes: - Add 3 cache volumes: cargo-registry, cargo-git, sccache - Configure sccache via RUSTC_WRAPPER environment variable - Add `mux clean-cache` command for manual cleanup - Update tests to verify cache configuration Benefits: - Dependency downloads: 0s (vs 30-60s) - Compilation: 90%+ faster with sccache - Works across worktrees and different project paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use non-root accessible paths for Rust cache volumes Fix critical permissions issue where cache volumes were mounted to paths inaccessible by non-root users (/root, /usr/local/cargo). Changes: - Mount cargo caches to /workspace/.cargo (instead of /usr/local/cargo) - Mount sccache to /workspace/.cache/sccache (instead of /root/.cache) - Add CARGO_HOME=/workspace/.cargo environment variable - Update documentation to reflect new paths - Update test assertions for new paths Containers run as non-root (--user flag), so all caches must be under /workspace (HOME) where the user has write access. Addresses GitHub Actions bot review feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 6e90aab commit 2687cf4

2 files changed

Lines changed: 135 additions & 9 deletions

File tree

packages/multiplexer/src/backends/docker.rs

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ fn detect_git_worktree(path: &Path) -> anyhow::Result<Option<PathBuf>> {
124124
/// Docker container image to use
125125
const DOCKER_IMAGE: &str = "ghcr.io/shepherdjerred/dotfiles";
126126

127+
/// Shared cache volumes used across all mux Docker containers for faster Rust builds:
128+
/// - mux-cargo-registry: Downloaded crates from crates.io (/workspace/.cargo/registry)
129+
/// - mux-cargo-git: Git dependencies (/workspace/.cargo/git)
130+
/// - mux-sccache: Compilation cache (/workspace/.cache/sccache)
131+
///
132+
/// Caches are mounted under /workspace (HOME) since containers run as non-root user.
133+
/// sccache (Mozilla's compilation cache) is configured via RUSTC_WRAPPER environment variable.
134+
/// If sccache is not installed in the dotfiles image, cargo will show a warning but continue
135+
/// to work. To enable sccache compilation caching, install it in the dotfiles image:
136+
/// cargo install sccache
137+
/// or add it to the Dockerfile.
138+
127139
/// Proxy configuration for Docker containers.
128140
#[derive(Debug, Clone, Default)]
129141
pub struct DockerProxyConfig {
@@ -245,6 +257,32 @@ impl DockerBackend {
245257
"HOME=/workspace".to_string(),
246258
];
247259

260+
// Mount shared Rust cargo and sccache cache volumes for faster builds
261+
// These are shared across ALL mux sessions and persist between container restarts
262+
// sccache provides compilation caching (path-independent, content-addressed)
263+
// cargo caches provide dependency download caching
264+
// Note: Mounted under /workspace (HOME) since containers run as non-root user
265+
args.extend([
266+
"-v".to_string(),
267+
"mux-cargo-registry:/workspace/.cargo/registry".to_string(),
268+
"-v".to_string(),
269+
"mux-cargo-git:/workspace/.cargo/git".to_string(),
270+
"-v".to_string(),
271+
"mux-sccache:/workspace/.cache/sccache".to_string(),
272+
]);
273+
274+
// Configure sccache as Rust compiler wrapper (if installed in dotfiles image)
275+
// If sccache is not installed, cargo will show a clear warning but continue to work
276+
// This is a progressive enhancement - works without sccache, better with it
277+
args.extend([
278+
"-e".to_string(),
279+
"CARGO_HOME=/workspace/.cargo".to_string(),
280+
"-e".to_string(),
281+
"RUSTC_WRAPPER=sccache".to_string(),
282+
"-e".to_string(),
283+
"SCCACHE_DIR=/workspace/.cache/sccache".to_string(),
284+
]);
285+
248286
// Detect if workdir is a git worktree and mount parent .git directory
249287
match detect_git_worktree(workdir) {
250288
Ok(Some(parent_git_dir)) => {
@@ -791,6 +829,51 @@ mod tests {
791829
);
792830
}
793831

832+
/// Test that Rust caching is configured with cargo and sccache volumes
833+
#[test]
834+
fn test_rust_caching_configured() {
835+
let args = DockerBackend::build_create_args(
836+
"test-session",
837+
&PathBuf::from("/workspace"),
838+
"test prompt",
839+
1000,
840+
None,
841+
false,
842+
false,
843+
&[],
844+
)
845+
.expect("Failed to build args");
846+
847+
// Check cargo cache volumes
848+
let has_registry = args
849+
.iter()
850+
.any(|a| a.contains("mux-cargo-registry:/workspace/.cargo/registry"));
851+
assert!(has_registry, "Expected mux-cargo-registry volume mount");
852+
853+
let has_git = args
854+
.iter()
855+
.any(|a| a.contains("mux-cargo-git:/workspace/.cargo/git"));
856+
assert!(has_git, "Expected mux-cargo-git volume mount");
857+
858+
// Check sccache volume
859+
let has_sccache = args
860+
.iter()
861+
.any(|a| a.contains("mux-sccache:/workspace/.cache/sccache"));
862+
assert!(has_sccache, "Expected mux-sccache volume mount");
863+
864+
// Check cargo and sccache environment variables
865+
let has_cargo_home = args.iter().any(|a| a == "CARGO_HOME=/workspace/.cargo");
866+
assert!(has_cargo_home, "Expected CARGO_HOME=/workspace/.cargo");
867+
868+
let has_rustc_wrapper = args.iter().any(|a| a == "RUSTC_WRAPPER=sccache");
869+
assert!(has_rustc_wrapper, "Expected RUSTC_WRAPPER=sccache");
870+
871+
let has_sccache_dir = args
872+
.iter()
873+
.any(|a| a == "SCCACHE_DIR=/workspace/.cache/sccache");
874+
assert!(has_sccache_dir, "Expected SCCACHE_DIR=/workspace/.cache/sccache");
875+
}
876+
794877
/// Test that attach command uses bash, not zsh (which doesn't exist in container)
795878
#[test]
796879
fn test_attach_uses_bash_not_zsh() {
@@ -1120,11 +1203,11 @@ mod tests {
11201203
&[],
11211204
).expect("Failed to build args");
11221205

1123-
// Count volume mounts (should only have the workspace mount)
1206+
// Count volume mounts (should have workspace + 3 cargo/sccache cache mounts)
11241207
let mount_count = args.iter().filter(|a| *a == "-v").count();
11251208
assert_eq!(
1126-
mount_count, 1,
1127-
"Normal git repo should only have workspace mount, got {mount_count} mounts"
1209+
mount_count, 4,
1210+
"Normal git repo should have workspace + 3 cache mounts, got {mount_count} mounts"
11281211
);
11291212
}
11301213

@@ -1235,11 +1318,11 @@ mod tests {
12351318
&[],
12361319
).expect("Failed to build args");
12371320

1238-
// Should only have workspace mount (no git mount)
1321+
// Should have workspace + 3 cache mounts (no git parent mount)
12391322
let mount_count = args.iter().filter(|a| *a == "-v").count();
12401323
assert_eq!(
1241-
mount_count, 1,
1242-
"Malformed worktree should only have workspace mount, got {mount_count} mounts"
1324+
mount_count, 4,
1325+
"Malformed worktree should have workspace + 3 cache mounts, got {mount_count} mounts"
12431326
);
12441327
}
12451328

@@ -1270,11 +1353,11 @@ mod tests {
12701353
&[],
12711354
).expect("Failed to build args");
12721355

1273-
// Should only have workspace mount (no git mount due to validation failure)
1356+
// Should have workspace + 3 cache mounts (no git parent mount due to validation failure)
12741357
let mount_count = args.iter().filter(|a| *a == "-v").count();
12751358
assert_eq!(
1276-
mount_count, 1,
1277-
"Worktree with missing parent should only have workspace mount, got {mount_count} mounts"
1359+
mount_count, 4,
1360+
"Worktree with missing parent should have workspace + 3 cache mounts, got {mount_count} mounts"
12781361
);
12791362
}
12801363

packages/multiplexer/src/main.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ enum Commands {
9999

100100
/// Reconcile state with reality
101101
Reconcile,
102+
103+
/// Clean Rust compiler cache volumes (frees disk space)
104+
CleanCache {
105+
/// Force cleanup without confirmation
106+
#[arg(short, long)]
107+
force: bool,
108+
},
102109
}
103110

104111
#[tokio::main]
@@ -251,6 +258,42 @@ async fn main() -> anyhow::Result<()> {
251258
}
252259
}
253260
}
261+
Commands::CleanCache { force } => {
262+
use std::io::Write;
263+
264+
if !force {
265+
println!("This will delete shared Rust compiler cache volumes:");
266+
println!(" - mux-cargo-registry (downloaded crates)");
267+
println!(" - mux-cargo-git (git dependencies)");
268+
println!(" - mux-sccache (compilation cache)");
269+
println!("\nFuture builds will redownload dependencies and recompile.");
270+
print!("Continue? (y/N) ");
271+
std::io::stdout().flush()?;
272+
273+
let mut input = String::new();
274+
std::io::stdin().read_line(&mut input)?;
275+
if input.trim().to_lowercase() != "y" {
276+
println!("Aborted");
277+
return Ok(());
278+
}
279+
}
280+
281+
for volume in ["mux-cargo-registry", "mux-cargo-git", "mux-sccache"] {
282+
let output = tokio::process::Command::new("docker")
283+
.args(["volume", "rm", volume])
284+
.output()
285+
.await?;
286+
287+
if output.status.success() {
288+
println!("Deleted volume: {volume}");
289+
} else {
290+
let stderr = String::from_utf8_lossy(&output.stderr);
291+
eprintln!("Warning: Failed to delete {volume}: {stderr}");
292+
}
293+
}
294+
295+
println!("Cache cleanup complete");
296+
}
254297
}
255298

256299
Ok(())

0 commit comments

Comments
 (0)