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/methods/messages.rs b/src/app/methods/messages.rs index bba3fe3..2a7ba41 100644 --- a/src/app/methods/messages.rs +++ b/src/app/methods/messages.rs @@ -109,17 +109,13 @@ impl MessageMethods for App { } } - if new_index >= total { - self.chat_state = ChatState::Normal; - } else { + if new_index < total { *selected_index = new_index; + self.stop_playback(); } - self.stop_playback(); - } else { - // Дошли до самого нового сообщения - выходим из режима выбора - self.chat_state = ChatState::Normal; - self.stop_playback(); + // Если new_index >= total — остаёмся на текущем } + // Если уже на последнем — ничего не делаем, остаёмся на месте } } 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