implemented robust fuse mount

This commit is contained in:
nvrl committed 2026-06-30 05:43:14 +02:00
1 parent ca30dceae7
commit 99e80a7206
8 files changed
+276 -99

No files matched your search

+54 -2
View File
@@ -38,6 +38,13 @@ enum Command {
/// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>,
},
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
/// until stopped. This is what the systemd user service runs; you normally
/// don't invoke it by hand.
Daemon {
/// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>,
},
/// Keep a file on this device (download + cache it). Needs a running mount.
Pin {
/// File path, inside the mountpoint or relative to it.
@@ -88,6 +95,7 @@ fn main() -> Result<()> {
Command::Logout => cmd_logout(),
Command::Status => cmd_status(),
Command::Mount { mountpoint } => cmd_mount(mountpoint),
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
Command::Pin { path } => cmd_pin(path),
Command::Unpin { path } => cmd_unpin(path),
Command::Pins => cmd_pins(),
@@ -158,6 +166,50 @@ fn cmd_status() -> Result<()> {
}
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
mount_once(mountpoint)?;
Ok(())
}
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
/// an external unmount triggers a remount; errors back off and retry. This is
/// the entry point for the systemd user service.
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
loop {
// Wait until a session is stored. The GUI enables this service on login,
// but the service may also start at boot before the user has logged in.
loop {
match auth::load() {
Ok(_) => break,
Err(pdfs_core::Error::NotLoggedIn) => {
tracing::info!("not logged in; waiting…");
std::thread::sleep(std::time::Duration::from_secs(3));
}
Err(e) => return Err(e.into()),
}
}
match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
tracing::info!("daemon stopping");
return Ok(());
}
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
tracing::warn!("mount ended externally; remounting in 2s");
std::thread::sleep(std::time::Duration::from_secs(2));
}
Err(e) => {
tracing::error!(error = %e, "mount failed; retrying in 5s");
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
}
}
/// One mount run: resume the session, mount, and block until the mount ends
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
/// daemon loop can decide whether to exit or remount.
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
let dirs = AppDirs::new()?;
dirs.ensure()?;
let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
@@ -230,8 +282,8 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
}
result.context("mount failed")?;
Ok(())
let outcome = result.context("mount failed")?;
Ok(outcome)
}
fn cmd_pin(path: PathBuf) -> Result<()> {
+1
View File
@@ -7,6 +7,7 @@ pub mod cache;
pub mod config;
pub mod control;
pub mod error;
pub mod service;
pub use error::{Error, Result};
+51
View File
@@ -0,0 +1,51 @@
//! Thin wrappers over `systemctl --user` for the auto-mount daemon.
//!
//! The mount is owned by a systemd *user* service (`proton-drive.service`, see
//! `packaging/`). Front-ends never spawn `pdfs mount` directly anymore: logging
//! in enables+starts the unit, logging out disables+stops it, and the tray's
//! "Disconnect" just stops it. systemd then handles restart-on-failure and a
//! clean SIGTERM stop (which the daemon turns into a lazy unmount).
//!
//! Every call is best-effort: a failure is logged but not fatal, since the user
//! can always drive `systemctl --user` by hand.
use std::process::{Command, Stdio};
/// The systemd user unit that runs `pdfs daemon`.
pub const SERVICE: &str = "proton-drive.service";
/// Run one `systemctl --user <args> proton-drive.service`, returning whether it
/// succeeded. Output is suppressed; failures are logged.
fn systemctl(args: &[&str]) -> bool {
let ok = Command::new("systemctl")
.arg("--user")
.args(args)
.arg(SERVICE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
tracing::warn!(args = ?args, "systemctl --user {SERVICE} failed");
}
ok
}
/// Enable the service so it survives reboots/logins and start it now. Called on
/// successful login.
pub fn enable_start() -> bool {
systemctl(&["enable", "--now"])
}
/// Stop the running mount without disabling it. Called by the tray's
/// "Disconnect": the next login (or reboot) brings it back.
pub fn stop() -> bool {
systemctl(&["stop"])
}
/// Disable the service so it no longer auto-starts, and stop it now. Called on
/// logout.
pub fn disable_stop() -> bool {
systemctl(&["disable", "--now"])
}
+115 -11
View File
@@ -1667,12 +1667,53 @@ fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listene
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted.
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
/// shutdown) or remount (the mount went away under it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountOutcome {
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
/// ourselves. The caller should exit cleanly.
Shutdown,
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
}
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
/// previous run that died without unmounting leaves the kernel mount in place,
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
/// when the old mount is still busy. Swallow all output/errors: if there is no
/// stale mount this is simply a no-op.
fn clear_stale_mount(mountpoint: &Path) {
for bin in ["fusermount3", "fusermount"] {
let ok = std::process::Command::new(bin)
.arg("-u")
.arg("-z")
.arg(mountpoint)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
return;
}
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop.
///
/// Fetches the My Files root up front (so an auth/network failure surfaces
/// before the kernel mount), then spawns the Phase 2 event-sync task, the
/// Phase 4 control socket, and takes over the calling thread in the FUSE session
/// loop. `rt` must be a handle to a *running* multi-threaded runtime.
/// Phase 4 control socket, and runs the FUSE session on its own thread while
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
/// mount ending on its own. On a stop signal we lazily unmount ourselves
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
/// are in flight), so `systemctl --user stop` is always a clean teardown.
/// `rt` must be a handle to a *running* multi-threaded runtime.
pub fn mount(
client: ProtonDriveClient,
rt: tokio::runtime::Handle,
@@ -1680,7 +1721,7 @@ pub fn mount(
cache: ContentCache,
control_socket: &Path,
username: String,
) -> std::io::Result<()> {
) -> std::io::Result<MountOutcome> {
let root = rt
.block_on(client.get_my_files_folder())
.map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?;
@@ -1707,6 +1748,10 @@ pub fn mount(
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
// A crashed previous run can leave the kernel mount in place, which makes
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
// Bind the control socket before the FUSE session takes over the thread. A
@@ -1725,16 +1770,75 @@ pub fn mount(
let fs = ProtonFs::new(core.clone(), root);
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own thread;
// `join` then blocks here until the filesystem is unmounted, preserving
// `mount`'s blocking contract.
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
let notifier = session.notifier();
// for the event task. `spawn` runs the session loop on its own background
// thread; we then wait here for either a stop signal or the mount ending.
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
let notifier = bg.notifier();
rt.spawn(run_event_sync(
client, scope, core.state, core.cache, notifier,
));
let result = session.join();
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so
// the loop below can react without blocking a worker thread. A bounded
// channel of 1 is enough — we only need to know that *a* stop arrived.
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
rt.spawn(async move {
let mut sigterm = match tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate(),
) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGTERM handler failed");
return;
}
};
let mut sigint = match tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::interrupt(),
) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGINT handler failed");
return;
}
};
tokio::select! {
_ = sigterm.recv() => info!("received SIGTERM"),
_ = sigint.recv() => info!("received SIGINT"),
}
let _ = sig_tx.try_send(());
});
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
// mount ending on its own (→ the session thread finishes). Poll instead of
// blocking on `join` so we can also notice the signal.
let outcome = loop {
match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => {
info!("stop requested; unmounting");
if let Err(e) = bg.umount_and_join() {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::Shutdown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
if let Err(e) = bg.join() {
warn!(error = %e, "session join failed");
}
break MountOutcome::Unmounted;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join.
let _ = bg.join();
break MountOutcome::Unmounted;
}
}
};
let _ = std::fs::remove_file(control_socket);
result
Ok(outcome)
}
+19 -57
View File
@@ -6,8 +6,8 @@
//! Google-Drive-style layout:
//!
//! * a **login form** (email / password / optional 2FA) driving
//! [`pdfs_core::auth::login`];
//! * a **mount toggle** that spawns `pdfs mount` / `fusermount3 -u`;
//! [`pdfs_core::auth::login`]; logging in enables the systemd mount service;
//! * a **read-only mount status** line (the mount is managed automatically);
//! * a **cache usage** read-out from the on-disk [`ContentCache`]; and
//! * the **pin list** with per-file unpin.
//!
@@ -25,12 +25,13 @@ use std::time::Duration;
use adw::prelude::*;
use gtk4::gio;
use gtk4::glib;
use gtk4::glib::{BoxedAnyObject, SignalHandlerId};
use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::{AppDirs, DEFAULT_CACHE_BUDGET_BYTES};
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send};
use pdfs_core::service;
/// How many photos to pull per [`Request::PhotosTimeline`] page.
const PHOTOS_PAGE: usize = 60;
@@ -56,8 +57,9 @@ struct Ui {
login_status: gtk4::Label,
// Main page.
account_row: adw::ActionRow,
mount_row: adw::SwitchRow,
mount_handler: RefCell<Option<SignalHandlerId>>,
/// Read-only mount status line. The mount is driven by the systemd user
/// service (enabled on login), not by the user — so this only reports.
mount_row: adw::ActionRow,
cache_bar: gtk4::ProgressBar,
cache_label: gtk4::Label,
pins_group: adw::PreferencesGroup,
@@ -177,7 +179,6 @@ fn build_window(app: &adw::Application) {
login_status: login_widgets.4,
account_row: main_widgets.0,
mount_row: main_widgets.1,
mount_handler: RefCell::new(None),
cache_bar: main_widgets.2,
cache_label: main_widgets.3,
pins_group: main_widgets.4,
@@ -196,7 +197,6 @@ fn build_window(app: &adw::Application) {
wire_login(&ui);
wire_logout(&ui, &main_widgets.5);
wire_mount_switch(&ui);
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
wire_gallery(&ui, &gallery_widgets.3);
@@ -331,7 +331,7 @@ fn build_main_page() -> (
gtk4::Widget,
(
adw::ActionRow,
adw::SwitchRow,
adw::ActionRow,
gtk4::ProgressBar,
gtk4::Label,
adw::PreferencesGroup,
@@ -351,10 +351,11 @@ fn build_main_page() -> (
account_row.add_suffix(&logout_button);
account_group.add(&account_row);
// Mount group: a switch row driving the FUSE mount.
// Mount group: a read-only status line. The mount is managed automatically
// by the systemd user service; there is no toggle to fiddle with.
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
let mount_row = adw::SwitchRow::builder()
.title("Mount Proton Drive")
let mount_row = adw::ActionRow::builder()
.title("Proton Drive")
.subtitle("Not mounted")
.build();
mount_group.add(&mount_row);
@@ -439,6 +440,8 @@ fn wire_login(ui: &Rc<Ui>) {
ui.login_status.set_text("");
ui.password.set_text("");
ui.totp.set_text("");
// Enable+start the mount service now that we have a session.
service::enable_start();
refresh(&ui);
}
Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")),
@@ -485,14 +488,13 @@ fn spawn_login(
rx
}
/// Connect the sign-out button: forget the stored session and drop back to the
/// login page. Unmounts first so the daemon isn't left without credentials.
/// Connect the sign-out button: disable+stop the mount service (so the daemon
/// isn't left running without credentials), forget the stored session, and drop
/// back to the login page.
fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
let ui = ui.clone();
button.connect_clicked(move |_| {
if *ui.mounted.borrow() {
unmount(&ui.dirs.default_mountpoint());
}
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
@@ -500,22 +502,6 @@ fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
});
}
/// Connect the mount switch. The handler id is stored so the refresh loop can
/// block it while syncing the switch to the real mount state (otherwise setting
/// `active` would re-trigger a mount/unmount).
fn wire_mount_switch(ui: &Rc<Ui>) {
let ui_for_handler = ui.clone();
let handler = ui.mount_row.connect_active_notify(move |row| {
let mountpoint = ui_for_handler.dirs.default_mountpoint();
if row.is_active() {
spawn_mount();
} else {
unmount(&mountpoint);
}
});
*ui.mount_handler.borrow_mut() = Some(handler);
}
/// Re-read mount status, login identity, cache usage and the pin list, and
/// repaint the window. Cheap, synchronous local reads — safe on the main loop.
fn refresh(ui: &Rc<Ui>) {
@@ -547,12 +533,7 @@ fn refresh(ui: &Rc<Ui>) {
};
*ui.mounted.borrow_mut() = mount.is_some();
// Sync the switch without re-triggering its handler.
if let Some(id) = ui.mount_handler.borrow().as_ref() {
ui.mount_row.block_signal(id);
ui.mount_row.set_active(mount.is_some());
ui.mount_row.unblock_signal(id);
}
// Read-only status: the service mounts automatically, we only report.
ui.mount_row.set_subtitle(&match &mount {
Some(mp) => format!("Mounted at {mp}"),
None => "Not mounted".into(),
@@ -1299,25 +1280,6 @@ fn open_path(path: &str) {
}
}
/// Launch `pdfs mount` detached so the window keeps running independently.
fn spawn_mount() {
match Command::new("pdfs").arg("mount").spawn() {
Ok(_) => tracing::info!("spawned `pdfs mount`"),
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
}
}
/// Tear down the FUSE mount. `fusermount3 -u` is the native path; fall back to
/// `fusermount` on older systems.
fn unmount(mountpoint: &Path) {
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
match try_unmount("fusermount3").or_else(|_| try_unmount("fusermount")) {
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
Ok(s) => tracing::error!("unmount exited with {s}"),
Err(e) => tracing::error!("failed to run fusermount: {e}"),
}
}
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
+12 -29
View File
@@ -19,6 +19,7 @@ use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, send};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
const POLL_INTERVAL: Duration = Duration::from_secs(3);
@@ -71,26 +72,6 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
}
}
/// Launch `pdfs mount` detached so the tray keeps running independently.
fn spawn_mount() {
match Command::new("pdfs").arg("mount").spawn() {
Ok(_) => tracing::info!("spawned `pdfs mount`"),
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
}
}
/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown;
/// fall back to `fusermount` on older systems.
fn unmount(mountpoint: &Path) {
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount"));
match result {
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
Ok(s) => tracing::error!("unmount exited with {s}"),
Err(e) => tracing::error!("failed to run fusermount: {e}"),
}
}
fn open_folder(mountpoint: &Path) {
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
@@ -111,9 +92,8 @@ impl Tray for DriveTray {
}
fn menu(&self) -> Vec<MenuItem<Self>> {
// Values captured into the action closures (which only get `&mut Self`).
let mountpoint = self.state.mountpoint.clone();
let open_mp = mountpoint.clone();
// Value captured into the action closure (which only gets `&mut Self`).
let open_mp = self.state.mountpoint.clone();
let mut items: Vec<MenuItem<Self>> = vec![
StandardItem {
@@ -134,25 +114,28 @@ impl Tray for DriveTray {
}
.into(),
);
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
// login or reboot brings it back; this is a deliberate disconnect.
items.push(
StandardItem {
label: "Unmount".into(),
label: "Disconnect".into(),
activate: Box::new(move |this: &mut Self| {
unmount(&mountpoint);
service::stop();
this.state.mounted = false;
this.state.line = "Unmounting…".into();
this.state.line = "Disconnecting…".into();
}),
..Default::default()
}
.into(),
);
} else {
// Enable+start the service so it mounts now and on future logins.
items.push(
StandardItem {
label: "Mount".into(),
label: "Connect".into(),
activate: Box::new(|this: &mut Self| {
spawn_mount();
this.state.line = "Mounting…".into();
service::enable_start();
this.state.line = "Connecting…".into();
}),
..Default::default()
}