Compare commits
2 Commits
dfd4184039
...
f8aab8232a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aab8232a | ||
|
|
3234607bcd |
25
CONTEXT.md
25
CONTEXT.md
@@ -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
11
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
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
|
//! 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};
|
||||||
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};
|
||||||
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
|
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 {
|
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();
|
|
||||||
}
|
}
|
||||||
|
// Если уже на последнем — ничего не делаем, остаёмся на месте
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,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>,
|
||||||
@@ -196,6 +199,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(),
|
||||||
|
|||||||
32
src/main.rs
32
src/main.rs
@@ -82,6 +82,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();
|
||||||
|
|
||||||
@@ -103,6 +112,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();
|
||||||
@@ -111,7 +121,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
|
||||||
@@ -128,7 +138,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;
|
||||||
@@ -409,6 +422,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();
|
||||||
|
|
||||||
|
|||||||
@@ -644,7 +644,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(),
|
||||||
@@ -661,7 +661,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
|
||||||
|
|||||||
Reference in New Issue
Block a user