use std::path::{Path, PathBuf};
use anyhow::anyhow;
pub(crate) fn default_virtualized_mount_path(
workspace_parent: &Path,
repo_name: &str,
sanitized_thread: &str,
) -> PathBuf {
workspace_parent
.join(format!(".{repo_name}-heddle-mounts"))
.join(sanitized_thread)
}
#[allow(dead_code)]
pub(crate) fn virtualized_unsupported_error() -> anyhow::Error {
anyhow!("Virtualized workspace requires Linux + heddle built with --features mount")
}
#[cfg(all(target_os = "linux", feature = "mount"))]
mod linux {
use std::{
path::{Path, PathBuf},
sync::Mutex,
};
use anyhow::{Context, Result, anyhow};
use mount::{ContentAddressedMount, FuseShell};
use repo::Repository;
use tracing::warn;
use crate::util::OnceMap;
pub struct MountHandle {
session: Mutex<Option<mount::BackgroundSession>>,
mountpoint: PathBuf,
}
impl MountHandle {
pub fn unmount(&self) -> Result<()> {
let mut guard = self.session.lock().expect("mount session lock");
*guard = None;
Ok(())
}
pub fn mountpoint(&self) -> &Path {
&self.mountpoint
}
}
static REGISTRY: OnceMap<String, std::sync::Arc<MountHandle>> = OnceMap::new();
pub fn spawn_mount_for_thread(
repo: Repository,
thread_id: &str,
mountpoint: &Path,
) -> Result<std::sync::Arc<MountHandle>> {
std::fs::create_dir_all(mountpoint)
.with_context(|| format!("create mount point {}", mountpoint.display()))?;
let mount = ContentAddressedMount::new(repo, thread_id)
.map_err(|e| anyhow!("open content-addressed mount for {thread_id}: {e}"))?;
let shell = FuseShell::new(mount);
let session = shell.mount_background(mountpoint).map_err(|e| {
anyhow!(
"spawn FUSE background session at {}: {e}",
mountpoint.display()
)
})?;
let handle = std::sync::Arc::new(MountHandle {
session: Mutex::new(Some(session)),
mountpoint: mountpoint.to_path_buf(),
});
REGISTRY.insert(thread_id.to_string(), std::sync::Arc::clone(&handle));
Ok(handle)
}
pub fn unmount_thread_if_mounted(thread_id: &str) -> bool {
let Some(handle) = REGISTRY.remove(&thread_id.to_string()) else {
return false;
};
if let Err(err) = handle.unmount() {
warn!(
thread = thread_id,
mountpoint = %handle.mountpoint().display(),
"unmount failed: {err}"
);
}
true
}
}
#[cfg(all(target_os = "linux", feature = "mount"))]
#[allow(unused_imports)] pub(crate) use linux::MountHandle;
#[cfg(all(target_os = "linux", feature = "mount"))]
pub(crate) use linux::{spawn_mount_for_thread, unmount_thread_if_mounted};
#[cfg(not(all(target_os = "linux", feature = "mount")))]
mod stub {
use std::path::Path;
use anyhow::Result;
pub struct MountHandle(std::convert::Infallible);
pub fn spawn_mount_for_thread(
_repo: repo::Repository,
_thread_id: &str,
_mountpoint: &Path,
) -> Result<std::sync::Arc<MountHandle>> {
Err(super::virtualized_unsupported_error())
}
pub fn unmount_thread_if_mounted(_thread_id: &str) -> bool {
false
}
}
#[cfg(not(all(target_os = "linux", feature = "mount")))]
#[allow(unused_imports)] pub(crate) use stub::MountHandle;
#[cfg(not(all(target_os = "linux", feature = "mount")))]
pub(crate) use stub::{spawn_mount_for_thread, unmount_thread_if_mounted};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MountOwnership {
PreferDaemon,
InProcess,
}
impl MountOwnership {
pub fn from_flags(daemon: bool, no_daemon: bool) -> Self {
if no_daemon || !daemon {
Self::InProcess
} else {
Self::PreferDaemon
}
}
}
pub(crate) fn establish_virtualized_mount(
repo_root: &Path,
thread_id: &str,
mountpoint: &Path,
ownership: MountOwnership,
) -> anyhow::Result<()> {
match ownership {
MountOwnership::PreferDaemon => {
let attempt = crate::cli::commands::daemon_client::mount_via_daemon_classified(
repo_root, thread_id, mountpoint,
);
match classify_daemon_attempt(attempt, thread_id) {
DaemonAttemptResolution::Daemon => Ok(()),
DaemonAttemptResolution::FallbackInProcess => {
let mount_repo = repo::Repository::open(repo_root)?;
spawn_mount_for_thread(mount_repo, thread_id, mountpoint)?;
Ok(())
}
DaemonAttemptResolution::Fatal(err) => Err(err),
}
}
MountOwnership::InProcess => {
let mount_repo = repo::Repository::open(repo_root)?;
spawn_mount_for_thread(mount_repo, thread_id, mountpoint)?;
Ok(())
}
}
}
#[derive(Debug)]
enum DaemonAttemptResolution {
Daemon,
FallbackInProcess,
Fatal(anyhow::Error),
}
fn classify_daemon_attempt(
attempt: std::result::Result<
std::path::PathBuf,
crate::cli::commands::daemon_client::DaemonMountError,
>,
thread_id: &str,
) -> DaemonAttemptResolution {
use crate::cli::commands::daemon_client::DaemonMountError;
match attempt {
Ok(_) => DaemonAttemptResolution::Daemon,
Err(DaemonMountError::Fatal(err)) => DaemonAttemptResolution::Fatal(err),
Err(DaemonMountError::Unavailable(reason)) => {
tracing::warn!(
thread = thread_id,
"daemon unavailable ({reason}); using in-process mount. \
Pass --no-daemon to suppress this warning."
);
DaemonAttemptResolution::FallbackInProcess
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_flags_prefer_daemon() {
assert_eq!(
MountOwnership::from_flags(true, false),
MountOwnership::PreferDaemon,
);
}
#[test]
fn no_daemon_flag_uses_in_process() {
assert_eq!(
MountOwnership::from_flags(false, true),
MountOwnership::InProcess,
);
assert_eq!(
MountOwnership::from_flags(true, true),
MountOwnership::InProcess,
);
}
#[test]
fn overrides_with_resolves_conflicting_flags_to_in_process_when_no_daemon_wins() {
assert_eq!(
MountOwnership::from_flags(false, true),
MountOwnership::InProcess,
);
}
#[test]
fn overrides_with_resolves_conflicting_flags_to_daemon_when_daemon_wins() {
assert_eq!(
MountOwnership::from_flags(true, false),
MountOwnership::PreferDaemon,
);
}
#[test]
fn classify_daemon_attempt_ok_resolves_to_daemon() {
let resolution =
classify_daemon_attempt(Ok(std::path::PathBuf::from("/tmp/some-mount")), "thread-x");
assert!(matches!(resolution, DaemonAttemptResolution::Daemon));
}
#[test]
fn classify_daemon_attempt_unavailable_falls_back_to_in_process() {
use crate::cli::commands::daemon_client::DaemonMountError;
let resolution = classify_daemon_attempt(
Err(DaemonMountError::Unavailable(
"could not start daemon: exec failed".to_string(),
)),
"thread-y",
);
assert!(matches!(
resolution,
DaemonAttemptResolution::FallbackInProcess
));
}
#[test]
fn classify_daemon_attempt_fatal_does_not_fall_back() {
use crate::cli::commands::daemon_client::DaemonMountError;
let resolution = classify_daemon_attempt(
Err(DaemonMountError::Fatal(anyhow!(
"daemon mount failed: [mount_conflict] thread X is already mounted at Y"
))),
"thread-z",
);
match resolution {
DaemonAttemptResolution::Fatal(err) => {
assert!(
err.to_string().contains("mount_conflict"),
"fatal error should preserve the daemon-reported code, got {err:?}"
);
}
other => {
panic!("expected Fatal, got {other:?} — fallback would hide the real conflict")
}
}
}
#[test]
fn fallback_warning_text_mentions_no_daemon_suppression() {
let source = include_str!("mount_lifecycle.rs");
assert!(
source.contains("Pass --no-daemon to suppress this warning."),
"warning hint must be present verbatim so users learn the opt-out flag"
);
}
}