From 25c57c55fb328ae205bad78e1a1fc19c73fe15d1 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 24 Feb 2026 14:23:30 +0300 Subject: [PATCH] feat: add per-account lock file protection via fs2 Prevent running multiple tele-tui instances with the same account by using advisory file locks (flock). Lock is acquired before raw mode so errors print to normal terminal. Account switching acquires new lock before releasing old. Also log set_tdlib_parameters errors via tracing instead of silently discarding them. Co-Authored-By: Claude Opus 4.6 --- CONTEXT.md | 25 +++++++++ Cargo.lock | 11 ++++ Cargo.toml | 1 + src/accounts/lock.rs | 127 +++++++++++++++++++++++++++++++++++++++++++ src/accounts/mod.rs | 2 + src/app/mod.rs | 5 ++ src/main.rs | 32 ++++++++++- src/tdlib/client.rs | 7 ++- 8 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/accounts/lock.rs diff --git a/CONTEXT.md b/CONTEXT.md index 60865de..b491f05 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,31 @@ ## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) +### Per-Account Lock File Protection — DONE + +Защита от запуска двух экземпляров tele-tui с одним аккаунтом + логирование ошибок TDLib. + +**Проблема**: При запуске второго экземпляра с тем же аккаунтом, TDLib не может залочить свою БД. `set_tdlib_parameters` молча падает (`let _ = ...`), и приложение зависает на "Инициализация TDLib...". + +**Решение**: Advisory file locks через `fs2` (flock): +- **Lock файл**: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock` +- **Автоматическое освобождение** при crash/SIGKILL (ядро ОС закрывает file descriptors) +- **При старте**: acquire lock ДО `enable_raw_mode()` → ошибка выводится в обычный терминал +- **При переключении аккаунтов**: acquire new → release old → switch (при ошибке — остаёмся на старом) +- **Логирование**: `set_tdlib_parameters` ошибки теперь логируются через `tracing::error!` + +**Новые файлы:** +- `src/accounts/lock.rs` — `acquire_lock()`, `release_lock()`, `account_lock_path()` + 4 теста + +**Модифицированные файлы:** +- `Cargo.toml` — зависимость `fs2 = "0.4"` +- `src/accounts/mod.rs` — `pub mod lock;` + re-exports +- `src/app/mod.rs` — поле `account_lock: Option` в `App` +- `src/main.rs` — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters +- `src/tdlib/client.rs` — логирование set_tdlib_parameters в `recreate_client()` + +--- + ### Photo Albums (Media Groups) — DONE Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото. diff --git a/Cargo.lock b/Cargo.lock index 6c7b974..b613951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -3383,6 +3393,7 @@ dependencies = [ "crossterm", "dirs 5.0.1", "dotenvy", + "fs2", "image", "insta", "notify-rust", diff --git a/Cargo.toml b/Cargo.toml index 8610cf7..5d7dcf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ thiserror = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22.1" +fs2 = "0.4" [dev-dependencies] insta = "1.34" diff --git a/src/accounts/lock.rs b/src/accounts/lock.rs new file mode 100644 index 0000000..db135b9 --- /dev/null +++ b/src/accounts/lock.rs @@ -0,0 +1,127 @@ +//! Per-account advisory file locking to prevent concurrent access. +//! +//! Uses `flock` (via `fs2`) for automatic lock release on process crash/SIGKILL. +//! Lock file: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock` + +use fs2::FileExt; +use std::fs::{self, File}; +use std::path::PathBuf; + +/// Returns the lock file path for a given account. +/// +/// Path: `{data_dir}/tele-tui/accounts/{name}/tele-tui.lock` +pub fn account_lock_path(account_name: &str) -> PathBuf { + let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("tele-tui"); + path.push("accounts"); + path.push(account_name); + path.push("tele-tui.lock"); + path +} + +/// Acquires an exclusive advisory lock for the given account. +/// +/// Creates the lock file and parent directories if needed. +/// Returns the open `File` handle — the lock is held as long as this handle exists. +/// +/// # Errors +/// +/// Returns an error message if the lock is already held by another process +/// or if the lock file cannot be created. +pub fn acquire_lock(account_name: &str) -> Result { + let lock_path = account_lock_path(account_name); + + // Ensure parent directory exists + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + format!( + "Не удалось создать директорию для lock-файла: {}", + e + ) + })?; + } + + let file = File::create(&lock_path).map_err(|e| { + format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e) + })?; + + file.try_lock_exclusive().map_err(|_| { + format!( + "Аккаунт '{}' уже используется другим экземпляром tele-tui.\n\ + Lock-файл: {}", + account_name, + lock_path.display() + ) + })?; + + Ok(file) +} + +/// Explicitly releases the lock by unlocking and dropping the file handle. +/// +/// Used during account switching to release the old account's lock +/// before acquiring the new one. +pub fn release_lock(lock_file: File) { + let _ = lock_file.unlock(); + drop(lock_file); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lock_path_structure() { + let path = account_lock_path("default"); + let path_str = path.to_string_lossy(); + assert!(path_str.contains("tele-tui")); + assert!(path_str.contains("accounts")); + assert!(path_str.contains("default")); + assert!(path_str.ends_with("tele-tui.lock")); + } + + #[test] + fn test_lock_path_per_account() { + let path1 = account_lock_path("work"); + let path2 = account_lock_path("personal"); + assert_ne!(path1, path2); + assert!(path1.to_string_lossy().contains("work")); + assert!(path2.to_string_lossy().contains("personal")); + } + + #[test] + fn test_acquire_and_release() { + let name = "test-lock-acquire-release"; + let lock = acquire_lock(name).expect("first acquire should succeed"); + + // Second acquire should fail (same process, exclusive lock) + let result = acquire_lock(name); + assert!(result.is_err(), "second acquire should fail"); + assert!( + result.unwrap_err().contains("уже используется"), + "error should mention already in use" + ); + + // Release and re-acquire + release_lock(lock); + let lock2 = acquire_lock(name).expect("acquire after release should succeed"); + + // Cleanup + release_lock(lock2); + let _ = fs::remove_file(account_lock_path(name)); + } + + #[test] + fn test_lock_released_on_drop() { + let name = "test-lock-drop"; + { + let _lock = acquire_lock(name).expect("acquire should succeed"); + // _lock dropped here + } + + // After drop, lock should be free + let lock = acquire_lock(name).expect("acquire after drop should succeed"); + release_lock(lock); + let _ = fs::remove_file(account_lock_path(name)); + } +} diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs index 63a79dc..fee1398 100644 --- a/src/accounts/mod.rs +++ b/src/accounts/mod.rs @@ -4,9 +4,11 @@ //! Each account has its own TDLib database directory under //! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`. +pub mod lock; pub mod manager; pub mod profile; +pub use lock::{acquire_lock, release_lock}; #[allow(unused_imports)] pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; #[allow(unused_imports)] diff --git a/src/app/mod.rs b/src/app/mod.rs index 64cde32..6e6d76f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -123,6 +123,9 @@ pub struct App { /// Время последнего рендеринга изображений (для throttling до 15 FPS) #[cfg(feature = "images")] pub last_image_render_time: Option, + // Account lock + /// Advisory file lock to prevent concurrent access to the same account + pub account_lock: Option, // Account switcher /// Account switcher modal state (global overlay) pub account_switcher: Option, @@ -197,6 +200,8 @@ impl App { search_query: String::new(), needs_redraw: true, last_typing_sent: None, + // Account lock + account_lock: None, // Account switcher account_switcher: None, current_account_name: "default".to_string(), diff --git a/src/main.rs b/src/main.rs index 38eb922..a6a9530 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,15 @@ async fn main() -> Result<(), io::Error> { ) .unwrap_or(db_path); + // Acquire per-account lock BEFORE raw mode (so error prints to normal terminal) + let account_lock = accounts::acquire_lock( + account_arg.as_deref().unwrap_or(&accounts_config.default_account), + ) + .unwrap_or_else(|e| { + eprintln!("Error: {}", e); + std::process::exit(1); + }); + // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); @@ -102,6 +111,7 @@ async fn main() -> Result<(), io::Error> { // Create app state with account-specific db_path let mut app = App::new(config, db_path); app.current_account_name = account_name; + app.account_lock = Some(account_lock); // Запускаем инициализацию TDLib в фоне (только для реального клиента) let client_id = app.td_client.client_id(); @@ -110,7 +120,7 @@ async fn main() -> Result<(), io::Error> { let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); tokio::spawn(async move { - let _ = tdlib_rs::functions::set_tdlib_parameters( + if let Err(e) = tdlib_rs::functions::set_tdlib_parameters( false, // use_test_dc db_path_str, // database_directory "".to_string(), // files_directory @@ -127,7 +137,10 @@ async fn main() -> Result<(), io::Error> { env!("CARGO_PKG_VERSION").to_string(), // application_version client_id, ) - .await; + .await + { + tracing::error!("set_tdlib_parameters failed: {:?}", e); + } }); let res = run_app(&mut terminal, &mut app).await; @@ -406,6 +419,21 @@ async fn run_app( // Check pending account switch if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { + // 0. Acquire lock for new account before switching + match accounts::acquire_lock(&account_name) { + Ok(new_lock) => { + // Release old lock + if let Some(old_lock) = app.account_lock.take() { + accounts::release_lock(old_lock); + } + app.account_lock = Some(new_lock); + } + Err(e) => { + app.error_message = Some(e); + continue; + } + } + // 1. Stop playback app.stop_playback(); diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 5ab10b6..0cdd34d 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -665,7 +665,7 @@ impl TdClient { let db_path_str = new_client.db_path.to_string_lossy().to_string(); tokio::spawn(async move { - let _ = functions::set_tdlib_parameters( + if let Err(e) = functions::set_tdlib_parameters( false, db_path_str, "".to_string(), @@ -682,7 +682,10 @@ impl TdClient { env!("CARGO_PKG_VERSION").to_string(), new_client_id, ) - .await; + .await + { + tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e); + } }); // 4. Replace self