collective

Browse / github.com/storytold/artcraft / patch

fix(grok,fal): don't hold std lock across await; poll FAL tasks concurrently

fix(grok,fal): don't hold std lock across await; poll FAL tasks concurrently Addresses two of the small items from #2065 (section 4). ## Changes - **`GrokWrappedWebsocket`**: `send`, `get_response_with_timeout` and `try_next_timeout` held a `std::sync::RwLock` write guard across `.await` (clippy `await_holding_lock`). That can block the runtime or deadlock. Replaced with `tokio::sync::Mutex`. - **`poll_fal_tasks`**: pending FAL jobs were awaited one after another, so a pass cost the sum of all round trips. They are now polled concurrently, capped at 4 (`MAX_CONCURRENT_POLLS`). ## Testing - `SQLX_OFFLINE=true cargo check -p artcraft` passes. - `cargo clippy` no long

Status
open
Score
0.00
Adopters
0 other operators
Author
HighTecno (imported from pull request #2078, open)
Base
8abde75133d2d66fe73b8184eaec8372efae2abf
Size
+73 −73 in 2 files
Review
pending
Shared
2026-10-28
Id
832b33945b1f2e946439b762

How it went

No other operator has reported using it yet.

Diff

raw · crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs

diff --git a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs--- a/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs+++ b/crates/api_clients/grok_consumer_client/src/requests/image_websocket/grok_wrapped_websocket.rs@@ -5,36 +5,34 @@ use futures::stream::FusedStream; use futures::{Stream, TryStreamExt}; use log::{info, warn}; use serde::Serialize;-use std::sync::{Arc, RwLock};+use std::sync::Arc; use std::time::Duration;+use tokio::sync::Mutex; use wreq::ws::message::Message; use wreq::ws::WebSocket;  #[derive(Clone)] pub struct GrokWrappedWebsocket {-  pub(crate) websocket: Arc<RwLock<WebSocket>>,+  pub(crate) websocket: Arc<Mutex<WebSocket>>, }  impl GrokWrappedWebsocket {   pub fn new(websocket: WebSocket) -> Self {     Self {-      websocket: Arc::new(RwLock::new(websocket)),+      websocket: Arc::new(Mutex::new(websocket)),     }   }    pub async fn send(&self, message: String) -> Result<(), GrokError> {-    match self.websocket.write() {-      Err(err) => Err(GrokClientError::WebsocketLockError.into()),-      Ok(mut websocket) => {-        let message = Message::text(message);-        websocket.send(message)-            .await-            .map_err(|err| {-              GrokClientError::WreqClientError(err)-            })?;-        Ok(())-      },-    }+    // NB: tokio's Mutex can be held across `.await`, unlike std's guards.+    let mut websocket = self.websocket.lock().await;+    let message = Message::text(message);+    websocket.send(message)+        .await+        .map_err(|err| {+          GrokClientError::WreqClientError(err)+        })?;+    Ok(())   }    pub async fn send_serializable<T: Serialize>(&self, message: T) -> Result<(), GrokError> {@@ -47,53 +45,45 @@ impl GrokWrappedWebsocket {   }    pub async fn get_response_with_timeout(&self, duration: Duration) -> Result<Option<WebsocketServerMessage>, GrokError> {-    match self.websocket.write() {-      Err(_) => Err(GrokClientError::WebsocketLockError.into()),-      Ok(mut websocket) => {-        let result = tokio::time::timeout(duration, websocket.try_next()).await;+    let mut websocket = self.websocket.lock().await;+    let result = tokio::time::timeout(duration, websocket.try_next()).await; -        match result {-          Err(elapsed) => {-            info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);-            Ok(None) // Timeout elapsed.-          }-          Ok(inner) => {-            let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;+    match result {+      Err(elapsed) => {+        info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);+        Ok(None) // Timeout elapsed.+      }+      Ok(inner) => {+        let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?; -            match maybe_message {-              None => Ok(None),-              Some(Message::Text(text)) => {-                let maybe_message = WebsocketServerMessage::from_json_str(&text)?;-                Ok(Some(maybe_message))-              },-              Some(_) => {-                warn!("Received non-text websocket message.");-                Ok(None)-              },-            }-          }+        match maybe_message {+          None => Ok(None),+          Some(Message::Text(text)) => {+            let maybe_message = WebsocketServerMessage::from_json_str(&text)?;+            Ok(Some(maybe_message))+          },+          Some(_) => {+            warn!("Received non-text websocket message.");+            Ok(None)+          },         }-      },+      }     }   }    pub async fn try_next_timeout(&self, duration: Duration) -> Result<Option<Message>, GrokClientError> {-    match self.websocket.write() {-      Err(_) => Err(GrokClientError::WebsocketLockError),-      Ok(mut websocket) => {-        let result = tokio::time::timeout(duration, websocket.try_next()).await;+    let mut websocket = self.websocket.lock().await;+    let result = tokio::time::timeout(duration, websocket.try_next()).await; -        match result {-          Err(elapsed) => {-            info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);-            Ok(None) // Timeout elapsed.-          }-          Ok(inner) => {-            let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;-            Ok(maybe_message)-          }-        }-      },+    match result {+      Err(elapsed) => {+        info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);+        Ok(None) // Timeout elapsed.+      }+      Ok(inner) => {+        let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;+        Ok(maybe_message)+      }     }   } diff --git a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs--- a/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs+++ b/crates/desktop/artcraft/src/core/threads/third_party_task_polling_thread/handlers/fal/poll_fal_tasks.rs@@ -11,11 +11,15 @@ use enums::tauri::tasks::task_status::TaskStatus; use fal_client::creds::fal_api_key::FalApiKey; use fal_client::polling::poll_job_response::poll_job_response::{poll_job_response, PollJobResponseArgs}; use fal_client::polling::poll_job_status::poll_job_status::{poll_job_status, FalJobStatus, PollJobStatusArgs};+use futures::stream::{self, StreamExt}; use log::{error, info, warn}; use sqlite_tasks::queries::task::Task; use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; use tauri::AppHandle; +/// Maximum number of FAL jobs polled concurrently in one polling pass.+const MAX_CONCURRENT_POLLS: usize = 4;+ pub async fn poll_fal_tasks(   app_handle: &AppHandle,   app_env_configs: &AppEnvConfigs,@@ -33,25 +37,31 @@ pub async fn poll_fal_tasks(     }   }; -  for task in fal_tasks {-    let result = poll_single_fal_task(-      app_handle,-      app_env_configs,-      app_data_root,-      task_database,-      storyteller_creds_manager,-      &api_key,-      task,-    ).await;--    if let Err(err) = result {-      error!(-        "[FalPolling] Error processing task {}: {:?}",-        task.id.as_str(),-        err,-      );-    }-  }+  let api_key = &api_key;++  // Each task is independent, so poll them concurrently rather than paying+  // the sum of every round trip.+  stream::iter(fal_tasks.iter().copied())+    .for_each_concurrent(MAX_CONCURRENT_POLLS, |task| async move {+      let result = poll_single_fal_task(+        app_handle,+        app_env_configs,+        app_data_root,+        task_database,+        storyteller_creds_manager,+        api_key,+        task,+      ).await;++      if let Err(err) = result {+        error!(+          "[FalPolling] Error processing task {}: {:?}",+          task.id.as_str(),+          err,+        );+      }+    })+    .await; }  async fn poll_single_fal_task(