Compare commits

...

2 Commits

Author SHA1 Message Date
Mikhail Kilin
f8aab8232a fix: keep selection on last/first message instead of deselecting
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
When pressing down on the last message or up on the first message in
chat navigation, stay on the current message instead of exiting
message selection mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:27:43 +03:00
Mikhail Kilin
3234607bcd 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 <noreply@anthropic.com>
2026-02-24 14:23:30 +03:00
9 changed files with 210 additions and 12 deletions

View File

@@ -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<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
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.

11
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

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,8 +4,10 @@
//! 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};
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

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

View File

@@ -121,6 +121,9 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
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 modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>,
@@ -196,6 +199,8 @@ impl<T: TdClientTrait> App<T> {
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(),

View File

@@ -82,6 +82,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();
@@ -103,6 +112,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();
@@ -111,7 +121,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
@@ -128,7 +138,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;
@@ -409,6 +422,21 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// 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();

View File

@@ -644,7 +644,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(),
@@ -661,7 +661,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