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>
This commit is contained in:
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,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)]
|
||||
|
||||
@@ -123,6 +123,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>,
|
||||
@@ -197,6 +200,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
@@ -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<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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user