From 99e80a7206a8ac6de79f024b4057ad7dea0747ae Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 30 Jun 2026 05:43:14 +0200 Subject: [PATCH] implemented robust fuse mount --- crates/pdfs-cli/src/main.rs | 56 +++++++++++++- crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/service.rs | 51 +++++++++++++ crates/pdfs-fuse/src/lib.rs | 126 +++++++++++++++++++++++++++++--- crates/pdfs-gui/src/app.rs | 76 +++++-------------- crates/pdfs-gui/src/main.rs | 41 +++-------- packaging/PKGBUILD | 5 ++ packaging/proton-drive.service | 19 +++++ 8 files changed, 276 insertions(+), 99 deletions(-) create mode 100644 crates/pdfs-core/src/service.rs create mode 100644 packaging/proton-drive.service diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index ff9f4a1..b3a3e97 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -38,6 +38,13 @@ enum Command { /// Mountpoint; defaults to ~/ProtonDrive. mountpoint: Option, }, + /// 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, + }, /// 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) -> 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) -> 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) -> Result { let dirs = AppDirs::new()?; dirs.ensure()?; let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint()); @@ -230,8 +282,8 @@ fn cmd_mount(mountpoint: Option) -> 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<()> { diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 881b3ff..a996fa7 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod cache; pub mod config; pub mod control; pub mod error; +pub mod service; pub use error::{Error, Result}; diff --git a/crates/pdfs-core/src/service.rs b/crates/pdfs-core/src/service.rs new file mode 100644 index 0000000..dddf96e --- /dev/null +++ b/crates/pdfs-core/src/service.rs @@ -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 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"]) +} diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index b3be1d7..c053eba 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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 { 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) } diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index f532b71..34c64ff 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -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>, + /// 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.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, button: >k4::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, button: >k4::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) { - 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) { @@ -547,12 +533,7 @@ fn refresh(ui: &Rc) { }; *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"]; diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index b19b08a..f8f8838 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -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> { - // 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> = 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() } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 63c4538..26428a5 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -61,5 +61,10 @@ package() { install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \ "$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg" + # systemd user service that runs the auto-mount daemon. Enabled per-user by + # the GUI on login (`systemctl --user enable --now proton-drive.service`). + install -Dm644 "packaging/proton-drive.service" \ + "$pkgdir/usr/lib/systemd/user/proton-drive.service" + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/packaging/proton-drive.service b/packaging/proton-drive.service new file mode 100644 index 0000000..faee49e --- /dev/null +++ b/packaging/proton-drive.service @@ -0,0 +1,19 @@ +[Unit] +Description=Proton Drive Files-On-Demand mount +Documentation=https://github.com/narl/proton-drive-linux +# Tie the mount to the desktop session: start after it is up and stop with it. +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it +# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount. +ExecStart=/usr/bin/pdfs daemon +Restart=on-failure +RestartSec=3 +# Give the lazy unmount time to finish before SIGKILL. +TimeoutStopSec=15 + +[Install] +WantedBy=default.target