use std::path::Path;
use anyhow::{Result, anyhow};
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct FskitReadinessReport {
pub state: &'static str,
pub backend: &'static str,
pub action: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings_url: Option<&'static str>,
}
pub(crate) struct SpawnedMount {
pub owner: VirtualizedMountOwner,
pub fskit_readiness: Option<FskitReadinessReport>,
}
#[cfg(any(all(target_os = "macos", feature = "mount"), test))]
fn fskit_interactive_setup_allowed(
explicitly_enabled: bool,
stdin_is_terminal: bool,
stderr_is_terminal: bool,
) -> bool {
explicitly_enabled && stdin_is_terminal && stderr_is_terminal
}
#[allow(dead_code)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VirtualizedMountBackend {
FuseWorker,
FsKit,
ProjFs,
Nfs,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VirtualizedMountOwner {
Daemon,
InProcess(VirtualizedMountBackend),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct VirtualizedMountOutcome {
pub owner: VirtualizedMountOwner,
pub fskit_readiness: Option<FskitReadinessReport>,
}
impl VirtualizedMountOutcome {
fn daemon() -> Self {
Self {
owner: VirtualizedMountOwner::Daemon,
fskit_readiness: None,
}
}
fn in_process(mounted: SpawnedMount) -> Self {
Self {
owner: mounted.owner,
fskit_readiness: mounted.fskit_readiness,
}
}
}
fn run_mount_io<T, F>(label: &'static str, work: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Result<T> + Send + 'static,
{
if tokio::runtime::Handle::try_current().is_err() {
return work();
}
let thread_name = format!("heddle-mount-{label}");
let join = std::thread::Builder::new()
.name(thread_name)
.spawn(work)
.map_err(|error| anyhow!("spawn {label} mount worker: {error}"))?;
join.join()
.map_err(|_| anyhow!("{label} mount worker panicked"))?
}
#[allow(dead_code)]
pub(crate) fn virtualized_unsupported_error() -> anyhow::Error {
anyhow!(
"Virtualized workspace requires Linux/macOS/Windows + 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, NfsSession, NfsShell,
worker::{Supervisor, default_worker_binary},
};
use objects::sync::LockExt;
use repo::Repository;
use tracing::warn;
use crate::util::OnceMap;
enum BackingSession {
FuseWorker(Supervisor),
Nfs(NfsSession),
}
impl BackingSession {
fn unmount(self) -> Result<()> {
match self {
Self::FuseWorker(s) => s.unmount(),
Self::Nfs(s) => s.unmount().map_err(|e| anyhow!("nfs unmount: {e}")),
}
}
}
pub struct MountHandle {
session: Mutex<Option<BackingSession>>,
mountpoint: PathBuf,
}
impl MountHandle {
pub fn unmount(&self) -> Result<()> {
let mut guard = self.session.lock_or_poisoned();
if let Some(s) = guard.take() {
s.unmount()?;
}
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,
_interactive_setup: bool,
) -> Result<super::SpawnedMount> {
std::fs::create_dir_all(mountpoint)
.with_context(|| format!("create mount point {}", mountpoint.display()))?;
let root = repo.root().to_path_buf();
drop(repo);
let (session, owner) = match spawn_fuse_worker(&root, thread_id, mountpoint) {
Ok(sup) => (
BackingSession::FuseWorker(sup),
super::VirtualizedMountOwner::InProcess(super::VirtualizedMountBackend::FuseWorker),
),
Err(native_err) => {
warn!(
thread = thread_id,
"heddle-fuse-worker spawn failed ({native_err}); falling back to NFS"
);
let reopened = Repository::open(&root)
.map_err(|e| anyhow!("reopen repo for NFS fallback: {e}"))?;
let mount = ContentAddressedMount::new(reopened, thread_id)
.map_err(|e| anyhow!("open mount for {thread_id} (NFS fallback): {e}"))?;
(
BackingSession::Nfs(NfsShell::new(mount).mount_background(mountpoint).map_err(
|e| {
anyhow!(
"FUSE worker spawn failed ({native_err}); NFS fallback also failed: {e}"
)
},
)?),
super::VirtualizedMountOwner::InProcess(super::VirtualizedMountBackend::Nfs),
)
}
};
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(super::SpawnedMount {
owner,
fskit_readiness: None,
})
}
fn spawn_fuse_worker(
repo_root: &Path,
thread_id: &str,
mountpoint: &Path,
) -> Result<Supervisor> {
let bin = default_worker_binary().context("locate heddle-fuse-worker")?;
Supervisor::spawn(&bin, repo_root, thread_id, mountpoint)
.with_context(|| format!("spawn heddle-fuse-worker for thread {thread_id}"))
}
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 = "macos", feature = "mount"))]
mod macos {
use std::{
io::{self, IsTerminal},
path::{Path, PathBuf},
process::Command,
sync::Mutex,
time::{Duration, Instant},
};
use anyhow::{Context, Result, anyhow};
use mount::{
ContentAddressedMount, NfsSession, NfsShell,
fskit::readiness::{self, Readiness},
};
use objects::{HeddleError, RecoveryDetails, sync::LockExt};
use repo::Repository;
use tracing::{debug, info, warn};
use crate::{cli::commands::mount_lifecycle::FskitReadinessReport, util::OnceMap};
const MIN_FSKIT_MACOS_MAJOR: u64 = 26;
const SETTINGS_PATH: &str =
"System Settings → General → Login Items & Extensions → File System Extensions";
const SETTINGS_LOGIN_ITEMS_URL: &str =
"x-apple.systempreferences:com.apple.LoginItems-Settings.extension";
const SETTINGS_SEQUOIA_FILE_EXTENSIONS_URL: &str =
"x-apple.systempreferences:com.apple.LoginItems-Settings.extension?Extensions";
const FSKIT_INSTALL_HINT: &str = "FSKit fast path available — install the host app: brew install --cask heddle (or download from https://github.com/HeddleCo/heddle/releases)";
const FSKIT_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const FSKIT_APPROVAL_TIMEOUT: Duration = Duration::from_secs(120);
const FSKIT_WAIT_MESSAGE: &str =
"Waiting for macOS to report Heddle enabled in File System Extensions";
const FSKIT_SPINNER_FRAMES: [char; 4] = ['|', '/', '-', '\\'];
const FSKIT_LINE_CLEAR_PADDING: &str = " ";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct MacOsVersion {
major: u64,
minor: u64,
patch: u64,
}
enum BackingSession {
FsKit(FsKitMount),
Nfs(NfsSession),
}
impl BackingSession {
fn unmount(self) -> Result<()> {
match self {
Self::FsKit(m) => m.unmount(),
Self::Nfs(s) => s.unmount().map_err(|e| anyhow!("nfs unmount: {e}")),
}
}
}
struct FsKitMount {
mountpoint: PathBuf,
}
impl FsKitMount {
fn mount(repo_path: &Path, thread_id: &str, mountpoint: &Path) -> Result<Self> {
let status = Command::new("/sbin/mount")
.arg("-F")
.arg("-t")
.arg("heddle")
.arg("-o")
.arg(format!("t={thread_id}"))
.arg(repo_path)
.arg(mountpoint)
.status()
.context("invoke /sbin/mount -F -t heddle")?;
if !status.success() {
return Err(anyhow!(
"/sbin/mount -F -t heddle returned {status} \
(extension installed but rejected the mount; \
check `log show --predicate 'subsystem == \"sh.heddle.HeddleFSModule\"'`)"
));
}
Ok(Self {
mountpoint: mountpoint.to_path_buf(),
})
}
fn unmount(self) -> Result<()> {
let status = Command::new("umount").arg(&self.mountpoint).status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(anyhow!("umount returned {s}")),
Err(e) => Err(anyhow!("invoke umount: {e}")),
}
}
}
pub struct MountHandle {
session: Mutex<Option<BackingSession>>,
mountpoint: PathBuf,
}
impl MountHandle {
pub fn unmount(&self) -> Result<()> {
let mut guard = self.session.lock_or_poisoned();
if let Some(s) = guard.take() {
s.unmount()?;
}
Ok(())
}
pub fn mountpoint(&self) -> &Path {
&self.mountpoint
}
}
static REGISTRY: OnceMap<String, std::sync::Arc<MountHandle>> = OnceMap::new();
struct MountedSession {
session: BackingSession,
owner: super::VirtualizedMountOwner,
}
impl MountedSession {
fn fskit(session: FsKitMount) -> Self {
Self {
session: BackingSession::FsKit(session),
owner: super::VirtualizedMountOwner::InProcess(
super::VirtualizedMountBackend::FsKit,
),
}
}
fn nfs(session: NfsSession) -> Self {
Self {
session: BackingSession::Nfs(session),
owner: super::VirtualizedMountOwner::InProcess(super::VirtualizedMountBackend::Nfs),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FsKitMountReport {
state: &'static str,
action: &'static str,
settings_url: Option<&'static str>,
}
impl FsKitMountReport {
fn readiness(self, backend: &'static str) -> FskitReadinessReport {
FskitReadinessReport {
state: self.state,
backend,
action: self.action,
settings_url: self.settings_url,
}
}
}
struct MacOsMountOutcome {
mounted: MountedSession,
fskit_readiness: Option<FskitReadinessReport>,
}
impl MacOsMountOutcome {
fn nfs(mounted: MountedSession, fskit_readiness: Option<FskitReadinessReport>) -> Self {
Self {
mounted,
fskit_readiness,
}
}
}
pub fn spawn_mount_for_thread(
repo: Repository,
thread_id: &str,
mountpoint: &Path,
interactive_setup: bool,
) -> Result<super::SpawnedMount> {
let root = repo.root().to_path_buf();
let outcome = if macos_supports_fskit() {
match readiness::probe() {
Readiness::Ready => {
info!(
thread = thread_id,
"FSKit extension ready; using `mount -F -t heddle`"
);
mount_via_fskit_or_nfs(
&root,
thread_id,
mountpoint,
FsKitMountReport {
state: "ready",
action: "mounted",
settings_url: None,
},
)?
}
Readiness::NeedsApproval => {
let settings_url = settings_deep_link().url;
if !super::fskit_interactive_setup_allowed(
interactive_setup,
io::stdin().is_terminal(),
io::stderr().is_terminal(),
) {
return Err(anyhow!(HeddleError::recovery(fskit_approval_required(
settings_url,
))));
}
print_needs_approval_block(settings_url);
open_settings(settings_url)?;
wait_for_fskit_ready(settings_url)?;
info!(
thread = thread_id,
"FSKit extension enabled during poll; using `mount -F -t heddle`"
);
mount_via_fskit_or_nfs(
&root,
thread_id,
mountpoint,
FsKitMountReport {
state: "ready_after_approval",
action: "mounted_after_poll",
settings_url: Some(settings_url),
},
)?
}
Readiness::NotInstalled => {
eprintln!("{FSKIT_INSTALL_HINT}");
MacOsMountOutcome::nfs(
mount_via_nfs(&root, thread_id, mountpoint)?,
Some(FskitReadinessReport {
state: "not_installed",
backend: "nfs",
action: "fell_back",
settings_url: None,
}),
)
}
Readiness::UnsupportedMacOS => {
eprintln!("{}", readiness::unsupported_macos_hint());
MacOsMountOutcome::nfs(
mount_via_nfs(&root, thread_id, mountpoint)?,
Some(FskitReadinessReport {
state: "unsupported_macos",
backend: "nfs",
action: "fell_back",
settings_url: None,
}),
)
}
Readiness::Unknown => {
MacOsMountOutcome::nfs(mount_via_nfs(&root, thread_id, mountpoint)?, None)
}
}
} else {
debug!("macOS version is below FSKit's supported runtime floor; using NFS fallback");
eprintln!("{}", readiness::unsupported_macos_hint());
MacOsMountOutcome::nfs(
mount_via_nfs(&root, thread_id, mountpoint)?,
Some(FskitReadinessReport {
state: "unsupported_macos",
backend: "nfs",
action: "fell_back",
settings_url: None,
}),
)
};
let handle = std::sync::Arc::new(MountHandle {
session: Mutex::new(Some(outcome.mounted.session)),
mountpoint: mountpoint.to_path_buf(),
});
REGISTRY.insert(thread_id.to_string(), std::sync::Arc::clone(&handle));
Ok(super::SpawnedMount {
owner: outcome.mounted.owner,
fskit_readiness: outcome.fskit_readiness,
})
}
fn macos_supports_fskit() -> bool {
let Some(version) = current_macos_version() else {
return false;
};
version.major >= MIN_FSKIT_MACOS_MAJOR
}
fn current_macos_version() -> Option<MacOsVersion> {
let output = Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
parse_macos_version(text.trim())
}
fn parse_macos_version(text: &str) -> Option<MacOsVersion> {
let mut parts = text.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some(MacOsVersion {
major,
minor,
patch,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SettingsDeepLink {
url: &'static str,
}
fn settings_deep_link() -> SettingsDeepLink {
let Some(version) = current_macos_version() else {
return SettingsDeepLink {
url: SETTINGS_LOGIN_ITEMS_URL,
};
};
settings_deep_link_for_version(version)
}
fn settings_deep_link_for_version(version: MacOsVersion) -> SettingsDeepLink {
let url = match version.major {
15 => SETTINGS_SEQUOIA_FILE_EXTENSIONS_URL,
26 => SETTINGS_LOGIN_ITEMS_URL,
_ => SETTINGS_LOGIN_ITEMS_URL,
};
SettingsDeepLink { url }
}
fn print_needs_approval_block(settings_url: &'static str) {
eprintln!(
"\nHeddle FSKit extension is installed, but macOS has not enabled it yet.\n\
\n\
macOS requires this approval in System Settings; Heddle cannot turn this permission on itself.\n\
\n\
Enable the fast path:\n\
1. Open {SETTINGS_PATH}.\n\
2. In that sheet, switch the picker to By Category / Extension Type, then turn on Heddle.\n\
3. If the By App view does not apply the change, stay on By Category / Extension Type; macOS controls this approval UI.\n\
\n\
Settings URL: {settings_url}\n\
Heddle will wait up to 120 seconds for macOS to report the extension enabled.\n"
);
}
fn open_settings(url: &str) -> Result<()> {
Command::new("open")
.arg(url)
.spawn()
.context("open System Settings for FSKit approval")?;
Ok(())
}
fn wait_for_fskit_ready(settings_url: &str) -> Result<()> {
let started = Instant::now();
let mut frame_index = 0usize;
loop {
if readiness::probe() == Readiness::Ready {
eprintln!(
"\rHeddle FSKit extension enabled; continuing. {FSKIT_LINE_CLEAR_PADDING}"
);
return Ok(());
}
if started.elapsed() >= FSKIT_APPROVAL_TIMEOUT {
return Err(anyhow!(HeddleError::recovery(fskit_approval_timeout(
settings_url,
FSKIT_APPROVAL_TIMEOUT.as_secs(),
))));
}
let frame = FSKIT_SPINNER_FRAMES[frame_index % FSKIT_SPINNER_FRAMES.len()];
eprint!("\r{frame} {FSKIT_WAIT_MESSAGE}.");
frame_index = frame_index.wrapping_add(1);
std::thread::sleep(FSKIT_POLL_INTERVAL);
}
}
fn fskit_approval_required(settings_url: &str) -> RecoveryDetails {
RecoveryDetails::safety_refusal(
"fskit_approval_required",
"macOS must approve the Heddle FSKit extension before this virtualized workspace can start",
format!("Rerun from a terminal with `--interactive-setup`, or enable Heddle at {settings_url}."),
"FSKit is installed but disabled, and this invocation cannot run interactive setup",
"opening System Settings or waiting for approval would block automation unexpectedly",
"System Settings was not opened and repository state was left unchanged",
)
.with_recovery_commands(vec!["heddle start --help".to_string()])
}
fn fskit_approval_timeout(settings_url: &str, timeout_seconds: u64) -> RecoveryDetails {
RecoveryDetails::safety_refusal(
"fskit_approval_timeout",
format!("macOS did not report the Heddle FSKit extension enabled within {timeout_seconds} seconds"),
format!("Enable Heddle at {settings_url}, then rerun the original start command."),
"the bounded interactive FSKit approval window expired",
"waiting without a deadline would leave the CLI blocked indefinitely",
"the virtualized workspace was not mounted and repository state was left unchanged",
)
.with_recovery_commands(vec!["heddle start --help".to_string()])
}
fn mount_via_nfs(
repo_root: &Path,
thread_id: &str,
mountpoint: &Path,
) -> Result<MountedSession> {
std::fs::create_dir_all(mountpoint)
.with_context(|| format!("create mount point {}", mountpoint.display()))?;
let repo = Repository::open(repo_root)
.with_context(|| format!("reopen repo for NFS mount at {}", repo_root.display()))?;
let mount = ContentAddressedMount::new(repo, thread_id)
.map_err(|e| anyhow!("open content-addressed mount for {thread_id}: {e}"))?;
let session = NfsShell::new(mount)
.mount_background(mountpoint)
.map_err(|e| {
anyhow!(
"NFS fallback failed: {e} (run `sudo` and ensure host has the NFS client enabled)"
)
})?;
warn!(
thread = thread_id,
"using NFS fallback (install + enable the Heddle FSKit extension for a faster path)"
);
Ok(MountedSession::nfs(session))
}
fn mount_via_fskit_or_nfs(
repo_root: &Path,
thread_id: &str,
mountpoint: &Path,
success: FsKitMountReport,
) -> Result<MacOsMountOutcome> {
std::fs::create_dir_all(mountpoint)
.with_context(|| format!("create mount point {}", mountpoint.display()))?;
match FsKitMount::mount(repo_root, thread_id, mountpoint) {
Ok(mount) => Ok(MacOsMountOutcome {
mounted: MountedSession::fskit(mount),
fskit_readiness: Some(success.readiness("fskit")),
}),
Err(error) => {
warn!(
thread = thread_id,
error = %error,
"FSKit mount failed after readiness probe; using NFS fallback"
);
eprintln!(
"Heddle FSKit mount failed ({error:#}); using NFS fallback for this run.\n\
If this follows a Heddle update, reinstall or re-enable the Heddle host app so macOS reloads the current File System Extension."
);
let mounted =
mount_via_nfs(repo_root, thread_id, mountpoint).with_context(|| {
format!("FSKit mount failed ({error:#}); NFS fallback also failed")
})?;
Ok(MacOsMountOutcome::nfs(
mounted,
Some(FskitReadinessReport {
state: "mount_failed",
backend: "nfs",
action: "fell_back",
settings_url: success.settings_url,
}),
))
}
}
}
pub fn unmount_thread_if_mounted(thread_id: &str) -> bool {
let Some(handle) = REGISTRY.remove(&thread_id.to_string()) else {
return false;
};
let mountpoint = handle.mountpoint().to_path_buf();
if let Err(err) = super::run_mount_io("macos-unmount", move || handle.unmount()) {
warn!(
thread = thread_id,
mountpoint = %mountpoint.display(),
"unmount failed: {err}"
);
}
true
}
}
#[cfg(all(target_os = "windows", feature = "mount"))]
mod windows {
use std::{
path::{Path, PathBuf},
sync::Mutex,
};
use anyhow::{Context, Result, anyhow};
use mount::{ContentAddressedMount, NfsSession, NfsShell, ProjFsSession, ProjFsShell};
use objects::sync::LockExt;
use repo::Repository;
use tracing::warn;
use crate::util::OnceMap;
enum BackingSession {
ProjFs(ProjFsSession),
Nfs(NfsSession),
}
impl BackingSession {
fn unmount(self) -> Result<()> {
match self {
Self::ProjFs(s) => s.unmount().map_err(|e| anyhow!("projfs unmount: {e}")),
Self::Nfs(s) => s.unmount().map_err(|e| anyhow!("nfs unmount: {e}")),
}
}
}
pub struct MountHandle {
session: Mutex<Option<BackingSession>>,
mountpoint: PathBuf,
}
impl MountHandle {
pub fn unmount(&self) -> Result<()> {
let mut guard = self.session.lock_or_poisoned();
if let Some(s) = guard.take() {
s.unmount()?;
}
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,
_interactive_setup: bool,
) -> Result<super::SpawnedMount> {
std::fs::create_dir_all(mountpoint)
.with_context(|| format!("create mount point {}", mountpoint.display()))?;
let root = repo.root().to_path_buf();
let mount = ContentAddressedMount::new(repo, thread_id)
.map_err(|e| anyhow!("open content-addressed mount for {thread_id}: {e}"))?;
let runtime_available = ProjFsShell::is_runtime_available();
if !runtime_available {
warn!(
thread = thread_id,
"ProjFS optional feature not enabled; using NFS fallback. \
Run `Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS` \
(Windows 10/11) or `... -FeatureName Projected-FS` (Windows Server) \
from an admin PowerShell for a faster mount.",
);
}
let (session, owner) = if runtime_available {
match ProjFsShell::new(mount).mount_background(mountpoint) {
Ok(s) => (
BackingSession::ProjFs(s),
super::VirtualizedMountOwner::InProcess(super::VirtualizedMountBackend::ProjFs),
),
Err(native_err) => {
warn!(
thread = thread_id,
"ProjFS mount failed ({native_err}); falling back to NFS",
);
let reopened = Repository::open(&root)
.map_err(|e| anyhow!("reopen repo for NFS fallback: {e}"))?;
let mount = ContentAddressedMount::new(reopened, thread_id)
.map_err(|e| anyhow!("open mount for {thread_id} (NFS fallback): {e}"))?;
(
BackingSession::Nfs(
NfsShell::new(mount)
.mount_background(mountpoint)
.map_err(|e| {
anyhow!(
"ProjFS mount failed ({native_err}); NFS fallback also failed: {e}"
)
})?,
),
super::VirtualizedMountOwner::InProcess(
super::VirtualizedMountBackend::Nfs,
),
)
}
}
} else {
(
BackingSession::Nfs(NfsShell::new(mount).mount_background(mountpoint).map_err(
|e| {
anyhow!(
"ProjFS unavailable and NFS fallback failed: {e}. \
Install the 'Projected File System' Windows optional feature \
(admin PowerShell: `Enable-WindowsOptionalFeature -Online \
-FeatureName Client-ProjFS`) or ensure the NFS client is enabled."
)
},
)?),
super::VirtualizedMountOwner::InProcess(super::VirtualizedMountBackend::Nfs),
)
};
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(super::SpawnedMount {
owner,
fskit_readiness: None,
})
}
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(all(target_os = "macos", feature = "mount"))]
#[allow(unused_imports)] pub(crate) use macos::MountHandle;
#[cfg(all(target_os = "macos", feature = "mount"))]
pub(crate) use macos::{spawn_mount_for_thread, unmount_thread_if_mounted};
#[cfg(all(target_os = "windows", feature = "mount"))]
#[allow(unused_imports)] pub(crate) use windows::MountHandle;
#[cfg(all(target_os = "windows", feature = "mount"))]
pub(crate) use windows::{spawn_mount_for_thread, unmount_thread_if_mounted};
#[cfg(not(any(
all(target_os = "linux", feature = "mount"),
all(target_os = "macos", feature = "mount"),
all(target_os = "windows", 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,
_interactive_setup: bool,
) -> Result<super::SpawnedMount> {
Err(super::virtualized_unsupported_error())
}
pub fn unmount_thread_if_mounted(_thread_id: &str) -> bool {
false
}
}
#[cfg(not(any(
all(target_os = "linux", feature = "mount"),
all(target_os = "macos", feature = "mount"),
all(target_os = "windows", feature = "mount"),
)))]
#[allow(unused_imports)] pub(crate) use stub::MountHandle;
#[cfg(not(any(
all(target_os = "linux", feature = "mount"),
all(target_os = "macos", feature = "mount"),
all(target_os = "windows", 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,
interactive_setup: bool,
) -> anyhow::Result<VirtualizedMountOutcome> {
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(VirtualizedMountOutcome::daemon()),
DaemonAttemptResolution::FallbackInProcess => spawn_in_process_mount(
repo_root.to_path_buf(),
thread_id.to_string(),
mountpoint.to_path_buf(),
interactive_setup,
),
DaemonAttemptResolution::Fatal(err) => Err(err),
}
}
MountOwnership::InProcess => spawn_in_process_mount(
repo_root.to_path_buf(),
thread_id.to_string(),
mountpoint.to_path_buf(),
interactive_setup,
),
}
}
fn spawn_in_process_mount(
repo_root: std::path::PathBuf,
thread_id: String,
mountpoint: std::path::PathBuf,
interactive_setup: bool,
) -> anyhow::Result<VirtualizedMountOutcome> {
run_mount_io("in-process-spawn", move || {
let mount_repo = repo::Repository::open(&repo_root)?;
let mounted =
spawn_mount_for_thread(mount_repo, &thread_id, &mountpoint, interactive_setup)?;
Ok(VirtualizedMountOutcome::in_process(mounted))
})
}
pub(crate) fn cleanup_virtualized_mount(
repo_root: &Path,
thread_id: &str,
owner: VirtualizedMountOwner,
) -> anyhow::Result<()> {
match owner {
VirtualizedMountOwner::Daemon => {
crate::cli::commands::daemon_client::unmount_via_daemon(repo_root, thread_id)?;
Ok(())
}
VirtualizedMountOwner::InProcess(_) => {
let thread_id = thread_id.to_string();
run_mount_io("in-process-unmount", move || {
unmount_thread_if_mounted(&thread_id);
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 fskit_setup_requires_explicit_opt_in_and_two_terminals() {
assert!(fskit_interactive_setup_allowed(true, true, true));
assert!(!fskit_interactive_setup_allowed(false, true, true));
assert!(!fskit_interactive_setup_allowed(true, false, true));
assert!(!fskit_interactive_setup_allowed(true, true, false));
}
#[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 daemon_outcome_records_daemon_owner_without_fskit_report() {
let outcome = VirtualizedMountOutcome::daemon();
assert_eq!(outcome.owner, VirtualizedMountOwner::Daemon);
assert_eq!(outcome.fskit_readiness, None);
}
#[test]
fn in_process_outcome_preserves_backend_and_fskit_report() {
let readiness = FskitReadinessReport {
state: "mount_failed",
backend: "nfs",
action: "fell_back",
settings_url: Some("x-apple.systempreferences:test"),
};
let outcome = VirtualizedMountOutcome::in_process(SpawnedMount {
owner: VirtualizedMountOwner::InProcess(VirtualizedMountBackend::Nfs),
fskit_readiness: Some(readiness.clone()),
});
assert_eq!(
outcome.owner,
VirtualizedMountOwner::InProcess(VirtualizedMountBackend::Nfs)
);
assert_eq!(outcome.fskit_readiness, Some(readiness));
}
#[test]
fn run_mount_io_leaves_tokio_runtime_thread() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime");
let runtime_thread = std::thread::current().id();
let mount_thread = runtime.block_on(async move {
run_mount_io("test-thread", move || Ok(std::thread::current().id()))
.expect("mount io helper should run")
});
assert_ne!(
runtime_thread, mount_thread,
"mount IO must leave a live Tokio runtime thread"
);
}
#[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"
);
}
}