- Add multi_thread_streams (default 4) and multi_thread_cutoff (default "50M") fields to ReadConfig, wired into rclone mount args - Expose both fields in Web UI config editor under Read Tuning section - Add Samba performance options: TCP_NODELAY, large readwrite, max xmit - Update config.toml.default with new fields and sftp_connections guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
10 KiB
Rust
318 lines
10 KiB
Rust
//! Manage rclone VFS FUSE mount lifecycle.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::config::{Config, ShareConfig};
|
|
|
|
use super::config::RCLONE_CONF_PATH;
|
|
|
|
/// Build the full `rclone mount` command-line arguments for a single share.
|
|
///
|
|
/// Each share gets its own rclone mount process with a dedicated RC port.
|
|
pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> Vec<String> {
|
|
let mut args = Vec::new();
|
|
|
|
// Subcommand and source:dest
|
|
args.push("mount".into());
|
|
args.push(format!("{}:{}", share.connection, share.remote_path));
|
|
args.push(share.mount_point.display().to_string());
|
|
|
|
// Point to our generated rclone.conf
|
|
args.push("--config".into());
|
|
args.push(RCLONE_CONF_PATH.into());
|
|
|
|
// VFS cache mode — full enables read-through + write-back
|
|
args.push("--vfs-cache-mode".into());
|
|
args.push("full".into());
|
|
|
|
// Write-back delay
|
|
args.push("--vfs-write-back".into());
|
|
args.push(config.writeback.write_back.clone());
|
|
|
|
// Cache size limits
|
|
args.push("--vfs-cache-max-size".into());
|
|
args.push(config.cache.max_size.clone());
|
|
|
|
args.push("--vfs-cache-max-age".into());
|
|
args.push(config.cache.max_age.clone());
|
|
|
|
// NOTE: --vfs-cache-min-free-space requires rclone 1.65+.
|
|
// Ubuntu apt may ship older versions. We detect support at runtime.
|
|
if rclone_supports_min_free_space() {
|
|
args.push("--vfs-cache-min-free-space".into());
|
|
args.push(config.cache.min_free.clone());
|
|
}
|
|
|
|
// Cache directory (SSD path)
|
|
args.push("--cache-dir".into());
|
|
args.push(config.cache.dir.display().to_string());
|
|
|
|
// Directory listing cache TTL
|
|
args.push("--dir-cache-time".into());
|
|
args.push(config.directory_cache.cache_time.clone());
|
|
|
|
// Read optimization
|
|
args.push("--buffer-size".into());
|
|
args.push(config.read.buffer_size.clone());
|
|
|
|
args.push("--vfs-read-chunk-size".into());
|
|
args.push(config.read.chunk_size.clone());
|
|
|
|
args.push("--vfs-read-chunk-size-limit".into());
|
|
args.push(config.read.chunk_limit.clone());
|
|
|
|
args.push("--vfs-read-ahead".into());
|
|
args.push(config.read.read_ahead.clone());
|
|
|
|
// Multi-thread download: splits large files across N parallel SFTP streams
|
|
args.push("--multi-thread-streams".into());
|
|
args.push(config.read.multi_thread_streams.to_string());
|
|
|
|
args.push("--multi-thread-cutoff".into());
|
|
args.push(config.read.multi_thread_cutoff.clone());
|
|
|
|
// Concurrent transfers for write-back
|
|
args.push("--transfers".into());
|
|
args.push(config.writeback.transfers.to_string());
|
|
|
|
// SFTP connection resilience: increase retries for flaky tunnels (Tailscale/WireGuard)
|
|
args.push("--retries".into());
|
|
args.push("10".into());
|
|
args.push("--low-level-retries".into());
|
|
args.push("20".into());
|
|
args.push("--retries-sleep".into());
|
|
args.push("1s".into());
|
|
args.push("--contimeout".into());
|
|
args.push("30s".into());
|
|
|
|
// Bandwidth limits (only add flag if at least one direction is limited)
|
|
let bw = format_bwlimit(&config.bandwidth.limit_up, &config.bandwidth.limit_down);
|
|
if bw != "0" {
|
|
args.push("--bwlimit".into());
|
|
args.push(bw);
|
|
}
|
|
|
|
// Enable rclone RC API on per-share port
|
|
args.push("--rc".into());
|
|
args.push("--rc-addr".into());
|
|
args.push(format!("127.0.0.1:{rc_port}"));
|
|
|
|
// Allow non-root users to access the FUSE mount (requires user_allow_other in /etc/fuse.conf)
|
|
args.push("--allow-other".into());
|
|
|
|
args
|
|
}
|
|
|
|
/// Format the `--bwlimit` value from separate up/down strings.
|
|
///
|
|
/// rclone accepts `RATE` for symmetric or `UP:DOWN` for asymmetric limits.
|
|
/// Returns `"0"` when both directions are unlimited.
|
|
fn format_bwlimit(up: &str, down: &str) -> String {
|
|
let up_zero = up == "0" || up.is_empty();
|
|
let down_zero = down == "0" || down.is_empty();
|
|
|
|
match (up_zero, down_zero) {
|
|
(true, true) => "0".into(),
|
|
_ => format!("{up}:{down}"),
|
|
}
|
|
}
|
|
|
|
/// Check if rclone supports `--vfs-cache-min-free-space` (added in v1.65).
|
|
///
|
|
/// Runs `rclone mount --help` and checks for the flag in the output.
|
|
/// Returns false if rclone is not found or the flag is absent.
|
|
fn rclone_supports_min_free_space() -> bool {
|
|
std::process::Command::new("rclone")
|
|
.args(["mount", "--help"])
|
|
.output()
|
|
.map(|o| {
|
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
|
stdout.contains("--vfs-cache-min-free-space")
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Build the rclone mount command as a string (for systemd ExecStart).
|
|
pub fn build_mount_command(config: &Config, share: &ShareConfig, rc_port: u16) -> String {
|
|
let args = build_mount_args(config, share, rc_port);
|
|
format!("/usr/bin/rclone {}", args.join(" "))
|
|
}
|
|
|
|
/// Check if a FUSE mount is currently active at the given mount point.
|
|
pub fn is_mounted(mount_point: &Path) -> Result<bool> {
|
|
let mp_str = mount_point.display().to_string();
|
|
|
|
let content = std::fs::read_to_string("/proc/mounts")
|
|
.with_context(|| "Failed to read /proc/mounts")?;
|
|
|
|
for line in content.lines() {
|
|
// /proc/mounts format: device mountpoint fstype options dump pass
|
|
let mut fields = line.split_whitespace();
|
|
let _device = fields.next();
|
|
if let Some(mp) = fields.next()
|
|
&& mp == mp_str {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_config() -> Config {
|
|
toml::from_str(
|
|
r#"
|
|
[[connections]]
|
|
name = "nas"
|
|
nas_host = "10.0.0.1"
|
|
nas_user = "admin"
|
|
|
|
[cache]
|
|
dir = "/tmp/cache"
|
|
|
|
[read]
|
|
[bandwidth]
|
|
[writeback]
|
|
[directory_cache]
|
|
[protocols]
|
|
|
|
[[shares]]
|
|
name = "photos"
|
|
connection = "nas"
|
|
remote_path = "/photos"
|
|
mount_point = "/mnt/photos"
|
|
"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_both_zero() {
|
|
assert_eq!(format_bwlimit("0", "0"), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_both_empty() {
|
|
assert_eq!(format_bwlimit("", ""), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_mixed_zero_empty() {
|
|
assert_eq!(format_bwlimit("0", ""), "0");
|
|
assert_eq!(format_bwlimit("", "0"), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_up_only() {
|
|
assert_eq!(format_bwlimit("10M", "0"), "10M:0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_down_only() {
|
|
assert_eq!(format_bwlimit("0", "50M"), "0:50M");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_bwlimit_both_set() {
|
|
assert_eq!(format_bwlimit("10M", "50M"), "10M:50M");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_args_contains_essentials() {
|
|
let config = test_config();
|
|
let share = &config.shares[0];
|
|
let args = build_mount_args(&config, share, 5572);
|
|
|
|
assert_eq!(args[0], "mount");
|
|
assert_eq!(args[1], "nas:/photos");
|
|
assert_eq!(args[2], "/mnt/photos");
|
|
|
|
assert!(args.contains(&"--config".to_string()));
|
|
assert!(args.contains(&RCLONE_CONF_PATH.to_string()));
|
|
assert!(args.contains(&"--vfs-cache-mode".to_string()));
|
|
assert!(args.contains(&"full".to_string()));
|
|
assert!(args.contains(&"--vfs-write-back".to_string()));
|
|
assert!(args.contains(&"5s".to_string()));
|
|
assert!(args.contains(&"--vfs-cache-max-size".to_string()));
|
|
assert!(args.contains(&"200G".to_string()));
|
|
assert!(args.contains(&"--vfs-cache-max-age".to_string()));
|
|
assert!(args.contains(&"720h".to_string()));
|
|
assert!(args.contains(&"--cache-dir".to_string()));
|
|
assert!(args.contains(&"/tmp/cache".to_string()));
|
|
assert!(args.contains(&"--dir-cache-time".to_string()));
|
|
assert!(args.contains(&"1h".to_string()));
|
|
assert!(args.contains(&"--buffer-size".to_string()));
|
|
assert!(args.contains(&"--multi-thread-streams".to_string()));
|
|
assert!(args.contains(&"--multi-thread-cutoff".to_string()));
|
|
assert!(args.contains(&"--transfers".to_string()));
|
|
assert!(args.contains(&"4".to_string()));
|
|
assert!(args.contains(&"--rc".to_string()));
|
|
assert!(args.contains(&"--rc-addr".to_string()));
|
|
assert!(args.contains(&"127.0.0.1:5572".to_string()));
|
|
assert!(args.contains(&"--allow-other".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_args_no_bwlimit_when_unlimited() {
|
|
let config = test_config();
|
|
let share = &config.shares[0];
|
|
let args = build_mount_args(&config, share, 5572);
|
|
assert!(!args.contains(&"--bwlimit".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_args_with_bwlimit() {
|
|
let mut config = test_config();
|
|
config.bandwidth.limit_up = "10M".into();
|
|
config.bandwidth.limit_down = "50M".into();
|
|
let share = &config.shares[0];
|
|
let args = build_mount_args(&config, share, 5572);
|
|
assert!(args.contains(&"--bwlimit".to_string()));
|
|
assert!(args.contains(&"10M:50M".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_command_format() {
|
|
let config = test_config();
|
|
let share = &config.shares[0];
|
|
let cmd = build_mount_command(&config, share, 5572);
|
|
assert!(cmd.starts_with("/usr/bin/rclone mount"));
|
|
assert!(cmd.contains("nas:/photos"));
|
|
assert!(cmd.contains("/mnt/photos"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_args_custom_config() {
|
|
let mut config = test_config();
|
|
config.shares[0].remote_path = "/volume1/media".into();
|
|
config.shares[0].mount_point = std::path::PathBuf::from("/mnt/media");
|
|
config.cache.dir = std::path::PathBuf::from("/ssd/cache");
|
|
config.writeback.transfers = 16;
|
|
|
|
let share = &config.shares[0];
|
|
let args = build_mount_args(&config, share, 5573);
|
|
assert_eq!(args[1], "nas:/volume1/media");
|
|
assert_eq!(args[2], "/mnt/media");
|
|
assert!(args.contains(&"/ssd/cache".to_string()));
|
|
assert!(args.contains(&"16".to_string()));
|
|
assert!(args.contains(&"127.0.0.1:5573".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_mount_args_different_rc_ports() {
|
|
let config = test_config();
|
|
let share = &config.shares[0];
|
|
|
|
let args0 = build_mount_args(&config, share, 5572);
|
|
assert!(args0.contains(&"127.0.0.1:5572".to_string()));
|
|
|
|
let args1 = build_mount_args(&config, share, 5573);
|
|
assert!(args1.contains(&"127.0.0.1:5573".to_string()));
|
|
}
|
|
}
|