Merge branch 'chore/sdk-0.10.1' into fix/clean-stop-runtime

# Conflicts:
#	docs/BUGS.md
This commit is contained in:
nvrl committed 2026-10-31 19:29:05 +01:00
commit 463c97a14d
34 files changed
+2800 -1228

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
//! Log priorities for journald.
//!
//! journald files each line a service writes at the unit's default priority,
//! info, unless the line opens with a syslog `<N>` prefix. Without one every
//! WARN and ERROR the daemon logs is stored as info, and the
//! `journalctl -p warning` that the troubleshooting guide asks for shows
//! nothing (`docs/BUGS.md` B258).
use std::fmt;
use std::os::unix::fs::MetadataExt;
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
/// Whether standard output is the journal stream systemd connected this
/// process to.
///
/// `JOURNAL_STREAM` alone does not say so: a terminal started from a user unit
/// inherits it, and every `pdfs` run in that terminal would print the
/// prefixes. systemd sets it to the stream's `device:inode`, so it is compared
/// against what fd 1 actually is.
pub(crate) fn stdout_is_journal() -> bool {
let Some(stream) = std::env::var_os("JOURNAL_STREAM") else {
return false;
};
let Ok(stdout) = std::fs::metadata("/proc/self/fd/1") else {
return false;
};
stream
.to_str()
.is_some_and(|value| names_stream(value, stdout.dev(), stdout.ino()))
}
/// Whether a `JOURNAL_STREAM` value names the file `dev`:`ino`.
fn names_stream(value: &str, dev: u64, ino: u64) -> bool {
value
.split_once(':')
.is_some_and(|(d, i)| d.parse() == Ok(dev) && i.parse() == Ok(ino))
}
/// The syslog priority journald should file a `level` line at.
fn priority(level: &Level) -> u8 {
match *level {
Level::ERROR => 3,
Level::WARN => 4,
Level::INFO => 6,
Level::DEBUG | Level::TRACE => 7,
}
}
/// An event format that opens each line with its `<N>` priority, then writes
/// the line as `F` would.
pub(crate) struct Prioritized<F>(pub(crate) F);
impl<S, N, F> FormatEvent<S, N> for Prioritized<F>
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
F: FormatEvent<S, N>,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
write!(writer, "<{}>", priority(event.metadata().level()))?;
self.0.format_event(ctx, writer, event)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Buf(Arc<Mutex<Vec<u8>>>);
impl io::Write for Buf {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn each_line_opens_with_the_priority_of_its_level() {
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let writer = buf.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.with_ansi(false)
.with_writer(move || writer.clone())
.event_format(Prioritized(tracing_subscriber::fmt::format()))
.finish();
tracing::subscriber::with_default(subscriber, || {
tracing::error!("e");
tracing::warn!("w");
tracing::info!("i");
tracing::debug!("d");
});
let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
let prefixes: Vec<&str> = out.lines().map(|line| &line[..3]).collect();
assert_eq!(prefixes, ["<3>", "<4>", "<6>", "<7>"]);
}
#[test]
fn journal_stream_must_name_the_same_file() {
assert!(names_stream("8:12345", 8, 12345));
assert!(!names_stream("8:12345", 8, 54321));
assert!(!names_stream("9:12345", 8, 12345));
assert!(!names_stream("8", 8, 12345));
assert!(!names_stream("", 8, 12345));
}
}
+274 -19
View File
@@ -20,6 +20,8 @@ use pdfs_core::control::{
use pdfs_core::db::Db;
use pdfs_core::service;
mod journal;
/// `println!` for this binary, shadowing std's, which panics when stdout is
/// closed: `pdfs ls | head` ended in a "Broken pipe" panic once `head` had its
/// lines. This one hands the failure to [`stdout_written`] instead.
@@ -879,15 +881,22 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
// packet with a longer (but equivalent) length encoding than the
// original; drop it to error so normal runs stay quiet.
.unwrap_or_else(|_| "info,pgp=error".into()),
)
.init();
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
// packet with a longer (but equivalent) length encoding than the
// original; drop it to error so normal runs stay quiet.
.unwrap_or_else(|_| "info,pgp=error".into());
if journal::stdout_is_journal() {
// journalctl colours lines by the priority the prefix gives them, so
// the escape codes would only be noise in the stored text.
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.event_format(journal::Prioritized(tracing_subscriber::fmt::format()))
.init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
@@ -1458,7 +1467,11 @@ fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
items: chosen,
},
None => CtlRequest::RestoreSyncFolders { items: chosen },
})?)
})?)?;
// The daemon acks before it works, so the outcome, and any folder it had
// to skip, only shows up later.
println!("Run `pdfs activity` to see which folders were restored.");
Ok(())
}
/// Expand a leading `~` in a hand-typed path. The daemon requires an absolute
@@ -2375,12 +2388,21 @@ where
/// 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<()> {
let state_dir = AppDirs::new()?.state_dir();
// A failure an earlier daemon recorded is not this one's.
pdfs_core::mountfail::clear(&state_dir);
let mut failure: Option<pdfs_core::mountfail::MountFailure> = None;
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.
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
match mount_once(mountpoint.clone()) {
let outcome = mount_once(mountpoint.clone());
if outcome.is_ok() {
// The mount came up, so the next failure starts a new run of them.
failure = None;
}
match outcome {
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
tracing::info!("daemon stopping");
return Ok(());
@@ -2395,13 +2417,51 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
Err(e) => {
// `{:#}` so the whole anyhow chain lands in the log — the top
// context alone ("mount failed") never says *why*.
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
let error = format!("{e:#}");
let now = unix_now();
let attempts = failure.as_ref().map_or(1, |f| f.attempts.saturating_add(1));
let delay = mount_retry_delay(attempts);
tracing::error!(
error,
attempts,
retry_secs = delay.as_secs(),
"mount failed; retrying"
);
let record = pdfs_core::mountfail::MountFailure {
error: error.clone(),
since: failure.as_ref().map_or(now, |f| f.since),
attempts,
next_attempt: now + delay.as_secs() as i64,
};
if let Err(e) = pdfs_core::mountfail::record(&state_dir, &record) {
tracing::warn!(error = %e, "could not record the mount failure");
}
pdfs_fuse::notify_status(&format!("Mount failing: {error}"));
failure = Some(record);
pdfs_fuse::idle_sleep(delay);
}
}
}
}
/// How long to wait before mount attempt `attempts + 1`, after `attempts`
/// failures in a row.
///
/// Doubles from 5 s to a 2-minute ceiling. A flat 5 s retry sent about four
/// requests to Proton per attempt, tens of thousands a day from an account
/// whose mount fails on a key, which no retry fixes (`docs/BUGS.md` B263). The
/// ceiling stays at minutes because a cold start without a network fails the
/// same way, and must mount soon after the network returns.
fn mount_retry_delay(attempts: u32) -> std::time::Duration {
const BASE_SECS: u64 = 5;
const MAX_SECS: u64 = 120;
let factor = 1u64
.checked_shl(attempts.saturating_sub(1))
.unwrap_or(u64::MAX);
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
}
/// 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.
@@ -3112,6 +3172,24 @@ fn human_secs(secs: u64) -> String {
}
}
/// The sync engine's line in `pdfs diagnostics`: whether it runs, and which
/// folder's pass it is on.
fn sync_engine_line(sync: &pdfs_core::control::SyncEngineState) -> String {
let mut line = match (sync.running, sync.pass_folder) {
(false, _) => "STOPPED; synced folders do not sync until the daemon restarts".to_string(),
(true, None) => "idle".to_string(),
(true, Some(folder)) => format!(
"pass on folder {folder} for {}, last progress {} ago",
human_secs(sync.pass_age_secs),
human_secs(sync.pass_idle_secs),
),
};
if sync.panics > 0 {
line.push_str(&format!("; {} pass(es) panicked", sync.panics));
}
line
}
fn cmd_diagnostics() -> Result<()> {
let response = control_request(CtlRequest::Diagnostics)?;
if emit_json(&response)? {
@@ -3147,6 +3225,9 @@ fn cmd_diagnostics() -> Result<()> {
human_secs(worker.age_secs)
);
}
if let Some(sync) = &d.sync {
println!("sync engine: {}", sync_engine_line(sync));
}
if d.inflight.is_empty() {
println!("in-flight requests: none");
} else {
@@ -3385,6 +3466,79 @@ fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
})?)
}
/// How long the oldest write may wait to upload before `diagnose` calls it
/// out. Under a day it is a slow link or a big file; past it, something is
/// holding the queue.
const STAGED_STALE_SECS: u64 = 86_400;
/// `diagnose` findings for the queue and the pause, from the `Status` reply.
/// `paused_for` is how long a timed pause still runs, in seconds.
fn sync_health(
failing_ops: u64,
failing_error: Option<&str>,
staged_bytes: u64,
staged_oldest_secs: u64,
paused: bool,
paused_for: Option<i64>,
) -> Vec<(DiagnoseLevel, &'static str, String)> {
let mut findings = Vec::new();
if failing_ops > 0 {
findings.push((
DiagnoseLevel::Warn,
" failing writes",
match failing_error {
Some(error) => format!("{failing_ops} keep failing; the last error: {error}"),
None => format!("{failing_ops} keep failing"),
},
));
}
if staged_bytes > 0 && staged_oldest_secs >= STAGED_STALE_SECS {
findings.push((
DiagnoseLevel::Warn,
" oldest unsent write",
format!(
"{} old, {} only on this computer",
human_secs(staged_oldest_secs),
human_bytes(staged_bytes)
),
));
}
if paused {
findings.push((
DiagnoseLevel::Warn,
" sync",
match paused_for {
Some(left) if left > 0 => {
format!("paused for another {}", human_secs(left as u64))
}
_ => "paused until resumed (`pdfs sync resume`)".to_string(),
},
));
}
findings
}
/// `diagnose` findings for the synced folders that need someone to look.
fn folder_health(
folders: &[pdfs_core::control::SyncFolderInfo],
) -> Vec<(DiagnoseLevel, String, String)> {
folders
.iter()
.filter_map(|folder| {
let what = match folder.state.as_str() {
"error" => "sync error; `pdfs sync list` and the activity feed say more",
"conflict" => "conflict copies to look at",
_ => return None,
};
Some((
DiagnoseLevel::Warn,
format!(" synced folder [{}]", folder.id),
format!("{}: {what}", folder.local_path),
))
})
.collect()
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -3721,21 +3875,26 @@ fn cmd_diagnose() -> Result<()> {
let socket_exists = socket.exists();
let socket_kind_ok =
std::fs::symlink_metadata(&socket).is_ok_and(|m| m.file_type().is_socket());
// A stale socket file with no listener is the common failure, so the reply
// is what decides this rather than the file's existence.
let status = control_request(CtlRequest::Status);
report.finding(
if socket_exists && !socket_kind_ok {
DiagnoseLevel::Fail
} else if socket_exists {
} else if socket_exists && status.is_ok() {
DiagnoseLevel::Ok
} else {
DiagnoseLevel::Warn
},
" control socket",
socket.display().to_string(),
if socket_exists && socket_kind_ok && status.is_err() {
format!("{} (left over; nothing listens on it)", socket.display())
} else {
socket.display().to_string()
},
);
// A stale socket file with no listener is the common failure, so the reply
// is what decides this rather than the file's existence.
let mut daemon_responding = false;
match control_request(CtlRequest::Status) {
match status {
Ok(CtlResponse::Status {
mountpoint,
online,
@@ -3743,6 +3902,12 @@ fn cmd_diagnose() -> Result<()> {
tokens_unsaved,
pending_uploads,
pending_changes,
failing_ops,
failing_error,
staged_bytes,
staged_oldest_secs,
paused,
paused_until,
version,
..
}) => {
@@ -3817,6 +3982,27 @@ fn cmd_diagnose() -> Result<()> {
format!("{pending} waiting to upload")
},
);
// The rest of the daemon's state that the report used to drop:
// writes that keep failing, a pause, a folder in error. Each of
// them stops changes from reaching Drive while everything above
// reads as fine (`docs/BUGS.md` B268).
for (level, check, detail) in sync_health(
failing_ops,
failing_error.as_deref(),
staged_bytes,
staged_oldest_secs,
paused,
paused_until.map(|until| until - unix_now()),
) {
report.finding(level, check, detail);
}
if let Ok(CtlResponse::SyncFolders { items }) =
control_request(CtlRequest::ListSyncFolders)
{
for (level, check, detail) in folder_health(&items) {
report.finding(level, &check, detail);
}
}
match await_integrity_check() {
Ok(CtlResponse::CacheReport {
integrity_problems,
@@ -3873,7 +4059,23 @@ fn cmd_diagnose() -> Result<()> {
" daemon responding",
format!("stopped or unreachable: {e:#}"),
);
println!(" (start it with `systemctl --user start proton-drive.service`)");
match pdfs_core::mountfail::read(&dirs.state_dir()) {
// A daemon whose mount keeps failing never opens the socket,
// so this is the only place its reason shows (B263).
Some(failure) => report.finding(
DiagnoseLevel::Fail,
" mount",
format!(
"failing for {}, {} attempt(s): {}",
human_secs(unix_now().saturating_sub(failure.since).max(0) as u64),
failure.attempts,
failure.error
),
),
None => println!(
" (start it with `systemctl --user start proton-drive.service`)"
),
}
}
}
@@ -4039,6 +4241,50 @@ mod pause_tests {
mod diagnose_tests {
use super::*;
/// A healthy queue says nothing; writes that keep failing, an old unsent
/// write and a pause each say so (`docs/BUGS.md` B268).
#[test]
fn a_stuck_queue_or_a_pause_is_reported() {
assert!(sync_health(0, None, 0, 0, false, None).is_empty());
assert!(sync_health(0, None, 4096, STAGED_STALE_SECS - 1, false, None).is_empty());
let checks: Vec<&str> = sync_health(
2,
Some("upload a.txt: quota"),
4096,
STAGED_STALE_SECS,
true,
Some(600),
)
.into_iter()
.map(|(level, check, _)| {
assert!(matches!(level, DiagnoseLevel::Warn));
check.trim()
})
.collect();
assert_eq!(checks, ["failing writes", "oldest unsent write", "sync"]);
}
/// Issue #53: a folder that would not sync, and `diagnose` found nothing.
#[test]
fn a_synced_folder_in_error_is_reported() {
let folder = |id: i64, state: &str| pdfs_core::control::SyncFolderInfo {
id,
local_path: format!("/home/u/f{id}"),
remote_uid: "vol~link".into(),
mode: "mirror".into(),
state: state.into(),
last_sync: 0,
pending_mode: None,
progress: None,
paused: false,
};
let findings =
folder_health(&[folder(1, "idle"), folder(2, "error"), folder(3, "syncing")]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].1.trim(), "synced folder [2]");
assert!(findings[0].2.starts_with("/home/u/f2: sync error"));
}
#[test]
fn finding_severity_is_counted_centrally() {
let mut report = DiagnoseReport::default();
@@ -4074,6 +4320,15 @@ mod diagnose_tests {
#[cfg(test)]
mod login_wait_tests {
use super::*;
/// The mount retry backs off from 5 s to a ceiling of minutes, rather than
/// asking Proton every 5 s forever (`docs/BUGS.md` B263).
#[test]
fn a_failing_mount_backs_off_to_a_ceiling() {
let secs: Vec<u64> = (1..=8).map(|n| mount_retry_delay(n).as_secs()).collect();
assert_eq!(secs, [5, 10, 20, 40, 80, 120, 120, 120]);
assert_eq!(mount_retry_delay(u32::MAX).as_secs(), 120);
}
use std::time::Duration;
#[test]