Compare commits

..

2 Commits

Author SHA1 Message Date
d3565c9ff9 Merge pull request 'fix(images): eliminate race condition when pressing v on downloading photo' (#26) from refactor into main
Reviewed-on: #26
2026-03-02 23:19:14 +00:00
Mikhail Kilin
90776448ce fix(images): eliminate race condition when pressing v on downloading photo
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Previously, handle_view_image called td_client.download_file() synchronously
while process_pending_chat_init already had a background synchronous=true
download in flight for the same file. TDLib returned is_downloading_completed=false
causing the view to fail on first press.

Fix: replace the blocking download in NotDownloaded/Downloading branches with
a pending_image_open intent flag. The main loop opens the modal automatically
when the background download result arrives via photo_download_rx. If no
background channel exists, a new one is started via direct tdlib_rs call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 02:15:51 +03:00
4 changed files with 94 additions and 35 deletions

View File

@@ -87,6 +87,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
#[cfg(feature = "images")] #[cfg(feature = "images")]
{ {
self.photo_download_rx = None; self.photo_download_rx = None;
self.pending_image_open = None;
} }
// Сбрасываем состояние чата в нормальный режим // Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal; self.chat_state = ChatState::Normal;

View File

@@ -20,6 +20,19 @@ use crate::types::ChatId;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::path::PathBuf; use std::path::PathBuf;
/// Pending intent to open the image modal once a photo finishes downloading.
///
/// Set when the user presses `v` on a photo that is still downloading.
/// The main loop opens the modal automatically when the download completes.
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct PendingImageOpen {
pub file_id: i32,
pub message_id: crate::types::MessageId,
pub photo_width: i32,
pub photo_height: i32,
}
/// State of the account switcher modal overlay. /// State of the account switcher modal overlay.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AccountSwitcherState { pub enum AccountSwitcherState {
@@ -123,6 +136,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>,
/// Pending intent: открыть модалку для этого фото когда загрузится
#[cfg(feature = "images")]
pub pending_image_open: Option<PendingImageOpen>,
// Account lock // Account lock
/// Advisory file lock to prevent concurrent access to the same account /// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>, pub account_lock: Option<std::fs::File>,
@@ -219,6 +235,8 @@ impl<T: TdClientTrait> App<T> {
image_modal: None, image_modal: None,
#[cfg(feature = "images")] #[cfg(feature = "images")]
last_image_render_time: None, last_image_render_time: None,
#[cfg(feature = "images")]
pending_image_open: None,
// Voice playback // Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(), audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(), voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),

View File

@@ -584,45 +584,44 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
}); });
app.needs_redraw = true; app.needs_redraw = true;
} }
PhotoDownloadState::Downloading => { PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
app.status_message = Some("Загрузка фото...".to_string()); // Запоминаем намерение открыть модалку — откроется когда загрузится
} app.pending_image_open = Some(crate::app::PendingImageOpen {
PhotoDownloadState::NotDownloaded => { file_id,
// Скачиваем фото и открываем message_id: msg_id,
photo_width,
photo_height,
});
app.status_message = Some("Загрузка фото...".to_string()); app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true; app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => { // Если нет активной фоновой загрузки — запускаем свою
// Обновляем состояние загрузки в сообщении if app.photo_download_rx.is_none() {
for msg in app.td_client.current_chat_messages_mut() { let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
if let Some(photo) = msg.photo_info_mut() { app.photo_download_rx = Some(rx);
if photo.file_id == file_id { let client_id = app.td_client.client_id();
photo.download_state = PhotoDownloadState::Downloaded(path.clone()); tokio::spawn(async move {
break; let result = tokio::time::timeout(Duration::from_secs(30), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
{
Ok(tdlib_rs::enums::File::File(f))
if f.local.is_downloading_completed
&& !f.local.path.is_empty() =>
{
Ok(f.local.path)
} }
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
} }
} })
// Открываем модалку .await;
app.image_modal = Some(ImageModalState { let result =
message_id: msg_id, result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
photo_path: path, let _ = tx.send((file_id, result));
photo_width, });
photo_height,
});
app.status_message = None;
}
Err(e) => {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = PhotoDownloadState::Error(e.clone());
break;
}
}
}
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
} }
} }
PhotoDownloadState::Error(_) => { PhotoDownloadState::Error(_) => {

View File

@@ -216,6 +216,47 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
} }
} }
} }
// Если это фото ждёт открытия в модалке — открываем
let pending_matches = app
.pending_image_open
.as_ref()
.map(|p| p.file_id == file_id)
.unwrap_or(false);
if pending_matches {
// Ищем путь из обновлённого состояния
let downloaded_path = app
.td_client
.current_chat_messages()
.iter()
.find_map(|m| {
m.photo_info().and_then(|p| {
if p.file_id == file_id {
if let PhotoDownloadState::Downloaded(ref path) =
p.download_state
{
Some(path.clone())
} else {
None
}
} else {
None
}
})
});
if let (Some(path), Some(pending)) =
(downloaded_path, app.pending_image_open.take())
{
use crate::tdlib::ImageModalState;
app.image_modal = Some(ImageModalState {
message_id: pending.message_id,
photo_path: path,
photo_width: pending.photo_width,
photo_height: pending.photo_height,
});
app.status_message = None;
got_photos = true;
}
}
} }
} }
if got_photos { if got_photos {