Compare commits
2 Commits
dfd4184039
...
f8aab8232a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aab8232a | ||
|
|
3234607bcd |
25
CONTEXT.md
25
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<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
11
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
127
src/accounts/lock.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 — остаёмся на текущем
|
||||
}
|
||||
// Если уже на последнем — ничего не делаем, остаёмся на месте
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
32
src/main.rs
32
src/main.rs
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user