Merge pull request 'feat: per-account lock protection + fix message navigation' (#22) from refactor into main

This commit is contained in:
2026-02-24 12:39:01 +00:00
9 changed files with 210 additions and 12 deletions

View File

@@ -2,6 +2,31 @@
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) ## Статус: Фаза 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<File>` в `App<T>`
- `src/main.rs` — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters
- `src/tdlib/client.rs` — логирование set_tdlib_parameters в `recreate_client()`
---
### Photo Albums (Media Groups) — DONE ### Photo Albums (Media Groups) — DONE
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото. Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.

11
Cargo.lock generated
View File

@@ -1169,6 +1169,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -3383,6 +3393,7 @@ dependencies = [
"crossterm", "crossterm",
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"fs2",
"image", "image",
"insta", "insta",
"notify-rust", "notify-rust",

View File

@@ -37,6 +37,7 @@ thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1" base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies] [dev-dependencies]
insta = "1.34" insta = "1.34"

127
src/accounts/lock.rs Normal file
View File

@@ -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<File, String> {
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));
}
}

View File

@@ -4,9 +4,11 @@
//! Each account has its own TDLib database directory under //! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`. //! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager; pub mod manager;
pub mod profile; pub mod profile;
pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)] #[allow(unused_imports)]

View File

@@ -109,17 +109,13 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
} }
} }
if new_index >= total { if new_index < total {
self.chat_state = ChatState::Normal;
} else {
*selected_index = new_index; *selected_index = new_index;
self.stop_playback();
} }
self.stop_playback(); // Если new_index >= total — остаёмся на текущем
} else {
// Дошли до самого нового сообщения - выходим из режима выбора
self.chat_state = ChatState::Normal;
self.stop_playback();
} }
// Если уже на последнем — ничего не делаем, остаёмся на месте
} }
} }

View File

@@ -123,6 +123,9 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS) /// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>, pub last_image_render_time: Option<std::time::Instant>,
// Account lock
/// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>,
// Account switcher // Account switcher
/// Account switcher modal state (global overlay) /// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>, pub account_switcher: Option<AccountSwitcherState>,
@@ -197,6 +200,8 @@ impl<T: TdClientTrait> App<T> {
search_query: String::new(), search_query: String::new(),
needs_redraw: true, needs_redraw: true,
last_typing_sent: None, last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher // Account switcher
account_switcher: None, account_switcher: None,
current_account_name: "default".to_string(), current_account_name: "default".to_string(),

View File

@@ -81,6 +81,15 @@ async fn main() -> Result<(), io::Error> {
) )
.unwrap_or(db_path); .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 ДО создания клиента // Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs(); disable_tdlib_logs();
@@ -102,6 +111,7 @@ async fn main() -> Result<(), io::Error> {
// Create app state with account-specific db_path // Create app state with account-specific db_path
let mut app = App::new(config, db_path); let mut app = App::new(config, db_path);
app.current_account_name = account_name; app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента) // Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id(); 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(); let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { 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 false, // use_test_dc
db_path_str, // database_directory db_path_str, // database_directory
"".to_string(), // files_directory "".to_string(), // files_directory
@@ -127,7 +137,10 @@ async fn main() -> Result<(), io::Error> {
env!("CARGO_PKG_VERSION").to_string(), // application_version env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id, client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
}); });
let res = run_app(&mut terminal, &mut app).await; let res = run_app(&mut terminal, &mut app).await;
@@ -406,6 +419,21 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Check pending account switch // Check pending account switch
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { 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 // 1. Stop playback
app.stop_playback(); app.stop_playback();

View File

@@ -665,7 +665,7 @@ impl TdClient {
let db_path_str = new_client.db_path.to_string_lossy().to_string(); let db_path_str = new_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = functions::set_tdlib_parameters( if let Err(e) = functions::set_tdlib_parameters(
false, false,
db_path_str, db_path_str,
"".to_string(), "".to_string(),
@@ -682,7 +682,10 @@ impl TdClient {
env!("CARGO_PKG_VERSION").to_string(), env!("CARGO_PKG_VERSION").to_string(),
new_client_id, new_client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
}
}); });
// 4. Replace self // 4. Replace self