diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 5ac230f0a..dc6dfcb04 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -123,6 +123,25 @@ pub struct QuitState { /// the app: a second `Cmd+Q` always gets out. A repeated window close does /// not force — it asks again. See [`QuitTrigger`]. prompt_pending: AtomicBool, + /// Held while [`decide`](Self::decide) works out what one quit request + /// does, so two requests on different threads (a pooled `quit_app` and a + /// `Cmd+Q` on the main thread, or two `quit_app`s) are decided one after + /// the other, as they were when every request came from the main thread. + gate: Mutex<()>, +} + +/// What [`request_quit`] does with one request. See [`QuitState::decide`]. +#[derive(Debug)] +enum QuitDecision { + /// An explicit repeat while the alert was unanswered: quit without asking. + Force, + /// A shutdown is already under way: nothing to do. + AlreadyQuitting, + /// Nothing a quit would stop: quit. + Quit, + /// Sessions would stop: raise the alert describing them. The prompt is + /// already marked pending. + Ask(QuitBlockers), } impl QuitState { @@ -144,6 +163,61 @@ impl QuitState { self.prompt_pending.swap(false, Ordering::SeqCst) } + /// Decide what one quit request does: the whole check-then-act, under + /// [`gate`](Self::gate). + /// + /// The steps read and write separate flags (`prompt_pending`, + /// `quit_in_progress`) around a snapshot of the blockers, so they are only + /// right when one request finishes them before the next starts. The main + /// thread gave that for free while every request ran on it. `quit_app` now + /// runs on the blocking pool, where a second request could pass the + /// pending-prompt check before the first had set the flag: both then + /// raised an alert, and neither took the forced-quit path a repeated + /// explicit quit is owed. Under the gate the second request sees what the + /// first decided. + /// + /// `collect` runs under the gate, and it reads the store. So a `Cmd+Q` on + /// the main thread can wait here for a pooled request's read. That is no + /// new kind of wait: `Cmd+Q` makes the same read on the main thread + /// itself. The gate is released before the caller acts on the decision, + /// so it is never held across the alert's hop to the main thread. + /// + /// A poisoned gate is taken over: it guards no data, only the order. + fn decide(&self, trigger: QuitTrigger, collect: impl FnOnce() -> QuitBlockers) -> QuitDecision { + let _gate = self + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + // An explicit quit arriving while the alert is unanswered (a second + // `Cmd+Q`) is the escape hatch from a prompt that never appeared or + // never came back. The system alert isn't app-modal, so that second + // `Cmd+Q` is still dispatchable with the alert on screen. A repeated + // window close deliberately doesn't take this path and falls through + // to the checks below, which ask again. + if trigger.may_force() && self.take_prompt() { + return QuitDecision::Force; + } + + // Already shutting down, and the alert dismissed on click while cleanup + // runs out its budget — so there is nothing on screen saying so, and a + // `Cmd+Q` here means "I already answered", not "ask me again". + if self.is_quitting() { + return QuitDecision::AlreadyQuitting; + } + + let blockers = collect(); + if !should_prompt(&blockers) { + return QuitDecision::Quit; + } + + // A prompt already pending here means a repeated window close: the flag + // is already set, so setting it again is a no-op, and the alert goes + // back up describing whatever is running *now*. + self.set_prompt_pending(); + QuitDecision::Ask(blockers) + } + /// Run `cleanup` at most once. A caller that arrives while it is already /// running **blocks until it has finished**, then returns without re-running /// it. @@ -357,41 +431,21 @@ impl QuitTrigger { } } -/// Handle a quit request from the app menu, `Cmd+Q`, or (off macOS) the last -/// window's close. Cheap enough for the main thread: it snapshots blockers and -/// either hands off to a background quit or raises the alert. +/// Handle a quit request from the app menu, `Cmd+Q`, the store-incompatibility +/// screens' Close button, or (off macOS) the last window's close. Cheap enough +/// for the main thread: it snapshots blockers and either hands off to a +/// background quit or raises the alert. +/// +/// Callable from any thread. [`QuitState::decide`] makes the decision for one +/// request at a time, so concurrent requests behave as if they had arrived in +/// some order on the main thread. pub fn request_quit(app: &AppHandle, trigger: QuitTrigger) { let quit_state = app.state::(); - - // An explicit quit arriving while the alert is unanswered (a second - // `Cmd+Q`) is the escape hatch from a prompt that never appeared or never - // came back. The system alert isn't app-modal, so that second `Cmd+Q` is - // still dispatchable with the alert on screen. A repeated window close - // deliberately doesn't take this path and falls through to the gate below, - // which asks again. - if trigger.may_force() && quit_state.take_prompt() { - spawn_quit(app); - return; - } - - // Already shutting down, and the alert dismissed on click while cleanup runs - // out its budget — so there is nothing on screen saying so, and a `Cmd+Q` - // here means "I already answered", not "ask me again". - if quit_state.is_quitting() { - return; - } - - let blockers = collect_quit_blockers(app); - if !should_prompt(&blockers) { - spawn_quit(app); - return; + match quit_state.decide(trigger, || collect_quit_blockers(app)) { + QuitDecision::Force | QuitDecision::Quit => spawn_quit(app), + QuitDecision::AlreadyQuitting => {} + QuitDecision::Ask(blockers) => ask_before_quitting(app, &blockers), } - - // A prompt already pending here means a repeated window close: the flag is - // already set, so setting it again is a no-op, and the alert goes back up - // describing whatever is running *now*. - quit_state.set_prompt_pending(); - ask_before_quitting(app, &blockers); } /// Raise the confirmation alert and act on the answer. @@ -439,9 +493,23 @@ fn quit_confirmed(result: &MessageDialogResult) -> bool { /// /// Deliberately absent from the web-mode `dispatch` table — a browser client /// must not be able to terminate the desktop host. +/// +/// Runs [`request_quit`] on the blocking pool: with a store open it reads +/// the sessions a quit would stop, and as a synchronous command a wait on +/// the store lock there held the main thread. The menu and `Cmd+Q` paths +/// still call [`request_quit`] on the main thread, so a quit from here can +/// run beside one of those, or beside a second `quit_app`. Atomic flags do +/// not make that safe by themselves: the gate reads one flag, snapshots the +/// blockers and then sets another. [`QuitState::decide`] takes a lock for +/// those steps, so of two racing requests one is decided first and the other +/// sees its pending prompt. The alert hops to the main thread itself, after +/// the lock is released, and the cleanup runs at most once however many +/// quits race. #[tauri::command] -pub fn quit_app(app_handle: AppHandle) { - request_quit(&app_handle, QuitTrigger::Explicit); +pub async fn quit_app(app_handle: AppHandle) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || request_quit(&app_handle, QuitTrigger::Explicit)) + .await + .map_err(|e| format!("quit_app task failed: {e}")) } /// Run the quit sequence off the main thread so the bounded waits never freeze @@ -802,6 +870,7 @@ fn app_store(app: &AppHandle) -> Option> { mod tests { use super::*; use std::path::Path; + use std::sync::atomic::AtomicUsize; use std::sync::Barrier; fn active_session(session_type: Option<&str>, status: SessionStatus) -> ActiveSessionInfo { @@ -945,6 +1014,108 @@ mod tests { assert!(!state.take_prompt(), "prompt stayed armed after answering"); } + /// The gate in the order the main thread used to give it: the first quit + /// asks, an explicit repeat forces, a repeated window close asks again, + /// and nothing is asked once a shutdown is under way. + #[test] + fn the_gate_decides_one_request_after_another() { + let running = || blockers(&["review on fix-login"], 0); + + let state = QuitState::default(); + assert!(matches!( + state.decide(QuitTrigger::Explicit, QuitBlockers::default), + QuitDecision::Quit + )); + assert!(matches!( + state.decide(QuitTrigger::Explicit, running), + QuitDecision::Ask(_) + )); + assert!(matches!( + state.decide(QuitTrigger::Explicit, || panic!( + "a forced quit reads nothing" + )), + QuitDecision::Force + )); + + let state = QuitState::default(); + assert!(matches!( + state.decide(QuitTrigger::WindowClose, running), + QuitDecision::Ask(_) + )); + assert!(matches!( + state.decide(QuitTrigger::WindowClose, running), + QuitDecision::Ask(_) + )); + + let state = QuitState::default(); + state.run_cleanup_once(|| {}); + assert!(matches!( + state.decide(QuitTrigger::WindowClose, || panic!( + "a shutdown reads nothing" + )), + QuitDecision::AlreadyQuitting + )); + } + + /// `quit_app` runs on the blocking pool, so it can race a `Cmd+Q` on the + /// main thread or a second `quit_app`. Whichever is decided first asks; + /// the other must see that prompt and force the quit, as it did when the + /// main thread ran them one after the other. Without the gate both pass + /// the pending-prompt check before either sets the flag, and two alerts + /// go up. + #[test] + fn racing_explicit_quits_raise_one_prompt() { + let state = Arc::new(QuitState::default()); + let start = Arc::new(Barrier::new(2)); + // How many requests have reached the blocker snapshot. + let snapshots = Arc::new(AtomicUsize::new(0)); + + let requests: Vec<_> = (0..2) + .map(|_| { + let state = Arc::clone(&state); + let start = Arc::clone(&start); + let snapshots = Arc::clone(&snapshots); + std::thread::spawn(move || { + start.wait(); + state.decide(QuitTrigger::Explicit, || { + snapshots.fetch_add(1, Ordering::SeqCst); + // Stands in for the store read, and holds this request + // inside its check-then-act until the other one has + // had every chance to get in beside it. + let deadline = Instant::now() + Duration::from_millis(300); + while snapshots.load(Ordering::SeqCst) < 2 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + blockers(&["review on fix-login"], 0) + }) + }) + }) + .collect(); + let decisions: Vec = requests + .into_iter() + .map(|request| request.join().unwrap()) + .collect(); + + let asked = decisions + .iter() + .filter(|decision| matches!(decision, QuitDecision::Ask(_))) + .count(); + let forced = decisions + .iter() + .filter(|decision| matches!(decision, QuitDecision::Force)) + .count(); + assert_eq!( + (asked, forced), + (1, 1), + "one request asks and the other forces, got {decisions:?}" + ); + assert_eq!( + snapshots.load(Ordering::SeqCst), + 1, + "the forced quit took a blocker snapshot beside the first request" + ); + } + /// The `RunEvent::Exit` case: a `terminate:` arriving while a confirmed /// quit's cleanup is still running must hold `applicationWillTerminate:` /// open until that cleanup finishes, not return to an exit that kills the diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index d46553992..4f03a3c1c 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -1440,40 +1440,65 @@ fn truncate_workspace_name(name: &str, max: usize) -> String { } #[tauri::command(rename_all = "camelCase")] -pub fn get_branch( +pub async fn get_branch( store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result, String> { let store = crate::get_store(&store)?; - let branch = store.get_branch(&branch_id).map_err(|e| e.to_string())?; + tauri::async_runtime::spawn_blocking(move || { + let branch = store.get_branch(&branch_id).map_err(|e| e.to_string())?; - match branch { - Some(branch) => { - let workdir = store - .get_workdir_for_branch(&branch.id) - .map_err(|e| e.to_string())?; - Ok(Some(to_branch_with_workdir( - branch, - workdir.map(|w| w.path), - ))) + match branch { + Some(branch) => { + let workdir = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())?; + Ok(Some(to_branch_with_workdir( + branch, + workdir.map(|w| w.path), + ))) + } + None => Ok(None), } - None => Ok(None), - } + }) + .await + .map_err(|e| format!("get_branch task failed: {e}"))? } +/// Runs [`list_branches_for_project_impl`] on the blocking pool. The +/// frontend asks for it once per project at launch and again on every branch +/// change and window focus, so as a synchronous command it parked the main +/// thread, and so the window, behind whatever held the store. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. #[tauri::command] -pub fn list_branches_for_project( +pub async fn list_branches_for_project( store: tauri::State<'_, Mutex>>>, project_id: String, ) -> Result, String> { let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + list_branches_for_project_impl(&store, &project_id) + }) + .await + .map_err(|e| format!("list_branches_for_project task failed: {e}"))? +} + +/// The body of [`list_branches_for_project`]: store reads only, each taking +/// and releasing the store lock on its own. +fn list_branches_for_project_impl( + store: &Store, + project_id: &str, +) -> Result, String> { let _project = store - .get_project(&project_id) + .get_project(project_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("Project not found: {project_id}"))?; let branches = store - .list_branches_for_project(&project_id) + .list_branches_for_project(project_id) .map_err(|e| e.to_string())?; // NOTE: workstation IDs for remote branches are populated lazily by @@ -1482,7 +1507,7 @@ pub fn list_branches_for_project( // each took ~1s, causing multi-second UI freezes on project load. let commit_counts = store - .count_finalized_commits_by_branch_for_project(&project_id) + .count_finalized_commits_by_branch_for_project(project_id) .map_err(|e| e.to_string())?; let mut result = Vec::with_capacity(branches.len()); @@ -1504,7 +1529,7 @@ pub fn list_branches_for_project( /// Returns immediately with `worktree_path = None`. Call `setup_worktree` /// separately to create the git worktree in the background. #[tauri::command(rename_all = "camelCase")] -pub fn create_branch( +pub async fn create_branch( store: tauri::State<'_, Mutex>>>, project_id: String, branch_name: String, @@ -1512,39 +1537,43 @@ pub fn create_branch( project_repo_id: Option, ) -> Result { let store = get_store(&store)?; - crate::project_transfer::ensure_project_not_transferring(&project_id)?; + tauri::async_runtime::spawn_blocking(move || { + crate::project_transfer::ensure_project_not_transferring(&project_id)?; - // Detect default branch if none specified (via GitHub API, no local clone needed) - let target_repo = match project_repo_id { - Some(repo_id) => store - .get_project_repo(&repo_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project repo not found: {repo_id}"))?, - None => store - .get_primary_project_repo(&project_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project '{project_id}' has no repository attached"))?, - }; - let effective_base = match base_branch { - Some(b) => b, - None => git::detect_default_branch_for_repo(&target_repo.github_repo) - .map_err(|e| e.to_string())?, - }; + // Detect default branch if none specified (via GitHub API, no local clone needed) + let target_repo = match project_repo_id { + Some(repo_id) => store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?, + None => store + .get_primary_project_repo(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project '{project_id}' has no repository attached"))?, + }; + let effective_base = match base_branch { + Some(b) => b, + None => git::detect_default_branch_for_repo(&target_repo.github_repo) + .map_err(|e| e.to_string())?, + }; - // Normalise to "origin/" so diffs and worktree creation always - // use the remote-tracking ref rather than a stale local branch. - let effective_base = if effective_base.starts_with("origin/") { - effective_base - } else { - format!("origin/{effective_base}") - }; + // Normalise to "origin/" so diffs and worktree creation always + // use the remote-tracking ref rather than a stale local branch. + let effective_base = if effective_base.starts_with("origin/") { + effective_base + } else { + format!("origin/{effective_base}") + }; - // Create branch record only — no git worktree yet - let branch = store::Branch::new(&project_id, &branch_name, &effective_base) - .with_project_repo(&target_repo.id); - store.create_branch(&branch).map_err(|e| e.to_string())?; + // Create branch record only — no git worktree yet + let branch = store::Branch::new(&project_id, &branch_name, &effective_base) + .with_project_repo(&target_repo.id); + store.create_branch(&branch).map_err(|e| e.to_string())?; - Ok(to_branch_with_workdir(branch, None)) + Ok(to_branch_with_workdir(branch, None)) + }) + .await + .map_err(|e| format!("create_branch task failed: {e}"))? } /// Core worktree creation + DB linkage logic behind [`setup_worktree_sync`]. @@ -3757,6 +3786,30 @@ mod tests { } use crate::test_utils::TempGitRepo; + #[test] + fn listing_a_projects_branches_counts_their_commits() { + let store = Store::in_memory().unwrap(); + let project = store::Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = store::Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + let listed = list_branches_for_project_impl(&store, &project.id).unwrap(); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, branch.id); + assert_eq!(listed[0].commit_count, Some(0)); + } + + #[test] + fn listing_the_branches_of_an_unknown_project_is_an_error() { + let store = Store::in_memory().unwrap(); + + let error = list_branches_for_project_impl(&store, "no-such-project").unwrap_err(); + + assert_eq!(error, "Project not found: no-such-project"); + } + /// A shared workstation outlives a teardown that leaves another branch /// on it, and goes when every branch using it does. #[test] diff --git a/apps/staged/src-tauri/src/control_server/commands.rs b/apps/staged/src-tauri/src/control_server/commands.rs index 1555111fb..3d90d81ca 100644 --- a/apps/staged/src-tauri/src/control_server/commands.rs +++ b/apps/staged/src-tauri/src/control_server/commands.rs @@ -4,14 +4,12 @@ //! and they stay out of `ALLOWED_COMMANDS` so no stagedctl caller can //! approve itself. -use std::sync::{Arc, Mutex}; - -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use super::consent::{self, ControlGrantSummary}; use super::enrol::ControlAuthorizationRequest; use super::ControlGrants; -use crate::store::{now_timestamp, Store}; +use crate::store::now_timestamp; /// The connection requests awaiting an answer, oldest first. The frontend /// takes this snapshot once its event listener is live, so a request that @@ -28,9 +26,10 @@ pub fn list_pending_control_authorizations( .collect() } -/// Answer connection request `request_id`. Approving writes the durable -/// grant and mints this run's bearer; denying tells the waiting CLI -/// `authorization_denied`. Dismissing the dialog denies. +/// Answer connection request `request_id`. Approving records the answer in +/// memory only: the durable grant is written when the waiting CLI collects +/// it (`consent::mint_collected_approval`), off this thread. Denying tells +/// the CLI `authorization_denied`. Dismissing the dialog denies. #[tauri::command] pub fn answer_control_authorization( grants: State<'_, ControlGrants>, @@ -45,26 +44,34 @@ pub fn answer_control_authorization( } /// Every approved client that has not been revoked, newest first. +/// +/// A store read per grant, run on the blocking pool so a wait on the store +/// lock does not hold the main thread. The store and the grants are looked +/// up from the handle inside the closure. #[tauri::command] -pub fn list_control_grants( - store: State<'_, Mutex>>>, - grants: State<'_, ControlGrants>, +pub async fn list_control_grants( + app_handle: AppHandle, ) -> Result, String> { - let store = store - .lock() - .unwrap() - .clone() - .ok_or_else(|| "Database not initialized".to_string())?; - consent::grant_summaries(&store, &grants) + tauri::async_runtime::spawn_blocking(move || { + let store = consent::store_of(&app_handle)?; + let grants = app_handle.state::(); + consent::grant_summaries(&store, &grants) + }) + .await + .map_err(|e| format!("list_control_grants task failed: {e}"))? } /// Revoke grant `grant_id`: its live bearers are dropped at once and its /// next reconnect reads `authorization_revoked`. +/// +/// Stamps the row in the store, so it runs on the blocking pool like +/// [`list_control_grants`]. #[tauri::command] -pub fn revoke_control_grant( - app_handle: AppHandle, - grants: State<'_, ControlGrants>, - grant_id: String, -) -> Result<(), String> { - consent::revoke_grant(&app_handle, &grants, &grant_id).map(|_| ()) +pub async fn revoke_control_grant(app_handle: AppHandle, grant_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let grants = app_handle.state::(); + consent::revoke_grant(&app_handle, &grants, &grant_id).map(|_| ()) + }) + .await + .map_err(|e| format!("revoke_control_grant task failed: {e}"))? } diff --git a/apps/staged/src-tauri/src/control_server/consent.rs b/apps/staged/src-tauri/src/control_server/consent.rs index c27290eb1..94ef6bf8d 100644 --- a/apps/staged/src-tauri/src/control_server/consent.rs +++ b/apps/staged/src-tauri/src/control_server/consent.rs @@ -261,7 +261,7 @@ fn answer_error_message(error: AnswerError) -> String { } /// Revoke grant `grant_id` from the Settings panel: stamp the row (inline; -/// a Tauri command runs off the async runtime already), then retire it. +/// the Tauri command calls this on the blocking pool), then retire it. /// Returns whether the row was live. pub(crate) fn revoke_grant( app: &AppHandle, diff --git a/apps/staged/src-tauri/src/image_commands.rs b/apps/staged/src-tauri/src/image_commands.rs index df0b93cd2..f2b43fb62 100644 --- a/apps/staged/src-tauri/src/image_commands.rs +++ b/apps/staged/src-tauri/src/image_commands.rs @@ -94,34 +94,53 @@ pub(crate) fn create_image_impl( /// Return the filesystem path for an image (the frontend uses convertFileSrc). #[tauri::command(rename_all = "camelCase")] -pub fn get_image_path( +pub async fn get_image_path( store: tauri::State<'_, Mutex>>>, image_id: String, ) -> Result { let store = crate::get_store(&store)?; - let image = store - .get_image(&image_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Image not found: {image_id}"))?; - let path = - crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename)?; - Ok(path.to_string_lossy().to_string()) + tauri::async_runtime::spawn_blocking(move || { + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename)?; + Ok(path.to_string_lossy().to_string()) + }) + .await + .map_err(|e| format!("get_image_path task failed: {e}"))? } /// Delete an image record and its file on disk. +/// +/// Runs [`delete_image_impl`] on the blocking pool. The delete can take the +/// image's session and all its messages with it, so as a synchronous command +/// it held the main thread, and so the window, for that delete. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. #[tauri::command(rename_all = "camelCase")] -pub fn delete_image( +pub async fn delete_image( store: tauri::State<'_, Mutex>>>, image_id: String, ) -> Result<(), String> { let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || delete_image_impl(&store, &image_id)) + .await + .map_err(|e| format!("delete_image task failed: {e}"))? +} + +/// The body of [`delete_image`], shared with the web dispatcher's arm. +pub(crate) fn delete_image_impl(store: &Store, image_id: &str) -> Result<(), String> { let image = store - .get_image(&image_id) + .get_image(image_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("Image not found: {image_id}"))?; // Delete the DB record first (triggers session cleanup). - store.delete_image(&image_id).map_err(|e| e.to_string())?; + store.delete_image(image_id).map_err(|e| e.to_string())?; // Best-effort removal of the file and any cached PDF thumbnail. crate::store::images::remove_image_files(&image.project_id, &image.id, &image.filename); @@ -144,13 +163,18 @@ pub async fn get_attachment_thumbnail( /// List all images for a branch. #[tauri::command(rename_all = "camelCase")] -pub fn list_branch_images( +pub async fn list_branch_images( store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result, String> { - crate::get_store(&store)? - .list_images_for_branch(&branch_id) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .list_images_for_branch(&branch_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_branch_images task failed: {e}"))? } /// Size and density of the raster images a note embeds, keyed by id, for @@ -213,21 +237,26 @@ pub(crate) fn media_dimensions_impl( /// Read an image file and return its data as a base64-encoded data URL. #[tauri::command(rename_all = "camelCase")] -pub fn get_image_data( +pub async fn get_image_data( store: tauri::State<'_, Mutex>>>, image_id: String, ) -> Result { let store = crate::get_store(&store)?; - let image = store - .get_image(&image_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Image not found: {image_id}"))?; - let path = crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) - .map_err(|e| e.to_string())?; - let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - Ok(format!("data:{};base64,{}", image.mime_type, encoded)) + tauri::async_runtime::spawn_blocking(move || { + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .map_err(|e| e.to_string())?; + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(format!("data:{};base64,{}", image.mime_type, encoded)) + }) + .await + .map_err(|e| format!("get_image_data task failed: {e}"))? } /// Create an image from base64-encoded data (for browser file input / clipboard paste). @@ -321,6 +350,30 @@ mod tests { ) } + #[test] + fn delete_image_impl_removes_the_row_and_its_file() { + let f = Fixture::new(); + let image = attach(&f, "shot.png", "image/png", PNG).unwrap(); + let path = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .unwrap(); + assert!(path.exists()); + + delete_image_impl(&f.store, &image.id).unwrap(); + + assert!(f.store.get_image(&image.id).unwrap().is_none()); + assert!(!path.exists()); + } + + #[test] + fn delete_image_impl_reports_a_missing_image() { + let f = Fixture::new(); + + let error = delete_image_impl(&f.store, "no-such-image").unwrap_err(); + + assert_eq!(error, "Image not found: no-such-image"); + } + #[test] fn chat_attachments_are_stored_under_their_sniffed_format() { let f = Fixture::new(); diff --git a/apps/staged/src-tauri/src/image_commands/tool_results.rs b/apps/staged/src-tauri/src/image_commands/tool_results.rs index 2fbf439ba..765885e5c 100644 --- a/apps/staged/src-tauri/src/image_commands/tool_results.rs +++ b/apps/staged/src-tauri/src/image_commands/tool_results.rs @@ -5,8 +5,19 @@ use std::io::Read; use std::path::Path; /// Load a local image referenced by a tool result without persisting an attachment. +/// +/// Runs [`read_image_file_impl`] on the blocking pool: as a synchronous +/// command the read of up to [`MAX_IMAGE_SIZE`] and its base64 encoding held +/// the main thread, and so the window. #[tauri::command(rename_all = "camelCase")] -pub fn read_image_file(file_path: String) -> Result { +pub async fn read_image_file(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || read_image_file_impl(file_path)) + .await + .map_err(|e| format!("read_image_file task failed: {e}"))? +} + +/// The body of [`read_image_file`], shared with the web dispatcher's arm. +pub(crate) fn read_image_file_impl(file_path: String) -> Result { let path = Path::new(&file_path); if !path.is_absolute() { return Err("Image path must be absolute".to_string()); @@ -54,7 +65,7 @@ mod tests { let bytes = include_bytes!("../../icons/32x32.png"); std::fs::write(&path, bytes).unwrap(); assert_eq!( - read_image_file(path.to_string_lossy().into_owned()).unwrap(), + read_image_file_impl(path.to_string_lossy().into_owned()).unwrap(), format!( "data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes) @@ -70,11 +81,11 @@ mod tests { dir.path().join("document.txt"), Path::new("relative.png").to_path_buf(), ] { - assert!(read_image_file(path.to_string_lossy().into_owned()).is_err()); + assert!(read_image_file_impl(path.to_string_lossy().into_owned()).is_err()); } let directory = dir.path().join("directory.png"); std::fs::create_dir(&directory).unwrap(); - assert!(read_image_file(directory.to_string_lossy().into_owned()).is_err()); + assert!(read_image_file_impl(directory.to_string_lossy().into_owned()).is_err()); } #[test] @@ -85,7 +96,7 @@ mod tests { .unwrap() .set_len(MAX_IMAGE_SIZE + 1) .unwrap(); - assert!(read_image_file(path.to_string_lossy().into_owned()) + assert!(read_image_file_impl(path.to_string_lossy().into_owned()) .unwrap_err() .contains("exceeds 10 MB limit")); } diff --git a/apps/staged/src-tauri/src/launch_resume/mod.rs b/apps/staged/src-tauri/src/launch_resume/mod.rs index ffc72dece..6d2d62e4f 100644 --- a/apps/staged/src-tauri/src/launch_resume/mod.rs +++ b/apps/staged/src-tauri/src/launch_resume/mod.rs @@ -344,45 +344,57 @@ pub fn get_interrupted_sessions( /// restarted. A second answer is harmless: the marker is already recorded, /// and `run_launch_resume` ignores rows that are no longer terminal with the /// quit's reason. +/// +/// Runs on the blocking pool: recording the marker saves the preferences +/// file, which as a synchronous command held the main thread. The store +/// handle is looked up inline but only checked where the run needs it, so a +/// dismissal still succeeds without a store. #[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn resume_interrupted_sessions( +pub async fn resume_interrupted_sessions( app_handle: tauri::AppHandle, store: tauri::State<'_, Mutex>>>, registry: tauri::State<'_, Arc>, action_executor: tauri::State<'_, Arc>, action_registry: tauri::State<'_, Arc>, - state: tauri::State<'_, LaunchResumeState>, session_ids: Vec, ) -> Result<(), String> { - if let Some(through) = state.take_handled() { - if let Err(e) = write_handled_through(&app_handle, through) { - log::warn!("[launch_resume] could not record the handled marker: {e}"); + let store = crate::get_store(&store); + let registry = Arc::clone(®istry); + let action_executor = Arc::clone(&action_executor); + let action_registry = Arc::clone(&action_registry); + tauri::async_runtime::spawn_blocking(move || { + let state = app_handle.state::(); + if let Some(through) = state.take_handled() { + if let Err(e) = write_handled_through(&app_handle, through) { + log::warn!("[launch_resume] could not record the handled marker: {e}"); + } } - } - // The deferred pushes the startup pass held for this offer. On a - // dismissal nothing comes back, so they go now; otherwise after the run, - // once the resumed rows are in place for the drain's idle check. - let held_deferred_pushes = state.take_deferred_push_holds(); - if session_ids.is_empty() { - kick_held_deferred_pushes(&app_handle, held_deferred_pushes); - return Ok(()); - } - let deps = run::ResumeDeps { - store: crate::get_store(&store)?, - registry: Arc::clone(®istry), - action_executor: Arc::clone(&action_executor), - action_registry: Arc::clone(&action_registry), - app_handle: app_handle.clone(), - }; - tauri::async_runtime::spawn(async move { - let summary = run::run_launch_resume(deps, session_ids).await; - kick_held_deferred_pushes(&app_handle, held_deferred_pushes); - if let Some(state) = app_handle.try_state::() { - *state.last_run.lock().unwrap() = Some(summary); + // The deferred pushes the startup pass held for this offer. On a + // dismissal nothing comes back, so they go now; otherwise after the run, + // once the resumed rows are in place for the drain's idle check. + let held_deferred_pushes = state.take_deferred_push_holds(); + if session_ids.is_empty() { + kick_held_deferred_pushes(&app_handle, held_deferred_pushes); + return Ok(()); } - }); - Ok(()) + let deps = run::ResumeDeps { + store: store?, + registry, + action_executor, + action_registry, + app_handle: app_handle.clone(), + }; + tauri::async_runtime::spawn(async move { + let summary = run::run_launch_resume(deps, session_ids).await; + kick_held_deferred_pushes(&app_handle, held_deferred_pushes); + if let Some(state) = app_handle.try_state::() { + *state.last_run.lock().unwrap() = Some(summary); + } + }); + Ok(()) + }) + .await + .map_err(|e| format!("resume_interrupted_sessions task failed: {e}"))? } fn kick_held_deferred_pushes( diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d7f00dcbb..efa1475cc 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -448,54 +448,82 @@ fn reset_store( Ok(Some(store)) } -#[allow(clippy::too_many_arguments)] +/// Runs on the blocking pool: deleting the old database files and opening +/// the new store (schema and migrations) held the main thread as a +/// synchronous command. [`reset_store`] holds the reset claim throughout, so +/// two confirmations in flight still serialize. The state that is not an +/// `Arc` is looked up from the handle inside the closure. #[tauri::command] -fn confirm_reset_store( +async fn confirm_reset_store( app_handle: tauri::AppHandle, - db_state: tauri::State<'_, DbState>, - store_slot: tauri::State<'_, Mutex>>>, - store_change_tx: tauri::State<'_, tokio::sync::broadcast::Sender>, pr_scheduler: tauri::State<'_, Arc>, rate_limit_scheduler: tauri::State<'_, Arc>, session_registry: tauri::State<'_, Arc>, action_executor: tauri::State<'_, Arc>, action_registry: tauri::State<'_, Arc>, ) -> Result<(), String> { - if let Some(store) = reset_store(&db_state, &store_slot, &store_change_tx)? { - start_store_services( - store, - Arc::clone(pr_scheduler.inner()), - Arc::clone(rate_limit_scheduler.inner()), - Arc::clone(session_registry.inner()), - Arc::clone(action_executor.inner()), - Arc::clone(action_registry.inner()), - app_handle.clone(), - ); - // Every window owns its prompt state. Tell peers to dismiss it after - // the shared backend store has been replaced successfully. - if let Err(error) = app_handle.emit("store-reset-completed", ()) { - log::warn!("Failed to broadcast store reset completion: {error}"); + let pr_scheduler = Arc::clone(&pr_scheduler); + let rate_limit_scheduler = Arc::clone(&rate_limit_scheduler); + let session_registry = Arc::clone(&session_registry); + let action_executor = Arc::clone(&action_executor); + let action_registry = Arc::clone(&action_registry); + tauri::async_runtime::spawn_blocking(move || { + let db_state = app_handle.state::(); + let store_slot = app_handle.state::>>>(); + let store_change_tx = + app_handle.state::>(); + if let Some(store) = reset_store(&db_state, &store_slot, &store_change_tx)? { + start_store_services( + store, + pr_scheduler, + rate_limit_scheduler, + session_registry, + action_executor, + action_registry, + app_handle.clone(), + ); + // Every window owns its prompt state. Tell peers to dismiss it after + // the shared backend store has been replaced successfully. + if let Err(error) = app_handle.emit("store-reset-completed", ()) { + log::warn!("Failed to broadcast store reset completion: {error}"); + } } - } - Ok(()) + Ok(()) + }) + .await + .map_err(|e| format!("confirm_reset_store task failed: {e}"))? } // ============================================================================= // Project commands // ============================================================================= +/// A store read, run on the blocking pool. The projects store refetches this +/// on every project and branch change, so as a synchronous command it parked +/// the main thread, and so the window, behind whatever held the store. +/// +/// As for [`create_project`], nothing but the store handle lookup may run +/// inline: the same goes for every command in `OFFLOADED_COMMANDS`. #[tauri::command] -fn list_projects( +async fn list_projects( store: tauri::State<'_, Mutex>>>, ) -> Result, String> { - get_store(&store)? - .list_projects() - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || store.list_projects().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("list_projects task failed: {e}"))? } +/// Runs [`create_project_blocking`] on the blocking pool. As a synchronous +/// command it held the main thread, and so the window, through its store +/// writes and through the `gh` and `sq` lookups it can make. +/// +/// Nothing but the store handle lookup may run inline here: an `async fn` +/// command runs on a runtime worker, where a store lock wait or a child +/// process would stall timers and event sockets instead. #[allow(clippy::too_many_arguments)] #[tauri::command(rename_all = "camelCase")] -fn create_project( +async fn create_project( store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, name: String, @@ -508,6 +536,41 @@ fn create_project( head_repo: Option, ) -> Result { let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + create_project_blocking( + store, + app_handle, + name, + github_repo, + location, + subpath, + branch_name, + pr_number, + default_branch, + head_repo, + ) + }) + .await + .map_err(|e| format!("create_project task failed: {e}"))? +} + +/// The body of [`create_project`]. Blocking: it writes to the store, creates +/// the project directory, asks `gh` for the default branch when none was +/// prefetched, and on the first remote create of the process looks `sq` up +/// through a login shell. +#[allow(clippy::too_many_arguments)] +fn create_project_blocking( + store: Arc, + app_handle: tauri::AppHandle, + name: String, + github_repo: Option, + location: Option, + subpath: Option, + branch_name: Option, + pr_number: Option, + default_branch: Option, + head_repo: Option, +) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { return Err("Project name is required".to_string()); @@ -540,6 +603,17 @@ fn create_project( if let Some(sub) = subpath.clone() { project = project.with_subpath(sub); } + + // Resolve the first branch's base before the first write. The frontend + // prefetches the default branch; without it this asks `gh`, which can + // take up to its 60 s timeout. Off the main thread, the refetches each + // write's change event triggers are served while this command is still + // running, so a lookup between the writes would show the project without + // its branch for as long as `gh` took. + let effective_base = github_repo + .as_deref() + .map(|repo| git::resolve_default_branch(default_branch, repo)); + store.create_project(&project).map_err(|e| e.to_string())?; // Create the project-scoped worktree root so project sessions always @@ -561,7 +635,7 @@ fn create_project( .map_err(|e| e.to_string())?; } - if let Some(repo) = github_repo { + if let (Some(repo), Some(effective_base)) = (github_repo, effective_base) { let mut project_repo = store::ProjectRepo::new(&project.id, &repo, &inferred_branch_name, subpath).primary(); project_repo.head_repo = head_repo; @@ -571,10 +645,6 @@ fn create_project( // Create the initial branch record for the first repo so each new // project starts with exactly one branch tracked for that repository. - // Use the frontend-prefetched default branch when available to avoid - // a ~500ms-2s GitHub API round-trip during project creation. - let effective_base = git::resolve_default_branch(default_branch, &repo); - let (branch_id, is_local) = match project.location { store::ProjectLocation::Local => { let mut branch = @@ -671,39 +741,54 @@ fn create_project( Ok(project) } +/// A store read, run on the blocking pool like [`list_projects`]: the +/// frontend asks for it once per project at launch and again on every +/// project change. #[tauri::command(rename_all = "camelCase")] -fn list_project_repos( +async fn list_project_repos( store: tauri::State<'_, Mutex>>>, project_id: String, ) -> Result, String> { let store = get_store(&store)?; - store - .list_project_repos(&project_id) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .list_project_repos(&project_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_project_repos task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn list_recent_repos( +async fn list_recent_repos( store: tauri::State<'_, Mutex>>>, limit: Option, ) -> Result, String> { let store = get_store(&store)?; - let effective_limit = limit.unwrap_or(10); - store - .list_recent_repos(effective_limit) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let effective_limit = limit.unwrap_or(10); + store + .list_recent_repos(effective_limit) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_recent_repos task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn get_suggested_repos( +async fn get_suggested_repos( store: tauri::State<'_, Mutex>>>, project_id: String, limit: Option, ) -> Result, String> { let store = get_store(&store)?; - store - .get_suggested_repos_for_project(&project_id, limit.unwrap_or(5)) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .get_suggested_repos_for_project(&project_id, limit.unwrap_or(5)) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_suggested_repos task failed: {e}"))? } #[allow(clippy::too_many_arguments)] @@ -747,14 +832,18 @@ async fn add_project_repo( } #[tauri::command(rename_all = "camelCase")] -fn clear_project_repo_reason( +async fn clear_project_repo_reason( store: tauri::State<'_, Mutex>>>, project_repo_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - store - .clear_project_repo_reason(&project_repo_id) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .clear_project_repo_reason(&project_repo_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("clear_project_repo_reason task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] @@ -778,49 +867,57 @@ async fn remove_project_repo( } #[tauri::command(rename_all = "camelCase")] -fn set_primary_project_repo( +async fn set_primary_project_repo( store: tauri::State<'_, Mutex>>>, project_id: String, project_repo_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - let repo = store - .get_project_repo(&project_repo_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; - store - .set_primary_project_repo(&project_id, &project_repo_id) - .map_err(|e| e.to_string())?; - let project = store - .get_project(&project_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project not found: {project_id}"))?; - store - .update_project( - &project_id, - &project.name, - Some(&repo.github_repo), - &project.location, - repo.subpath.as_deref(), - ) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let repo = store + .get_project_repo(&project_repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; + store + .set_primary_project_repo(&project_id, &project_repo_id) + .map_err(|e| e.to_string())?; + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + store + .update_project( + &project_id, + &project.name, + Some(&repo.github_repo), + &project.location, + repo.subpath.as_deref(), + ) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_primary_project_repo task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn update_project_repo_branch_name( +async fn update_project_repo_branch_name( store: tauri::State<'_, Mutex>>>, project_id: String, project_repo_id: String, branch_name: String, ) -> Result<(), String> { let store = get_store(&store)?; - let trimmed = branch_name.trim(); - if trimmed.is_empty() { - return Err("Branch name is required".to_string()); - } - store - .update_project_repo_branch_name(&project_id, &project_repo_id, trimmed) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let trimmed = branch_name.trim(); + if trimmed.is_empty() { + return Err("Branch name is required".to_string()); + } + store + .update_project_repo_branch_name(&project_id, &project_repo_id, trimmed) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("update_project_repo_branch_name task failed: {e}"))? } #[tauri::command] @@ -846,103 +943,133 @@ async fn delete_project( // Repo badge commands // ============================================================================= +/// A store read, run on the blocking pool like [`list_projects`]. #[tauri::command] -fn get_all_repo_badges( +async fn get_all_repo_badges( store: tauri::State<'_, Mutex>>>, ) -> Result, String> { - get_store(&store)? - .list_repo_badges() - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store.list_repo_badges().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_all_repo_badges task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn delete_repo_badge( +async fn delete_repo_badge( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, ) -> Result<(), String> { - get_store(&store)? - .delete_repo_badge(&github_repo, &subpath) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .delete_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_repo_badge task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn update_repo_badge( +async fn update_repo_badge( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, short_name: String, hue: f64, ) -> Result { - let short_name = short_name.trim().to_string(); - if short_name.is_empty() || short_name.len() > 6 { - return Err("Short name must be 1-6 characters".into()); - } - if !short_name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.') - { - return Err("Short name must be lowercase alphanumeric (dots allowed)".into()); - } - if short_name.chars().filter(|&c| c == '.').count() > 1 - || short_name.starts_with('.') - || short_name.ends_with('.') - { - return Err("Short name may contain at most one dot, not at start or end".into()); - } - if !(0.0..=360.0).contains(&hue) { - return Err("Hue must be between 0 and 360".into()); - } let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + let short_name = short_name.trim().to_string(); + if short_name.is_empty() || short_name.len() > 6 { + return Err("Short name must be 1-6 characters".into()); + } + if !short_name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.') + { + return Err("Short name must be lowercase alphanumeric (dots allowed)".into()); + } + if short_name.chars().filter(|&c| c == '.').count() > 1 + || short_name.starts_with('.') + || short_name.ends_with('.') + { + return Err("Short name may contain at most one dot, not at start or end".into()); + } + if !(0.0..=360.0).contains(&hue) { + return Err("Hue must be between 0 and 360".into()); + } - // Check if another badge already uses this short name. - if let Some(existing) = store - .get_repo_badge_by_short_name(&short_name) - .map_err(|e| e.to_string())? - { - let is_same_badge = existing.github_repo == github_repo && existing.subpath == subpath; - if !is_same_badge { - let owner = if existing.subpath.is_empty() { - existing.github_repo.clone() - } else { - format!("{} ({})", existing.github_repo, existing.subpath) - }; - return Err(format!( - "Short name '{}' is already used by {}", - short_name, owner - )); + // Check if another badge already uses this short name. + if let Some(existing) = store + .get_repo_badge_by_short_name(&short_name) + .map_err(|e| e.to_string())? + { + let is_same_badge = existing.github_repo == github_repo && existing.subpath == subpath; + if !is_same_badge { + let owner = if existing.subpath.is_empty() { + existing.github_repo.clone() + } else { + format!("{} ({})", existing.github_repo, existing.subpath) + }; + return Err(format!( + "Short name '{}' is already used by {}", + short_name, owner + )); + } } - } - store - .update_repo_badge(&github_repo, &subpath, &short_name, hue) - .map_err(|e| e.to_string())?; - store - .get_repo_badge(&github_repo, &subpath) - .map_err(|e| e.to_string())? - .ok_or_else(|| { - format!( - "Badge not found after update for {}/{}", - github_repo, subpath - ) - }) + store + .update_repo_badge(&github_repo, &subpath, &short_name, hue) + .map_err(|e| e.to_string())?; + store + .get_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!( + "Badge not found after update for {}/{}", + github_repo, subpath + ) + }) + }) + .await + .map_err(|e| format!("update_repo_badge task failed: {e}"))? } +/// Runs [`pin_repo_impl`] on the blocking pool. As a synchronous command it +/// held the main thread, and so the window, through the pin write and the +/// default-branch backfill. Like [`create_project`], nothing but the store +/// handle lookup may run inline here. #[tauri::command(rename_all = "camelCase")] -fn pin_repo( +async fn pin_repo( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, ) -> Result<(), String> { let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || pin_repo_impl(&store, &github_repo, &subpath)) + .await + .map_err(|e| format!("pin_repo task failed: {e}"))? +} + +/// The body of [`pin_repo`], shared with the web arm. Blocking: after the pin +/// write, a badge with no default branch has it backfilled, which asks git or +/// `gh`. +pub(crate) fn pin_repo_impl( + store: &Arc, + github_repo: &str, + subpath: &str, +) -> Result<(), String> { store - .pin_repo(&github_repo, &subpath) + .pin_repo(github_repo, subpath) .map_err(|e| e.to_string())?; // Backfill default_branch if not yet detected - if let Ok(Some(badge)) = store.get_repo_badge(&github_repo, &subpath) { + if let Ok(Some(badge)) = store.get_repo_badge(github_repo, subpath) { if badge.default_branch.is_none() { - if let Err(e) = detect_and_store_default_branch(&store, &github_repo, &subpath) { + if let Err(e) = detect_and_store_default_branch(store, github_repo, subpath) { log::warn!("[pin_repo] failed to backfill default_branch: {e}"); } } @@ -952,24 +1079,34 @@ fn pin_repo( } #[tauri::command(rename_all = "camelCase")] -fn unpin_repo( +async fn unpin_repo( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, ) -> Result<(), String> { - get_store(&store)? - .unpin_repo(&github_repo, &subpath) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .unpin_repo(&github_repo, &subpath) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("unpin_repo task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn reorder_pinned_repos( +async fn reorder_pinned_repos( store: tauri::State<'_, Mutex>>>, ordered_keys: Vec<(String, String)>, ) -> Result<(), String> { - get_store(&store)? - .reorder_pinned_repos(&ordered_keys) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .reorder_pinned_repos(&ordered_keys) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("reorder_pinned_repos task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] @@ -998,15 +1135,20 @@ async fn list_repos_for_home( } #[tauri::command(rename_all = "camelCase")] -fn set_repo_default_branch( +async fn set_repo_default_branch( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, default_branch: String, ) -> Result<(), String> { - get_store(&store)? - .set_default_branch(&github_repo, &subpath, &default_branch) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_default_branch(&github_repo, &subpath, &default_branch) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_repo_default_branch task failed: {e}"))? } /// Detect the default branch for a repo, cache it in repo_badges, and return it. @@ -1050,13 +1192,17 @@ pub(crate) fn detect_and_store_default_branch( } #[tauri::command(rename_all = "camelCase")] -fn detect_default_branch( +async fn detect_default_branch( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: String, ) -> Result { let store = get_store(&store)?; - detect_and_store_default_branch(&store, &github_repo, &subpath) + tauri::async_runtime::spawn_blocking(move || { + detect_and_store_default_branch(&store, &github_repo, &subpath) + }) + .await + .map_err(|e| format!("detect_default_branch task failed: {e}"))? } /// Get the commit timeline for a repo's default branch from its local clone. @@ -1471,31 +1617,35 @@ fn get_or_create_project_action_context( } #[tauri::command(rename_all = "camelCase")] -fn list_project_actions( +async fn list_project_actions( store: tauri::State<'_, Mutex>>>, project_id: String, project_repo_id: Option, ) -> Result, String> { let store = get_store(&store)?; - let context = if let Some(repo_id) = project_repo_id { - let repo = store - .get_project_repo(&repo_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project repo not found: {repo_id}"))?; + tauri::async_runtime::spawn_blocking(move || { + let context = if let Some(repo_id) = project_repo_id { + let repo = store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?; + store + .get_or_create_action_context(&repo.github_repo, repo.subpath.as_deref()) + .map_err(|e| e.to_string())? + } else { + get_or_create_project_action_context(&store, &project_id)? + }; store - .get_or_create_action_context(&repo.github_repo, repo.subpath.as_deref()) - .map_err(|e| e.to_string())? - } else { - get_or_create_project_action_context(&store, &project_id)? - }; - store - .list_repo_actions(&context.id) - .map_err(|e| e.to_string()) + .list_repo_actions(&context.id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_project_actions task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] #[allow(clippy::too_many_arguments)] -fn update_project_action( +async fn update_project_action( store: tauri::State<'_, Mutex>>>, action_id: String, name: String, @@ -1506,63 +1656,79 @@ fn update_project_action( icon: Option, ) -> Result<(), String> { let store = get_store(&store)?; - let action = store - .get_repo_action(&action_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Action not found: {action_id}"))?; - - let updated = store::models::RepoAction { - id: action.id, - context_id: action.context_id, - name, - command, - action_type: builderbot_actions::ActionType::parse(&action_type) - .ok_or_else(|| format!("Invalid action type: {action_type}"))?, - sort_order, - run_detection_mode: action.run_detection_mode, - pinned, - icon, - created_at: action.created_at, - updated_at: store::now_timestamp(), - }; + tauri::async_runtime::spawn_blocking(move || { + let action = store + .get_repo_action(&action_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Action not found: {action_id}"))?; + + let updated = store::models::RepoAction { + id: action.id, + context_id: action.context_id, + name, + command, + action_type: builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?, + sort_order, + run_detection_mode: action.run_detection_mode, + pinned, + icon, + created_at: action.created_at, + updated_at: store::now_timestamp(), + }; - store - .update_repo_action(&updated) - .map_err(|e| e.to_string()) + store + .update_repo_action(&updated) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("update_project_action task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn delete_project_action( +async fn delete_project_action( store: tauri::State<'_, Mutex>>>, action_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - store - .delete_repo_action(&action_id) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .delete_repo_action(&action_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_project_action task failed: {e}"))? } #[tauri::command] -fn list_action_contexts( +async fn list_action_contexts( store: tauri::State<'_, Mutex>>>, ) -> Result, String> { let store = get_store(&store)?; - store.list_action_contexts().map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store.list_action_contexts().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_action_contexts task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn list_repo_actions( +async fn list_repo_actions( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: Option, ) -> Result, String> { let store = get_store(&store)?; - let context = store - .get_or_create_action_context(&github_repo, subpath.as_deref()) - .map_err(|e| e.to_string())?; - store - .list_repo_actions(&context.id) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + store + .list_repo_actions(&context.id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_repo_actions task failed: {e}"))? } /// Every action context's actions in one read-only call — the bulk hydration @@ -1585,7 +1751,7 @@ async fn list_all_repo_actions( #[tauri::command(rename_all = "camelCase")] #[allow(clippy::too_many_arguments)] -fn create_repo_action( +async fn create_repo_action( store: tauri::State<'_, Mutex>>>, github_repo: String, subpath: Option, @@ -1597,83 +1763,95 @@ fn create_repo_action( icon: Option, ) -> Result { let store = get_store(&store)?; - let context = store - .get_or_create_action_context(&github_repo, subpath.as_deref()) - .map_err(|e| e.to_string())?; - let parsed_type = builderbot_actions::ActionType::parse(&action_type) - .ok_or_else(|| format!("Invalid action type: {action_type}"))?; - let action = store::models::RepoAction::new(context.id, name, command, parsed_type, sort_order) - .with_pinned(pinned) - .with_icon(icon); - store - .create_repo_action(&action) - .map_err(|e| e.to_string())?; - Ok(action) + tauri::async_runtime::spawn_blocking(move || { + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + let parsed_type = builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?; + let action = + store::models::RepoAction::new(context.id, name, command, parsed_type, sort_order) + .with_pinned(pinned) + .with_icon(icon); + store + .create_repo_action(&action) + .map_err(|e| e.to_string())?; + Ok(action) + }) + .await + .map_err(|e| format!("create_repo_action task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn delete_all_repo_actions( +async fn delete_all_repo_actions( store: tauri::State<'_, Mutex>>>, context_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - store - .delete_all_repo_actions(&context_id) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .delete_all_repo_actions(&context_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_all_repo_actions task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -fn delete_action_context( +async fn delete_action_context( store: tauri::State<'_, Mutex>>>, context_id: String, ) -> Result<(), String> { let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + // Look up the context before deleting so we can clean up the clone directory. + let context = store + .get_action_context(&context_id) + .map_err(|e| e.to_string())?; - // Look up the context before deleting so we can clean up the clone directory. - let context = store - .get_action_context(&context_id) - .map_err(|e| e.to_string())?; - - store - .delete_action_context(&context_id) - .map_err(|e| e.to_string())?; + store + .delete_action_context(&context_id) + .map_err(|e| e.to_string())?; - // Clean up the git clone directory if no other action contexts reference the same repo. - // This runs on a background thread so the UI isn't blocked by large repo deletions. - if let Some(ctx) = context { - let remaining = store - .count_action_contexts_for_repo(&ctx.github_repo) - .unwrap_or(1); - if remaining == 0 { - if let Some(clone_path) = crate::paths::clone_path_for(&ctx.github_repo) { - std::thread::spawn(move || { - if clone_path.exists() { - // Rename to a temporary path first so that concurrent filesystem - // activity (e.g. Spotlight indexing) doesn't cause "Directory not - // empty" errors during removal. - let trash_path = clone_path.with_extension("deleting"); - let target = if std::fs::rename(&clone_path, &trash_path).is_ok() { - trash_path - } else { - clone_path.clone() - }; - if let Err(e) = std::fs::remove_dir_all(&target) { - log::warn!( - "Failed to remove clone directory {}: {e}", - target.display() - ); + // Clean up the git clone directory if no other action contexts reference the same repo. + // This runs on a background thread so the UI isn't blocked by large repo deletions. + if let Some(ctx) = context { + let remaining = store + .count_action_contexts_for_repo(&ctx.github_repo) + .unwrap_or(1); + if remaining == 0 { + if let Some(clone_path) = crate::paths::clone_path_for(&ctx.github_repo) { + std::thread::spawn(move || { + if clone_path.exists() { + // Rename to a temporary path first so that concurrent filesystem + // activity (e.g. Spotlight indexing) doesn't cause "Directory not + // empty" errors during removal. + let trash_path = clone_path.with_extension("deleting"); + let target = if std::fs::rename(&clone_path, &trash_path).is_ok() { + trash_path + } else { + clone_path.clone() + }; + if let Err(e) = std::fs::remove_dir_all(&target) { + log::warn!( + "Failed to remove clone directory {}: {e}", + target.display() + ); + } } - } - // Try to remove the parent owner directory if it's now empty. - if let Some(parent) = clone_path.parent() { - let _ = std::fs::remove_dir(parent); - } - }); + // Try to remove the parent owner directory if it's now empty. + if let Some(parent) = clone_path.parent() { + let _ = std::fs::remove_dir(parent); + } + }); + } } } - } - Ok(()) + Ok(()) + }) + .await + .map_err(|e| format!("delete_action_context task failed: {e}"))? } // ============================================================================= @@ -2599,8 +2777,8 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - dispatch_menu_event, open_store, read_legacy_preferences, reset_store, DbState, - MenuDispatch, StoreIncompatibility, + dispatch_menu_event, open_store, pin_repo_impl, read_legacy_preferences, reset_store, + DbState, MenuDispatch, StoreIncompatibility, }; use crate::store::{LegacyImportOutcome, LegacyStatusOptions}; use std::sync::{Arc, Barrier, Mutex}; @@ -2827,4 +3005,51 @@ mod tests { ); } } + + fn store_with_badge(default_branch: Option<&str>) -> Arc { + let store = Arc::new(crate::store::Store::in_memory().unwrap()); + store + .create_repo_badge(&crate::store::RepoBadge { + github_repo: "acme/widgets".to_string(), + subpath: String::new(), + short_name: "wid".to_string(), + hue: 120.0, + created_at: crate::store::now_timestamp(), + pinned: false, + pin_sort_order: None, + default_branch: None, + }) + .unwrap(); + if let Some(branch) = default_branch { + store + .set_default_branch("acme/widgets", "", branch) + .unwrap(); + } + store + } + + /// The badge already knows its default branch, so the backfill has + /// nothing to ask git or `gh` for and the test spawns no process. + #[test] + fn pin_repo_impl_pins_a_badge_and_keeps_its_default_branch() { + let store = store_with_badge(Some("trunk")); + + pin_repo_impl(&store, "acme/widgets", "").unwrap(); + + let badge = store.get_repo_badge("acme/widgets", "").unwrap().unwrap(); + assert!(badge.pinned); + assert_eq!(badge.pin_sort_order, Some(0)); + assert_eq!(badge.default_branch.as_deref(), Some("trunk")); + } + + /// The pin write fails first, so no backfill is attempted for a repo + /// that has no badge. + #[test] + fn pin_repo_impl_reports_a_missing_badge() { + let store = store_with_badge(Some("trunk")); + + let error = pin_repo_impl(&store, "acme/unknown", "").unwrap_err(); + + assert!(error.contains("No badge found"), "{error}"); + } } diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 079529e59..71c6bb7b7 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -70,8 +70,24 @@ pub(crate) fn standalone_note_to_timeline_item(note: Note) -> NoteTimelineItem { /// /// `subtype` is `"written"` when the user authored the note in the editor /// dialog; the drag-drop and save-action-output paths leave it unset. +/// +/// Runs [`create_standalone_note`] on the blocking pool. As a synchronous +/// command the write held the main thread, and so the window, for its own +/// time and for any wait on the store lock. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. +/// +/// On the pool two creates in flight land in either order, where the main +/// thread ran them in the order they arrived. The store call is one lock +/// hold, so they cannot pick the same title suffix; the order itself is kept +/// by the frontend, which sends a branch's creates one at a time (the +/// `writeOrder` queue in `commands.ts`). The same holds for the other four +/// writes that left the main thread with this one: [`update_note`], +/// [`set_note_read`], `mark_session_chat_read` and `queue_session_message`. #[tauri::command(rename_all = "camelCase")] -pub fn create_note( +pub async fn create_note( store: tauri::State<'_, Mutex>>>, branch_id: String, title: String, @@ -79,7 +95,11 @@ pub fn create_note( subtype: Option, ) -> Result { let store = crate::get_store(&store)?; - create_standalone_note(&store, &branch_id, &title, &content, subtype) + tauri::async_runtime::spawn_blocking(move || { + create_standalone_note(&store, &branch_id, &title, &content, subtype) + }) + .await + .map_err(|e| format!("create_note task failed: {e}"))? } /// The body of [`create_note`], shared with the web dispatcher's arm. @@ -106,15 +126,24 @@ pub(crate) fn create_standalone_note( /// /// Rejects notes an agent session produced — their content is owned by that /// session and would be overwritten on its next turn. +/// +/// Runs [`update_note_impl`] on the blocking pool, like [`create_note`]; only +/// the store handle lookup runs inline. Two saves of one note in flight +/// would land in either order here, so the frontend sends a note's saves and +/// read marks one at a time (see [`create_note`]). #[tauri::command(rename_all = "camelCase")] -pub fn update_note( +pub async fn update_note( store: tauri::State<'_, Mutex>>>, note_id: String, title: String, content: String, ) -> Result { let store = crate::get_store(&store)?; - update_note_impl(&store, ¬e_id, &title, &content) + tauri::async_runtime::spawn_blocking(move || { + update_note_impl(&store, ¬e_id, &title, &content) + }) + .await + .map_err(|e| format!("update_note task failed: {e}"))? } /// The body of [`update_note`], shared with the web dispatcher's arm. @@ -144,6 +173,10 @@ pub(crate) fn update_note_impl( /// chat attachment list, and deleting the session cascades its messages, all /// on the one store connection. Tauri runs a synchronous command on the main /// thread, where this froze the window for as long as it took. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. #[tauri::command(rename_all = "camelCase")] pub async fn delete_note( store: tauri::State<'_, Mutex>>>, @@ -152,7 +185,6 @@ pub async fn delete_note( delete_session: Option, ) -> Result<(), String> { let store = crate::get_store(&store)?; - tauri::async_runtime::spawn_blocking(move || { delete_note_impl( &store, @@ -162,7 +194,7 @@ pub async fn delete_note( ) }) .await - .map_err(|e| format!("Note delete task failed: {e}"))? + .map_err(|e| format!("delete_note task failed: {e}"))? } /// The body of [`delete_note`], shared with the web dispatcher's arm. Callers @@ -218,28 +250,44 @@ pub(crate) fn delete_note_impl( /// note a review comment produced (for `NoteModal`) without refetching the /// whole branch timeline. #[tauri::command(rename_all = "camelCase")] -pub fn get_branch_note_by_session( +pub async fn get_branch_note_by_session( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - crate::get_store(&store)? - .get_note_by_session(&session_id) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_note_by_session(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_branch_note_by_session task failed: {e}"))? } /// Mark a branch note read or unread, returning the row as it now reads. /// /// The note viewer marks a note read when it opens one; the viewer's toggle /// and the timeline context menu set either state. +/// +/// A store write, run on the blocking pool like [`create_note`]: the viewer +/// sends it every time it shows a note. The context menu's write is not +/// awaited, so "Mark as unread" and the read mark of opening the note next +/// can be in flight together; the frontend sends a note's read marks and +/// saves one at a time to keep them in order (see [`create_note`]). #[tauri::command(rename_all = "camelCase")] -pub fn set_note_read( +pub async fn set_note_read( store: tauri::State<'_, Mutex>>>, note_id: String, read: bool, ) -> Result { - crate::get_store(&store)? - .set_note_read(¬e_id, read) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_note_read(¬e_id, read) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_note_read task failed: {e}"))? } /// Fetch a single note by id (no branch filter), with resolved session status. @@ -248,13 +296,17 @@ pub fn set_note_read( /// child notes that live on other repo branches and are therefore absent from /// any single branch timeline. #[tauri::command(rename_all = "camelCase")] -pub fn get_note( +pub async fn get_note( store: tauri::State<'_, Mutex>>>, note_id: String, ) -> Result, String> { let store = crate::get_store(&store)?; - let note = store.get_note(¬e_id).map_err(|e| e.to_string())?; - Ok(note.map(|n| note_to_timeline_item(&store, n))) + tauri::async_runtime::spawn_blocking(move || { + let note = store.get_note(¬e_id).map_err(|e| e.to_string())?; + Ok(note.map(|n| note_to_timeline_item(&store, n))) + }) + .await + .map_err(|e| format!("get_note task failed: {e}"))? } /// List the child notes aggregated under a given project note, with resolved @@ -262,18 +314,22 @@ pub fn get_note( /// dedicated path the parent project-note view uses to resolve `#note:` /// hashtag references to their titles. #[tauri::command(rename_all = "camelCase")] -pub fn list_child_notes( +pub async fn list_child_notes( store: tauri::State<'_, Mutex>>>, parent_project_note_id: String, ) -> Result, String> { let store = crate::get_store(&store)?; - let notes = store - .list_child_notes(&parent_project_note_id) - .map_err(|e| e.to_string())?; - Ok(notes - .into_iter() - .map(|n| note_to_timeline_item(&store, n)) - .collect()) + tauri::async_runtime::spawn_blocking(move || { + let notes = store + .list_child_notes(&parent_project_note_id) + .map_err(|e| e.to_string())?; + Ok(notes + .into_iter() + .map(|n| note_to_timeline_item(&store, n)) + .collect()) + }) + .await + .map_err(|e| format!("list_child_notes task failed: {e}"))? } // ============================================================================= @@ -281,28 +337,39 @@ pub fn list_child_notes( // ============================================================================= #[tauri::command] -pub fn create_project_note( +pub async fn create_project_note( store: tauri::State<'_, Mutex>>>, project_id: String, title: String, content: String, ) -> Result { let store = crate::get_store(&store)?; - let note = crate::store::ProjectNote::new(&project_id, &title, &content); - store - .create_project_note(¬e) - .map_err(|e| e.to_string())?; - Ok(note) + tauri::async_runtime::spawn_blocking(move || { + let note = crate::store::ProjectNote::new(&project_id, &title, &content); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await + .map_err(|e| format!("create_project_note task failed: {e}"))? } +/// A store read, run on the blocking pool like [`delete_note`]: every +/// mounted branch card and project section refetches it on a notes change. #[tauri::command] -pub fn list_project_notes( +pub async fn list_project_notes( store: tauri::State<'_, Mutex>>>, project_id: String, ) -> Result, String> { - crate::get_store(&store)? - .list_project_notes_with_status(&project_id) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .list_project_notes_with_status(&project_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_project_notes task failed: {e}"))? } /// Fetch a single project note by id (no project filter), with resolved @@ -313,44 +380,60 @@ pub fn list_project_notes( /// that reference into a note body whose reader is scoped to a different /// project. This lets such a reference open instead of silently no-opping. #[tauri::command(rename_all = "camelCase")] -pub fn get_project_note( +pub async fn get_project_note( store: tauri::State<'_, Mutex>>>, project_note_id: String, ) -> Result, String> { - crate::get_store(&store)? - .get_project_note_with_status(&project_note_id) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_project_note_with_status(&project_note_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_project_note task failed: {e}"))? } /// Get a single project note by its linked session ID, with resolved session status. #[tauri::command(rename_all = "camelCase")] -pub fn get_project_note_by_session( +pub async fn get_project_note_by_session( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - crate::get_store(&store)? - .get_project_note_by_session_with_status(&session_id) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_project_note_by_session_with_status(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_project_note_by_session task failed: {e}"))? } /// Mark a project note read or unread, returning the row as it now reads; the /// project-note counterpart of [`set_note_read`]. #[tauri::command(rename_all = "camelCase")] -pub fn set_project_note_read( +pub async fn set_project_note_read( store: tauri::State<'_, Mutex>>>, project_note_id: String, read: bool, ) -> Result { - crate::get_store(&store)? - .set_project_note_read(&project_note_id, read) - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_project_note_read(&project_note_id, read) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_project_note_read task failed: {e}"))? } /// Delete a project note, its child notes, and every session they owned. /// /// Async, with the body on the blocking pool, for the reason given on /// [`delete_note`]; here the media check covers the parent and every child, -/// and there is a session cascade per note that had one. +/// and there is a session cascade per note that had one. Only the handle +/// lookups run inline. #[tauri::command(rename_all = "camelCase")] pub async fn delete_project_note( registry: tauri::State<'_, Arc>, @@ -359,12 +442,11 @@ pub async fn delete_project_note( ) -> Result<(), String> { let store = crate::get_store(&store)?; let registry = Arc::clone(®istry); - tauri::async_runtime::spawn_blocking(move || { delete_project_note_impl(&store, ®istry, ¬e_id) }) .await - .map_err(|e| format!("Project note delete task failed: {e}"))? + .map_err(|e| format!("delete_project_note task failed: {e}"))? } /// The body of [`delete_project_note`], shared with the web dispatcher's arm. @@ -657,6 +739,23 @@ mod tests { } } + /// The delete reports the sessions it orphaned, and the body removes + /// their rows: a session that outlived its note would have nowhere to + /// write its result. + #[test] + fn deleting_a_project_note_deletes_the_session_it_owned() { + let f = Fixture::new(); + let note = crate::store::ProjectNote::new(&f.project.id, "Plan", "body") + .with_session(&f.session.id); + f.store.create_project_note(¬e).unwrap(); + let registry = crate::session_runner::SessionRegistry::new(); + + delete_project_note_impl(&f.store, ®istry, ¬e.id).unwrap(); + + assert!(f.store.get_project_note(¬e.id).unwrap().is_none()); + assert!(f.store.get_session(&f.session.id).unwrap().is_none()); + } + #[test] fn standalone_notes_start_read_only_when_the_user_wrote_them() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/note_transfer.rs b/apps/staged/src-tauri/src/note_transfer.rs index db74935b1..b234e2d36 100644 --- a/apps/staged/src-tauri/src/note_transfer.rs +++ b/apps/staged/src-tauri/src/note_transfer.rs @@ -1152,12 +1152,14 @@ fn copy_file( /// the picker can create a remote destination from the repo the transfer will /// look for. `None` when the branch is bound to none. #[tauri::command(rename_all = "camelCase")] -pub fn get_note_repo( +pub async fn get_note_repo( store: tauri::State<'_, Mutex>>>, note_id: String, ) -> Result, String> { let store = crate::get_store(&store)?; - branch_note_repo(&store, ¬e_id) + tauri::async_runtime::spawn_blocking(move || branch_note_repo(&store, ¬e_id)) + .await + .map_err(|e| format!("get_note_repo task failed: {e}"))? } /// Move a branch note into another project, adding the note's repo + subpath diff --git a/apps/staged/src-tauri/src/project_search.rs b/apps/staged/src-tauri/src/project_search.rs index 70e32c212..d505621ac 100644 --- a/apps/staged/src-tauri/src/project_search.rs +++ b/apps/staged/src-tauri/src/project_search.rs @@ -12,11 +12,21 @@ use std::sync::{Arc, Mutex}; use crate::store::{ProjectSearchItem, Store}; +/// A read over every note, project-note and review title, run on the +/// blocking pool: as a synchronous command it held the main thread, and so +/// the window, for the whole read and for any wait on the store before it. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. #[tauri::command] -pub fn get_project_search_index( +pub async fn get_project_search_index( store: tauri::State<'_, Mutex>>>, ) -> Result, String> { - crate::get_store(&store)? - .list_project_search_items() - .map_err(|e| e.to_string()) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store.list_project_search_items().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_project_search_index task failed: {e}"))? } diff --git a/apps/staged/src-tauri/src/prs/commands.rs b/apps/staged/src-tauri/src/prs/commands.rs index 0edee85a7..1419ca4cb 100644 --- a/apps/staged/src-tauri/src/prs/commands.rs +++ b/apps/staged/src-tauri/src/prs/commands.rs @@ -104,14 +104,19 @@ pub async fn get_pr_url( /// Update the PR number for a branch. #[tauri::command(rename_all = "camelCase")] -pub fn update_branch_pr( +pub async fn update_branch_pr( store: tauri::State<'_, Mutex>>>, branch_id: String, pr_number: Option, ) -> Result<(), String> { - get_store(&store)? - .update_branch_pr_number(&branch_id, pr_number, None) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .update_branch_pr_number(&branch_id, pr_number, None) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("update_branch_pr task failed: {e}"))? } /// Refresh PR status for a single branch. @@ -148,13 +153,17 @@ pub async fn refresh_all_pr_statuses( /// a `pr-status-cleared` event so the frontend can drop the stale indicators /// immediately instead of waiting for the next GitHub refresh. #[tauri::command(rename_all = "camelCase")] -pub fn clear_branch_pr_status( +pub async fn clear_branch_pr_status( store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, branch_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - clear_branch_pr_status_impl(&store, &app_handle, &branch_id) + tauri::async_runtime::spawn_blocking(move || { + clear_branch_pr_status_impl(&store, &app_handle, &branch_id) + }) + .await + .map_err(|e| format!("clear_branch_pr_status task failed: {e}"))? } /// Check if a branch has commits that haven't been pushed to the remote. @@ -221,12 +230,16 @@ pub async fn defer_branch_push( /// Clear a branch's deferred push. #[tauri::command(rename_all = "camelCase")] -pub fn cancel_deferred_push( +pub async fn cancel_deferred_push( store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result<(), String> { let store = get_store(&store)?; - cancel_deferred_push_for_branch(&store, &branch_id) + tauri::async_runtime::spawn_blocking(move || { + cancel_deferred_push_for_branch(&store, &branch_id) + }) + .await + .map_err(|e| format!("cancel_deferred_push task failed: {e}"))? } /// Rebase a branch onto its base branch via a pipeline. diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 20f4937cb..b4152bc83 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -1202,13 +1202,24 @@ pub(crate) fn get_active_sessions_impl( .collect()) } +/// Runs [`get_active_sessions_impl`] on the blocking pool. The session +/// status listener asks at launch, on every return to the window and on a +/// timer, so as a synchronous command it parked the main thread, and so the +/// window, behind whatever held the store. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the handle lookups may +/// run inline: an `async fn` command runs on a runtime worker, where a store +/// lock wait would stall timers and event sockets instead. #[tauri::command] -pub fn get_active_sessions( +pub async fn get_active_sessions( store: tauri::State<'_, Mutex>>>, registry: tauri::State<'_, Arc>, ) -> Result, String> { let store = get_store(&store)?; - get_active_sessions_impl(&store, ®istry) + let registry = Arc::clone(®istry); + tauri::async_runtime::spawn_blocking(move || get_active_sessions_impl(&store, ®istry)) + .await + .map_err(|e| format!("get_active_sessions task failed: {e}"))? } /// The in-flight repo sessions a project session spawned, in start order. @@ -1217,99 +1228,146 @@ pub fn get_active_sessions( /// every queued or running child, each with the title the branch timeline /// would give it. #[tauri::command] -pub fn list_child_sessions( +pub async fn list_child_sessions( store: tauri::State<'_, Mutex>>>, parent_session_id: String, ) -> Result, String> { - get_store(&store)? - .list_active_child_sessions(&parent_session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .list_active_child_sessions(&parent_session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_child_sessions task failed: {e}"))? } +/// A store read, run on the blocking pool like [`get_active_sessions`]: the +/// timeline's live hints and the PR, push and pull buttons poll it. #[tauri::command] -pub fn get_session( +pub async fn get_session( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - get_store(&store)? - .get_session(&session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store.get_session(&session_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session task failed: {e}"))? } /// The visible transcript, or with `limit` its newest rows and with /// `before_id` the rows older than one already loaded — see /// [`store::TranscriptWindow`]. Neither set returns the whole transcript. +/// +/// A store read, run on the blocking pool like [`get_active_sessions`]. #[tauri::command] -pub fn get_session_messages( +pub async fn get_session_messages( store: tauri::State<'_, Mutex>>>, session_id: String, limit: Option, before_id: Option, ) -> Result, String> { - get_store(&store)? - .get_session_messages_window(&session_id, store::TranscriptWindow { limit, before_id }) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_session_messages_window(&session_id, store::TranscriptWindow { limit, before_id }) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session_messages task failed: {e}"))? } +/// A store read, run on the blocking pool like [`get_active_sessions`]: the +/// timeline's live hints poll it for every running session in a visible list. #[tauri::command] -pub fn get_session_messages_since( +pub async fn get_session_messages_since( store: tauri::State<'_, Mutex>>>, session_id: String, since_id: i64, refetch_ids: Vec, ) -> Result, String> { - get_store(&store)? - .get_session_messages_since(&session_id, since_id, &refetch_ids) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_session_messages_since(&session_id, since_id, &refetch_ids) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session_messages_since task failed: {e}"))? } #[tauri::command] -pub fn get_session_acp_metadata_messages( +pub async fn get_session_acp_metadata_messages( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - get_store(&store)? - .get_session_acp_metadata_messages(&session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_session_acp_metadata_messages(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session_acp_metadata_messages task failed: {e}"))? } #[tauri::command] -pub fn get_session_acp_metadata_messages_since( +pub async fn get_session_acp_metadata_messages_since( store: tauri::State<'_, Mutex>>>, session_id: String, since_id: i64, refetch_ids: Vec, ) -> Result, String> { - get_store(&store)? - .get_session_acp_metadata_messages_since(&session_id, since_id, &refetch_ids) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_session_acp_metadata_messages_since(&session_id, since_id, &refetch_ids) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session_acp_metadata_messages_since task failed: {e}"))? } /// One tick of the session pane's poll: status, follow-up queue and both /// transcript increments from a single snapshot (see `store/session_poll.rs`). +/// +/// Runs on the blocking pool like [`get_active_sessions`]: an open running +/// chat sends this twice a second, so as a synchronous command it kept a +/// store read on the main thread whenever a session was running. #[tauri::command(rename_all = "camelCase")] -pub fn poll_session( +pub async fn poll_session( store: tauri::State<'_, Mutex>>>, session_id: String, request: store::SessionPollRequest, ) -> Result { - get_store(&store)? - .poll_session(&session_id, &request) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .poll_session(&session_id, &request) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("poll_session task failed: {e}"))? } /// Resolve a tool-result image reference (`//`, as /// projected into the transcript by the store) to a data URL. The web client /// fetches `/api/tool-images/…` directly; this is the Tauri IPC equivalent. #[tauri::command(rename_all = "camelCase")] -pub fn get_tool_image( +pub async fn get_tool_image( store: tauri::State<'_, Mutex>>>, message_id: i64, field: String, index: usize, ) -> Result { let store = get_store(&store)?; - tool_image_data_url(&store, message_id, &field, index) + tauri::async_runtime::spawn_blocking(move || { + tool_image_data_url(&store, message_id, &field, index) + }) + .await + .map_err(|e| format!("get_tool_image task failed: {e}"))? } pub(crate) fn tool_image_data_url( @@ -1333,12 +1391,16 @@ pub(crate) fn tool_image_data_url( /// The web client fetches `/api/tool-bodies/{message_id}` directly; this is /// the Tauri IPC equivalent. #[tauri::command(rename_all = "camelCase")] -pub fn get_tool_call_body( +pub async fn get_tool_call_body( store: tauri::State<'_, Mutex>>>, message_id: i64, ) -> Result { let store = get_store(&store)?; - tool_call_body(&store, message_id).map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + tool_call_body(&store, message_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_tool_call_body task failed: {e}"))? } pub(crate) fn tool_call_body( @@ -1351,36 +1413,58 @@ pub(crate) fn tool_call_body( } #[tauri::command] -pub fn get_session_acp_initialization( +pub async fn get_session_acp_initialization( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - get_store(&store)? - .get_session_acp_initialization(&session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .get_session_acp_initialization(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("get_session_acp_initialization task failed: {e}"))? } #[tauri::command] -pub fn count_assistant_messages_after( +pub async fn count_assistant_messages_after( store: tauri::State<'_, Mutex>>>, session_id: String, after_timestamp: i64, ) -> Result { - get_store(&store)? - .count_assistant_messages_after(&session_id, after_timestamp) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .count_assistant_messages_after(&session_id, after_timestamp) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("count_assistant_messages_after task failed: {e}"))? } /// Record that the user has read the session's chat, clearing the unread dot /// on its note row. +/// +/// A store write, run on the blocking pool like [`get_active_sessions`]: the +/// note viewer sends it each time a chat opens or closes, so as a +/// synchronous command it parked the main thread behind whatever held the +/// store. The frontend sends a session's marks one at a time (the +/// `writeOrder` queue in `commands.ts`), since on the pool two in flight +/// land in either order. #[tauri::command] -pub fn mark_session_chat_read( +pub async fn mark_session_chat_read( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result<(), String> { - get_store(&store)? - .mark_session_chat_read(&session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .mark_session_chat_read(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("mark_session_chat_read task failed: {e}"))? } // ============================================================================= @@ -2808,8 +2892,16 @@ fn continue_branch_and_type( Ok((lookup(branch_id)?, None)) } +/// Queue a follow-up behind a running session's turn. +/// +/// A store write, run on the blocking pool like [`get_active_sessions`]; +/// only the store handle lookup runs inline. The queue drains in the order +/// its rows were stamped, and that stamp is taken when this body starts. On +/// the pool two messages in flight can start in either order, where the main +/// thread ran them as they arrived, so the frontend sends a session's +/// messages one at a time (the `writeOrder` queue in `commands.ts`). #[tauri::command] -pub fn queue_session_message( +pub async fn queue_session_message( store: tauri::State<'_, Mutex>>>, session_id: String, content: String, @@ -2817,34 +2909,48 @@ pub fn queue_session_message( branch_id: Option, ) -> Result { let store = get_store(&store)?; - store - .add_queued_session_message( - &session_id, - &content, - &image_ids.unwrap_or_default(), - branch_id.as_deref(), - ) - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || { + store + .add_queued_session_message( + &session_id, + &content, + &image_ids.unwrap_or_default(), + branch_id.as_deref(), + ) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("queue_session_message task failed: {e}"))? } #[tauri::command] -pub fn list_queued_session_messages( +pub async fn list_queued_session_messages( store: tauri::State<'_, Mutex>>>, session_id: String, ) -> Result, String> { - get_store(&store)? - .list_queued_session_messages(&session_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .list_queued_session_messages(&session_id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("list_queued_session_messages task failed: {e}"))? } #[tauri::command] -pub fn delete_queued_session_message( +pub async fn delete_queued_session_message( store: tauri::State<'_, Mutex>>>, id: String, ) -> Result { - get_store(&store)? - .delete_queued_session_message(&id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .delete_queued_session_message(&id) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_queued_session_message task failed: {e}"))? } /// Whether a message typed for `session_id` can reach its agent *now* (the @@ -2926,13 +3032,18 @@ pub(crate) fn live_delivery_for_session( } #[tauri::command] -pub fn get_session_live_delivery( +pub async fn get_session_live_delivery( store: tauri::State<'_, Mutex>>>, registry: tauri::State<'_, Arc>, session_id: String, ) -> Result { let store = get_store(&store)?; - live_delivery_for_session(&store, ®istry, &session_id) + let registry = Arc::clone(®istry); + tauri::async_runtime::spawn_blocking(move || { + live_delivery_for_session(&store, ®istry, &session_id) + }) + .await + .map_err(|e| format!("get_session_live_delivery task failed: {e}"))? } /// The `send_session_message_now` reply: how the message reached (or failed @@ -3417,14 +3528,24 @@ pub fn get_session_background_hold( registry.background_hold(&session_id).into() } +/// Runs [`delete_session_impl`] on the blocking pool. The delete cascades to +/// every message of the session in one statement, so as a synchronous command +/// it held the main thread, and so the window, for a long session's delete. +/// Like [`get_active_sessions`], only the handle lookups run inline. #[tauri::command] -pub fn delete_session( +pub async fn delete_session( registry: tauri::State<'_, Arc>, store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, session_id: String, ) -> Result<(), String> { - delete_session_impl(®istry, &get_store(&store)?, &app_handle, &session_id) + let store = get_store(&store)?; + let registry = Arc::clone(®istry); + tauri::async_runtime::spawn_blocking(move || { + delete_session_impl(®istry, &store, &app_handle, &session_id) + }) + .await + .map_err(|e| format!("delete_session task failed: {e}"))? } /// Delete a session row, for both transports. @@ -5887,7 +6008,7 @@ pub async fn start_or_queue_branch_session( /// take it. #[tauri::command(rename_all = "camelCase")] #[allow(clippy::too_many_arguments)] -pub fn queue_branch_session( +pub async fn queue_branch_session( store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, branch_id: String, @@ -5899,17 +6020,21 @@ pub fn queue_branch_session( acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; - queue_branch_session_for_store( - store, - Some(&app_handle), - branch_id, - prompt, - session_type, - provider, - image_ids, - launch_context, - acp_config_selection, - ) + tauri::async_runtime::spawn_blocking(move || { + queue_branch_session_for_store( + store, + Some(&app_handle), + branch_id, + prompt, + session_type, + provider, + image_ids, + launch_context, + acp_config_selection, + ) + }) + .await + .map_err(|e| format!("queue_branch_session task failed: {e}"))? } /// What a queued row's dependency gate lets a start path do with it. diff --git a/apps/staged/src-tauri/src/store/actions.rs b/apps/staged/src-tauri/src/store/actions.rs index 1a79756ce..e9e41929d 100644 --- a/apps/staged/src-tauri/src/store/actions.rs +++ b/apps/staged/src-tauri/src/store/actions.rs @@ -6,17 +6,22 @@ use super::models::{ActionContext, ActionType, RepoAction, RepoContextActions, R use super::{now_timestamp, Store, StoreError}; impl Store { + /// The lookup and the insert share one lock hold. The action lists call + /// this on first use from the blocking pool, several at once when a + /// repo's branch cards mount together; with the lock released in between, + /// two callers could both miss the row and the second insert would fail + /// on the unique (repo, subpath) index. pub fn get_or_create_action_context( &self, github_repo: &str, subpath: Option<&str>, ) -> Result { - if let Some(existing) = self.get_action_context_by_repo_and_subpath(github_repo, subpath)? { + let conn = self.conn.lock().unwrap(); + if let Some(existing) = Self::find_action_context(&conn, github_repo, subpath)? { return Ok(existing); } let context = ActionContext::new(github_repo.to_string(), subpath.map(str::to_string)); - let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO action_contexts (id, github_repo, subpath, has_detected_actions, detecting_actions, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", @@ -72,6 +77,14 @@ impl Store { subpath: Option<&str>, ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); + Self::find_action_context(&conn, github_repo, subpath) + } + + fn find_action_context( + conn: &rusqlite::Connection, + github_repo: &str, + subpath: Option<&str>, + ) -> Result, StoreError> { conn.query_row( "SELECT id, github_repo, subpath, has_detected_actions, detecting_actions, created_at, updated_at FROM action_contexts diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index d819cf6f8..7c56c76f6 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -5010,6 +5010,51 @@ fn test_delete_project_note_reports_running_child_sessions() { // Repo Actions // ============================================================================= +/// The action lists reach this from the blocking pool, several at once for +/// one repo. The lookup and insert are one lock hold, so every caller gets +/// the same row; with the lock released in between, a late caller's insert +/// failed on the unique index. +/// +/// One round seldom lands in that gap, so the callers race for a fresh +/// context many times over, lined up again before each. +#[test] +fn concurrent_get_or_create_action_context_calls_share_one_row() { + const CALLERS: usize = 8; + const ROUNDS: usize = 400; + let store = std::sync::Arc::new(Store::in_memory().unwrap()); + let start = std::sync::Arc::new(std::sync::Barrier::new(CALLERS)); + let callers: Vec<_> = (0..CALLERS) + .map(|_| { + let store = std::sync::Arc::clone(&store); + let start = std::sync::Arc::clone(&start); + std::thread::spawn(move || { + (0..ROUNDS) + .map(|round| { + let subpath = format!("apps/round-{round}"); + start.wait(); + store + .get_or_create_action_context("test-owner/test-repo", Some(&subpath)) + .map(|context| context.id) + .map_err(|e| e.to_string()) + }) + .collect::>() + }) + }) + .collect(); + let ids: Vec>> = callers + .into_iter() + .map(|caller| caller.join().unwrap()) + .collect(); + + for round in 0..ROUNDS { + let first = ids[0][round].as_ref().unwrap(); + for caller in &ids { + assert_eq!(caller[round].as_ref(), Ok(first), "round {round}"); + } + } + assert_eq!(store.list_action_contexts().unwrap().len(), ROUNDS); +} + #[test] fn test_repo_actions() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/tag_commands.rs b/apps/staged/src-tauri/src/tag_commands.rs index 6d74e8717..7ea33cdf3 100644 --- a/apps/staged/src-tauri/src/tag_commands.rs +++ b/apps/staged/src-tauri/src/tag_commands.rs @@ -11,80 +11,111 @@ use crate::get_store; use crate::store::{DeleteTagGroupMode, NewTag, Store, Tag, TagColor, TagGroup, TagState}; #[tauri::command(rename_all = "camelCase")] -pub fn get_tag_state( +pub async fn get_tag_state( store: tauri::State<'_, Mutex>>>, ) -> Result { - get_store(&store)? - .get_tag_state() - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || store.get_tag_state().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("get_tag_state task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn create_tag_group( +pub async fn create_tag_group( store: tauri::State<'_, Mutex>>>, name: String, tags: Vec, ) -> Result { - get_store(&store)? - .create_tag_group(&name, &tags) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .create_tag_group(&name, &tags) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("create_tag_group task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn rename_tag_group( +pub async fn rename_tag_group( store: tauri::State<'_, Mutex>>>, group_id: String, name: String, ) -> Result<(), String> { - get_store(&store)? - .rename_tag_group(&group_id, &name) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .rename_tag_group(&group_id, &name) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("rename_tag_group task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn set_icon_tag_group( +pub async fn set_icon_tag_group( store: tauri::State<'_, Mutex>>>, group_id: Option, ) -> Result<(), String> { - get_store(&store)? - .set_icon_tag_group(group_id.as_deref()) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_icon_tag_group(group_id.as_deref()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_icon_tag_group task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn set_tag_group_sidebar( +pub async fn set_tag_group_sidebar( store: tauri::State<'_, Mutex>>>, group_id: String, shown: bool, ) -> Result<(), String> { - get_store(&store)? - .set_tag_group_sidebar(&group_id, shown) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_tag_group_sidebar(&group_id, shown) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_tag_group_sidebar task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn reorder_tag_groups( +pub async fn reorder_tag_groups( store: tauri::State<'_, Mutex>>>, group_ids: Vec, ) -> Result<(), String> { - get_store(&store)? - .reorder_tag_groups(&group_ids) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .reorder_tag_groups(&group_ids) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("reorder_tag_groups task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn delete_tag_group( +pub async fn delete_tag_group( store: tauri::State<'_, Mutex>>>, group_id: String, mode: DeleteTagGroupMode, ) -> Result<(), String> { - get_store(&store)? - .delete_tag_group(&group_id, mode) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .delete_tag_group(&group_id, mode) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_tag_group task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn create_tag( +pub async fn create_tag( store: tauri::State<'_, Mutex>>>, name: String, group_id: Option, @@ -92,70 +123,98 @@ pub fn create_tag( icon: Option, assign_project_id: Option, ) -> Result { - get_store(&store)? - .create_tag( - &NewTag { name, color, icon }, - group_id.as_deref(), - assign_project_id.as_deref(), - ) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .create_tag( + &NewTag { name, color, icon }, + group_id.as_deref(), + assign_project_id.as_deref(), + ) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("create_tag task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn update_tag( +pub async fn update_tag( store: tauri::State<'_, Mutex>>>, tag_id: String, name: String, color: TagColor, icon: Option, ) -> Result<(), String> { - get_store(&store)? - .update_tag(&tag_id, &name, color, icon.as_deref()) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .update_tag(&tag_id, &name, color, icon.as_deref()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("update_tag task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn move_tag( +pub async fn move_tag( store: tauri::State<'_, Mutex>>>, tag_id: String, group_id: Option, index: usize, ) -> Result<(), String> { - get_store(&store)? - .move_tag(&tag_id, group_id.as_deref(), index) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .move_tag(&tag_id, group_id.as_deref(), index) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("move_tag task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn delete_tag( +pub async fn delete_tag( store: tauri::State<'_, Mutex>>>, tag_id: String, ) -> Result<(), String> { - get_store(&store)? - .delete_tag(&tag_id) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store.delete_tag(&tag_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("delete_tag task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn set_project_group_tag( +pub async fn set_project_group_tag( store: tauri::State<'_, Mutex>>>, project_id: String, group_id: String, tag_id: Option, ) -> Result<(), String> { - get_store(&store)? - .set_project_group_tag(&project_id, &group_id, tag_id.as_deref()) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_project_group_tag(&project_id, &group_id, tag_id.as_deref()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_project_group_tag task failed: {e}"))? } #[tauri::command(rename_all = "camelCase")] -pub fn set_project_tag_assigned( +pub async fn set_project_tag_assigned( store: tauri::State<'_, Mutex>>>, project_id: String, tag_id: String, assigned: bool, ) -> Result<(), String> { - get_store(&store)? - .set_project_tag_assigned(&project_id, &tag_id, assigned) - .map_err(|e| e.to_string()) + let store = get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + store + .set_project_tag_assigned(&project_id, &tag_id, assigned) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("set_project_tag_assigned task failed: {e}"))? } diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index c4c82f827..6e91b7e3c 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -1438,19 +1438,32 @@ pub(crate) fn cleanup_reviews_after_commit( } /// Delete a review and all its comments, optionally deleting its linked session. +/// +/// Runs [`delete_review_impl`] on the blocking pool. Deleting the session +/// with it cascades to every message of that session, so as a synchronous +/// command it held the main thread, and so the window, for that delete. +/// +/// As for every command in `OFFLOADED_COMMANDS`, only the store handle +/// lookup may run inline: an `async fn` command runs on a runtime worker, +/// where a store lock wait would stall timers and event sockets instead. #[tauri::command(rename_all = "camelCase")] -pub fn delete_review( +pub async fn delete_review( store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, review_id: String, delete_session: Option, ) -> Result<(), String> { - delete_review_impl( - &crate::get_store(&store)?, - &app_handle, - &review_id, - delete_session.unwrap_or(false), - ) + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + delete_review_impl( + &store, + &app_handle, + &review_id, + delete_session.unwrap_or(false), + ) + }) + .await + .map_err(|e| format!("delete_review task failed: {e}"))? } /// The body of [`delete_review`], shared with the web dispatcher's arm. Kicks @@ -1490,21 +1503,30 @@ pub(crate) fn delete_review_impl( /// Delete a pending commit (one with no SHA) by its DB id. /// This does NOT touch git — it only removes the DB record and optionally its session. +/// +/// Runs [`delete_pending_commit_impl`] on the blocking pool, for the reason +/// given on [`delete_review`]; only the handle lookups run inline. #[tauri::command(rename_all = "camelCase")] -pub fn delete_pending_commit( +pub async fn delete_pending_commit( registry: tauri::State<'_, Arc>, store: tauri::State<'_, Mutex>>>, app_handle: tauri::AppHandle, commit_id: String, delete_session: Option, ) -> Result<(), String> { - delete_pending_commit_impl( - &crate::get_store(&store)?, - ®istry, - &app_handle, - &commit_id, - delete_session.unwrap_or(false), - ) + let store = crate::get_store(&store)?; + let registry = Arc::clone(®istry); + tauri::async_runtime::spawn_blocking(move || { + delete_pending_commit_impl( + &store, + ®istry, + &app_handle, + &commit_id, + delete_session.unwrap_or(false), + ) + }) + .await + .map_err(|e| format!("delete_pending_commit task failed: {e}"))? } /// The body of [`delete_pending_commit`], shared with the web dispatcher's diff --git a/apps/staged/src-tauri/src/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index e16b915b9..f84ec489b 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -49,21 +49,31 @@ const KNOWN_OPENERS: &[(&str, &str)] = &[ /// Open a URL in the user's default browser. #[tauri::command] -pub fn open_url(app_handle: tauri::AppHandle, url: String) -> Result<(), String> { - use tauri_plugin_opener::OpenerExt; - app_handle - .opener() - .open_url(&url, None::<&str>) - .map_err(|e| format!("Failed to open URL: {e}")) +pub async fn open_url(app_handle: tauri::AppHandle, url: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + use tauri_plugin_opener::OpenerExt; + app_handle + .opener() + .open_url(&url, None::<&str>) + .map_err(|e| format!("Failed to open URL: {e}")) + }) + .await + .map_err(|e| format!("open_url task failed: {e}"))? } /// Check whether the `sq` CLI is available on this system. /// /// The frontend uses this to decide whether to show the Remote branch option /// in the new-branch modal. +/// +/// The first call per process looks `sq` up through a login shell, with no +/// timeout. It runs on the blocking pool: as a synchronous command it held +/// the main thread, and so the window, for that whole lookup at every launch. #[tauri::command] -pub fn is_sq_available() -> bool { - blox::is_sq_available() +pub async fn is_sq_available() -> Result { + tauri::async_runtime::spawn_blocking(blox::is_sq_available) + .await + .map_err(|e| format!("Failed to check sq availability: {e}")) } /// Read a text file from an absolute path. @@ -72,27 +82,38 @@ pub fn is_sq_available() -> bool { /// Tauri's native drag-and-drop events (which give file paths, not /// File objects like browser drag events). #[tauri::command(rename_all = "camelCase")] -pub fn read_text_file(file_path: String) -> Result { - let path = Path::new(&file_path); - if !path.exists() { - return Err(format!("File does not exist: {file_path}")); - } - if !path.is_file() { - return Err(format!("Not a file: {file_path}")); - } - let metadata = path - .metadata() - .map_err(|e| format!("Failed to read file metadata: {e}"))?; - if metadata.len() > 307_200 { - return Err("File too large (>300 KB)".to_string()); - } - std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}")) +pub async fn read_text_file(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let path = Path::new(&file_path); + if !path.exists() { + return Err(format!("File does not exist: {file_path}")); + } + if !path.is_file() { + return Err(format!("Not a file: {file_path}")); + } + let metadata = path + .metadata() + .map_err(|e| format!("Failed to read file metadata: {e}"))?; + if metadata.len() > 307_200 { + return Err("File too large (>300 KB)".to_string()); + } + std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}")) + }) + .await + .map_err(|e| format!("read_text_file task failed: {e}"))? } /// Resolve equivalent path roots for display-only relative path formatting. +/// +/// Runs [`resolve_path_aliases_impl`] on the blocking pool. The live session +/// hints ask every 750 ms per running session, and each path is a +/// `canonicalize` on the filesystem, which as a synchronous command ran on +/// the main thread. #[tauri::command] -pub fn resolve_path_aliases(paths: Vec) -> Vec { - resolve_path_aliases_impl(paths) +pub async fn resolve_path_aliases(paths: Vec) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || resolve_path_aliases_impl(paths)) + .await + .map_err(|e| format!("resolve_path_aliases task failed: {e}")) } pub(crate) fn resolve_path_aliases_impl(paths: Vec) -> Vec { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index c988ed5aa..c745d5bbc 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1110,12 +1110,16 @@ pub(crate) async fn dispatch( // ===================================================================== "list_projects" => { let store = get_store(store_mutex)?; - let mut projects = store.list_projects().map_err(|e| e.to_string())?; // A project-scoped control credential sees only its own project. - if let Some(project_id) = control_scope::project_filter(&caller) { - projects.retain(|project| project.id == project_id); - } - Ok(serde_json::to_value(projects).unwrap()) + let only_project = control_scope::project_filter(&caller).map(str::to_owned); + blocking_store_json(move || { + let mut projects = store.list_projects()?; + if let Some(project_id) = only_project { + projects.retain(|project| project.id == project_id); + } + Ok(projects) + }) + .await } "create_project" => { let store = get_store(store_mutex)?; @@ -1266,27 +1270,30 @@ pub(crate) async fn dispatch( "list_project_repos" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; - let repos = store - .list_project_repos(&project_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(repos).unwrap()) + blocking_store_json(move || store.list_project_repos(&project_id)).await } "list_recent_repos" => { let store = get_store(store_mutex)?; let limit: Option = opt_arg(&args, "limit")?; - let repos = store - .list_recent_repos(limit.unwrap_or(10)) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(repos).unwrap()) + blocking_json(move || -> Result<_, String> { + let repos = store + .list_recent_repos(limit.unwrap_or(10)) + .map_err(|e| e.to_string())?; + Ok(repos) + }) + .await } "get_suggested_repos" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let limit: Option = opt_arg(&args, "limit")?; - let repos = store - .get_suggested_repos_for_project(&project_id, limit.unwrap_or(5)) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(repos).unwrap()) + blocking_json(move || -> Result<_, String> { + let repos = store + .get_suggested_repos_for_project(&project_id, limit.unwrap_or(5)) + .map_err(|e| e.to_string())?; + Ok(repos) + }) + .await } "add_project_repo" => { let store = get_store(store_mutex)?; @@ -1320,22 +1327,28 @@ pub(crate) async fn dispatch( let project_id: String = arg(&args, "projectId")?; let project_repo_id: String = arg(&args, "projectRepoId")?; let branch_name: String = arg(&args, "branchName")?; - let trimmed = branch_name.trim(); - if trimmed.is_empty() { - return Err("Branch name is required".to_string()); - } - store - .update_project_repo_branch_name(&project_id, &project_repo_id, trimmed) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + let trimmed = branch_name.trim(); + if trimmed.is_empty() { + return Err("Branch name is required".to_string()); + } + store + .update_project_repo_branch_name(&project_id, &project_repo_id, trimmed) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "clear_project_repo_reason" => { let store = get_store(store_mutex)?; let project_repo_id: String = arg(&args, "projectRepoId")?; - store - .clear_project_repo_reason(&project_repo_id) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .clear_project_repo_reason(&project_repo_id) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "remove_project_repo" => { let project_id: String = arg(&args, "projectId")?; @@ -1359,27 +1372,30 @@ pub(crate) async fn dispatch( let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let project_repo_id: String = arg(&args, "projectRepoId")?; - let repo = store - .get_project_repo(&project_repo_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; - store - .set_primary_project_repo(&project_id, &project_repo_id) - .map_err(|e| e.to_string())?; - let project = store - .get_project(&project_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project not found: {project_id}"))?; - store - .update_project( - &project_id, - &project.name, - Some(&repo.github_repo), - &project.location, - repo.subpath.as_deref(), - ) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + let repo = store + .get_project_repo(&project_repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {project_repo_id}"))?; + store + .set_primary_project_repo(&project_id, &project_repo_id) + .map_err(|e| e.to_string())?; + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + store + .update_project( + &project_id, + &project.name, + Some(&repo.github_repo), + &project.location, + repo.subpath.as_deref(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "delete_project" => { let id: String = arg(&args, "id")?; @@ -1399,61 +1415,83 @@ pub(crate) async fn dispatch( // ===================================================================== "get_tag_state" => { let store = get_store(store_mutex)?; - let state = store.get_tag_state().map_err(|e| e.to_string())?; - let state = confine_tag_state(state, &caller); - Ok(serde_json::to_value(state).unwrap()) + let caller = caller.clone(); + blocking_json(move || -> Result<_, String> { + let state = store.get_tag_state().map_err(|e| e.to_string())?; + let state = confine_tag_state(state, &caller); + Ok(state) + }) + .await } "create_tag_group" => { let store = get_store(store_mutex)?; let name: String = arg(&args, "name")?; let tags: Vec = arg(&args, "tags")?; - let group = store - .create_tag_group(&name, &tags) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(group).unwrap()) + blocking_json(move || -> Result<_, String> { + let group = store + .create_tag_group(&name, &tags) + .map_err(|e| e.to_string())?; + Ok(group) + }) + .await } "rename_tag_group" => { let store = get_store(store_mutex)?; let group_id: String = arg(&args, "groupId")?; let name: String = arg(&args, "name")?; - store - .rename_tag_group(&group_id, &name) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .rename_tag_group(&group_id, &name) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "set_icon_tag_group" => { let store = get_store(store_mutex)?; let group_id: Option = opt_arg(&args, "groupId")?; - store - .set_icon_tag_group(group_id.as_deref()) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .set_icon_tag_group(group_id.as_deref()) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "set_tag_group_sidebar" => { let store = get_store(store_mutex)?; let group_id: String = arg(&args, "groupId")?; let shown: bool = arg(&args, "shown")?; - store - .set_tag_group_sidebar(&group_id, shown) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .set_tag_group_sidebar(&group_id, shown) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "reorder_tag_groups" => { let store = get_store(store_mutex)?; let group_ids: Vec = arg(&args, "groupIds")?; - store - .reorder_tag_groups(&group_ids) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .reorder_tag_groups(&group_ids) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "delete_tag_group" => { let store = get_store(store_mutex)?; let group_id: String = arg(&args, "groupId")?; let mode: store::DeleteTagGroupMode = arg(&args, "mode")?; - store - .delete_tag_group(&group_id, mode) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .delete_tag_group(&group_id, mode) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "create_tag" => { let store = get_store(store_mutex)?; @@ -1464,10 +1502,13 @@ pub(crate) async fn dispatch( }; let group_id: Option = opt_arg(&args, "groupId")?; let assign_project_id: Option = opt_arg(&args, "assignProjectId")?; - let tag = store - .create_tag(&new, group_id.as_deref(), assign_project_id.as_deref()) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(tag).unwrap()) + blocking_json(move || -> Result<_, String> { + let tag = store + .create_tag(&new, group_id.as_deref(), assign_project_id.as_deref()) + .map_err(|e| e.to_string())?; + Ok(tag) + }) + .await } "update_tag" => { let store = get_store(store_mutex)?; @@ -1475,46 +1516,61 @@ pub(crate) async fn dispatch( let name: String = arg(&args, "name")?; let color: store::TagColor = arg(&args, "color")?; let icon: Option = opt_arg(&args, "icon")?; - store - .update_tag(&tag_id, &name, color, icon.as_deref()) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .update_tag(&tag_id, &name, color, icon.as_deref()) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "move_tag" => { let store = get_store(store_mutex)?; let tag_id: String = arg(&args, "tagId")?; let group_id: Option = opt_arg(&args, "groupId")?; let index: usize = arg(&args, "index")?; - store - .move_tag(&tag_id, group_id.as_deref(), index) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .move_tag(&tag_id, group_id.as_deref(), index) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "delete_tag" => { let store = get_store(store_mutex)?; let tag_id: String = arg(&args, "tagId")?; - store.delete_tag(&tag_id).map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store.delete_tag(&tag_id).map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "set_project_group_tag" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let group_id: String = arg(&args, "groupId")?; let tag_id: Option = opt_arg(&args, "tagId")?; - store - .set_project_group_tag(&project_id, &group_id, tag_id.as_deref()) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .set_project_group_tag(&project_id, &group_id, tag_id.as_deref()) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "set_project_tag_assigned" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let tag_id: String = arg(&args, "tagId")?; let assigned: bool = arg(&args, "assigned")?; - store - .set_project_tag_assigned(&project_id, &tag_id, assigned) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .set_project_tag_assigned(&project_id, &tag_id, assigned) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } // ===================================================================== @@ -1522,8 +1578,7 @@ pub(crate) async fn dispatch( // ===================================================================== "get_all_repo_badges" => { let store = get_store(store_mutex)?; - let badges = store.list_repo_badges().map_err(|e| e.to_string())?; - Ok(serde_json::to_value(badges).unwrap()) + blocking_store_json(move || store.list_repo_badges()).await } "ensure_repo_badges" => { // Simplified version — just returns existing badges without AI generation @@ -1573,23 +1628,29 @@ pub(crate) async fn dispatch( let subpath: String = arg(&args, "subpath")?; let short_name: String = arg(&args, "shortName")?; let hue: f64 = arg(&args, "hue")?; - store - .update_repo_badge(&github_repo, &subpath, &short_name, hue) - .map_err(|e| e.to_string())?; - let badge = store - .get_repo_badge(&github_repo, &subpath) - .map_err(|e| e.to_string())? - .ok_or("Badge not found after update")?; - Ok(serde_json::to_value(badge).unwrap()) + blocking_json(move || -> Result<_, String> { + store + .update_repo_badge(&github_repo, &subpath, &short_name, hue) + .map_err(|e| e.to_string())?; + let badge = store + .get_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string())? + .ok_or("Badge not found after update")?; + Ok(badge) + }) + .await } "delete_repo_badge" => { let store = get_store(store_mutex)?; let github_repo: String = arg(&args, "githubRepo")?; let subpath: String = arg(&args, "subpath")?; - store - .delete_repo_badge(&github_repo, &subpath) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .delete_repo_badge(&github_repo, &subpath) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } // ===================================================================== @@ -1601,40 +1662,30 @@ pub(crate) async fn dispatch( let subpath: String = arg(&args, "subpath")?; // The backfill may ask `gh` or git for the default branch, so the // whole arm runs on the blocking pool. - blocking_json(move || { - store - .pin_repo(&github_repo, &subpath) - .map_err(|e| e.to_string())?; - // Backfill default_branch if not yet detected - if let Ok(Some(badge)) = store.get_repo_badge(&github_repo, &subpath) { - if badge.default_branch.is_none() { - if let Err(e) = - crate::detect_and_store_default_branch(&store, &github_repo, &subpath) - { - log::warn!("[pin_repo] failed to backfill default_branch: {e}"); - } - } - } - Ok::<_, String>(()) - }) - .await + blocking_json(move || crate::pin_repo_impl(&store, &github_repo, &subpath)).await } "unpin_repo" => { let store = get_store(store_mutex)?; let github_repo: String = arg(&args, "githubRepo")?; let subpath: String = arg(&args, "subpath")?; - store - .unpin_repo(&github_repo, &subpath) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .unpin_repo(&github_repo, &subpath) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "reorder_pinned_repos" => { let store = get_store(store_mutex)?; let ordered_keys: Vec<(String, String)> = arg(&args, "orderedKeys")?; - store - .reorder_pinned_repos(&ordered_keys) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .reorder_pinned_repos(&ordered_keys) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "list_repos_for_home" => { let store = get_store(store_mutex)?; @@ -1658,10 +1709,13 @@ pub(crate) async fn dispatch( let github_repo: String = arg(&args, "githubRepo")?; let subpath: String = arg(&args, "subpath")?; let default_branch: String = arg(&args, "defaultBranch")?; - store - .set_default_branch(&github_repo, &subpath, &default_branch) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .set_default_branch(&github_repo, &subpath, &default_branch) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "detect_default_branch" => { let store = get_store(store_mutex)?; @@ -1919,35 +1973,39 @@ pub(crate) async fn dispatch( "get_branch" => { let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; - let branch = store.get_branch(&branch_id).map_err(|e| e.to_string())?; - let enriched = match branch { - Some(branch) => { - let workdir = store - .get_workdir_for_branch(&branch.id) - .map_err(|e| e.to_string())?; - Some(crate::branches::to_branch_with_workdir_public( - branch, - workdir.map(|w| w.path), - )) - } - None => None, - }; - Ok(serde_json::to_value(enriched).unwrap()) + blocking_json(move || -> Result<_, String> { + let branch = store.get_branch(&branch_id).map_err(|e| e.to_string())?; + let enriched = match branch { + Some(branch) => { + let workdir = store + .get_workdir_for_branch(&branch.id) + .map_err(|e| e.to_string())?; + Some(crate::branches::to_branch_with_workdir_public( + branch, + workdir.map(|w| w.path), + )) + } + None => None, + }; + Ok(enriched) + }) + .await } "list_branches_for_project" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; - let branches = store - .list_branches_for_project(&project_id) - .map_err(|e| e.to_string())?; - let enriched: Vec = branches - .into_iter() - .map(|b| { - let workdir = store.get_workdir_for_branch(&b.id).ok().flatten(); - crate::branches::to_branch_with_workdir_public(b, workdir.map(|w| w.path)) - }) - .collect(); - Ok(serde_json::to_value(enriched).unwrap()) + blocking_store_json(move || { + let branches = store.list_branches_for_project(&project_id)?; + let enriched: Vec = branches + .into_iter() + .map(|b| { + let workdir = store.get_workdir_for_branch(&b.id).ok().flatten(); + crate::branches::to_branch_with_workdir_public(b, workdir.map(|w| w.path)) + }) + .collect(); + Ok(enriched) + }) + .await } "create_branch" => { let store = get_store(store_mutex)?; @@ -1956,20 +2014,23 @@ pub(crate) async fn dispatch( let base_branch: String = arg(&args, "baseBranch")?; let pr_number: Option = opt_arg(&args, "prNumber")?; let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; - crate::project_transfer::ensure_project_not_transferring(&project_id)?; + blocking_json(move || -> Result<_, String> { + crate::project_transfer::ensure_project_not_transferring(&project_id)?; - let mut branch = crate::store::Branch::new(&project_id, &branch_name, &base_branch); - if let Some(pr) = pr_number { - branch = branch.with_pr(pr); - } - if let Some(ref repo_id) = project_repo_id { - branch = branch.with_project_repo(repo_id); - } - store.create_branch(&branch).map_err(|e| e.to_string())?; - let workdir = store.get_workdir_for_branch(&branch.id).ok().flatten(); - let enriched = - crate::branches::to_branch_with_workdir_public(branch, workdir.map(|w| w.path)); - Ok(serde_json::to_value(enriched).unwrap()) + let mut branch = crate::store::Branch::new(&project_id, &branch_name, &base_branch); + if let Some(pr) = pr_number { + branch = branch.with_pr(pr); + } + if let Some(ref repo_id) = project_repo_id { + branch = branch.with_project_repo(repo_id); + } + store.create_branch(&branch).map_err(|e| e.to_string())?; + let workdir = store.get_workdir_for_branch(&branch.id).ok().flatten(); + let enriched = + crate::branches::to_branch_with_workdir_public(branch, workdir.map(|w| w.path)); + Ok(enriched) + }) + .await } "delete_branch" => { let id: String = arg(&args, "branchId")?; @@ -2653,30 +2714,33 @@ pub(crate) async fn dispatch( let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let project_repo_id: Option = opt_arg(&args, "projectRepoId")?; - let context = if let Some(repo_id) = project_repo_id { - let repo = store - .get_project_repo(&repo_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project repo not found: {repo_id}"))?; - store - .get_or_create_action_context(&repo.github_repo, repo.subpath.as_deref()) - .map_err(|e| e.to_string())? - } else { - let project = store - .get_project(&project_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project not found: {project_id}"))?; - let repo = project - .primary_repo() - .ok_or("Project has no repository attached")?; - store - .get_or_create_action_context(repo, project.subpath.as_deref()) - .map_err(|e| e.to_string())? - }; - let actions = store - .list_repo_actions(&context.id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(actions).unwrap()) + blocking_json(move || -> Result<_, String> { + let context = if let Some(repo_id) = project_repo_id { + let repo = store + .get_project_repo(&repo_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project repo not found: {repo_id}"))?; + store + .get_or_create_action_context(&repo.github_repo, repo.subpath.as_deref()) + .map_err(|e| e.to_string())? + } else { + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let repo = project + .primary_repo() + .ok_or("Project has no repository attached")?; + store + .get_or_create_action_context(repo, project.subpath.as_deref()) + .map_err(|e| e.to_string())? + }; + let actions = store + .list_repo_actions(&context.id) + .map_err(|e| e.to_string())?; + Ok(actions) + }) + .await } "update_project_action" => { let store = get_store(store_mutex)?; @@ -2687,53 +2751,65 @@ pub(crate) async fn dispatch( let sort_order: i32 = arg(&args, "sortOrder")?; let pinned: bool = arg(&args, "pinned")?; let icon: Option = opt_arg(&args, "icon")?; - let action = store - .get_repo_action(&action_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Action not found: {action_id}"))?; - let updated = crate::store::models::RepoAction { - id: action.id, - context_id: action.context_id, - name, - command: command_str, - action_type: builderbot_actions::ActionType::parse(&action_type) - .ok_or_else(|| format!("Invalid action type: {action_type}"))?, - sort_order, - run_detection_mode: action.run_detection_mode, - pinned, - icon, - created_at: action.created_at, - updated_at: crate::store::now_timestamp(), - }; - store - .update_repo_action(&updated) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + let action = store + .get_repo_action(&action_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Action not found: {action_id}"))?; + let updated = crate::store::models::RepoAction { + id: action.id, + context_id: action.context_id, + name, + command: command_str, + action_type: builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?, + sort_order, + run_detection_mode: action.run_detection_mode, + pinned, + icon, + created_at: action.created_at, + updated_at: crate::store::now_timestamp(), + }; + store + .update_repo_action(&updated) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "delete_project_action" => { let store = get_store(store_mutex)?; let action_id: String = arg(&args, "actionId")?; - store - .delete_repo_action(&action_id) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .delete_repo_action(&action_id) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "list_action_contexts" => { let store = get_store(store_mutex)?; - let contexts = store.list_action_contexts().map_err(|e| e.to_string())?; - Ok(serde_json::to_value(contexts).unwrap()) + blocking_json(move || -> Result<_, String> { + let contexts = store.list_action_contexts().map_err(|e| e.to_string())?; + Ok(contexts) + }) + .await } "list_repo_actions" => { let store = get_store(store_mutex)?; let github_repo: String = arg(&args, "githubRepo")?; let subpath: Option = opt_arg(&args, "subpath")?; - let context = store - .get_or_create_action_context(&github_repo, subpath.as_deref()) - .map_err(|e| e.to_string())?; - let actions = store - .list_repo_actions(&context.id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(actions).unwrap()) + blocking_json(move || -> Result<_, String> { + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + let actions = store + .list_repo_actions(&context.id) + .map_err(|e| e.to_string())?; + Ok(actions) + }) + .await } "list_all_repo_actions" => { let store = get_store(store_mutex)?; @@ -2750,40 +2826,49 @@ pub(crate) async fn dispatch( let sort_order: i32 = arg(&args, "sortOrder")?; let pinned: bool = arg(&args, "pinned")?; let icon: Option = opt_arg(&args, "icon")?; - let context = store - .get_or_create_action_context(&github_repo, subpath.as_deref()) - .map_err(|e| e.to_string())?; - let parsed_type = builderbot_actions::ActionType::parse(&action_type) - .ok_or_else(|| format!("Invalid action type: {action_type}"))?; - let action = crate::store::models::RepoAction::new( - context.id, - name, - command_str, - parsed_type, - sort_order, - ) - .with_pinned(pinned) - .with_icon(icon); - store - .create_repo_action(&action) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(action).unwrap()) + blocking_json(move || -> Result<_, String> { + let context = store + .get_or_create_action_context(&github_repo, subpath.as_deref()) + .map_err(|e| e.to_string())?; + let parsed_type = builderbot_actions::ActionType::parse(&action_type) + .ok_or_else(|| format!("Invalid action type: {action_type}"))?; + let action = crate::store::models::RepoAction::new( + context.id, + name, + command_str, + parsed_type, + sort_order, + ) + .with_pinned(pinned) + .with_icon(icon); + store + .create_repo_action(&action) + .map_err(|e| e.to_string())?; + Ok(action) + }) + .await } "delete_all_repo_actions" => { let store = get_store(store_mutex)?; let context_id: String = arg(&args, "contextId")?; - store - .delete_all_repo_actions(&context_id) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .delete_all_repo_actions(&context_id) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "delete_action_context" => { let store = get_store(store_mutex)?; let context_id: String = arg(&args, "contextId")?; - store - .delete_action_context(&context_id) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .delete_action_context(&context_id) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } // ===================================================================== @@ -3006,140 +3091,156 @@ pub(crate) async fn dispatch( let title: String = arg(&args, "title")?; let content: String = arg(&args, "content")?; let subtype: Option = opt_arg(&args, "subtype")?; - let item = crate::note_commands::create_standalone_note( - &store, &branch_id, &title, &content, subtype, - )?; - Ok(serde_json::to_value(item).unwrap()) + blocking_json(move || { + crate::note_commands::create_standalone_note( + &store, &branch_id, &title, &content, subtype, + ) + }) + .await } "update_note" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; let title: String = arg(&args, "title")?; let content: String = arg(&args, "content")?; - let item = crate::note_commands::update_note_impl(&store, ¬e_id, &title, &content)?; - Ok(serde_json::to_value(item).unwrap()) + blocking_json(move || { + crate::note_commands::update_note_impl(&store, ¬e_id, &title, &content) + }) + .await } "delete_note" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; - let app = app_handle.clone(); - tauri::async_runtime::spawn_blocking(move || { + let app_handle = app_handle.clone(); + // The media cleanup scans the note and message tables per image. + blocking_json(move || { crate::note_commands::delete_note_impl( &store, - &app, + &app_handle, ¬e_id, delete_session_flag.unwrap_or(false), ) }) .await - .map_err(|e| format!("Note delete task failed: {e}"))??; - Ok(Value::Null) } "get_note" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; - let note = store.get_note(¬e_id).map_err(|e| e.to_string())?; - let item = note.map(|n| crate::note_commands::note_to_timeline_item(&store, n)); - Ok(serde_json::to_value(item).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = store.get_note(¬e_id).map_err(|e| e.to_string())?; + let item = note.map(|n| crate::note_commands::note_to_timeline_item(&store, n)); + Ok(item) + }) + .await } "set_note_read" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; let read: bool = arg(&args, "read")?; - let note = store - .set_note_read(¬e_id, read) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_store_json(move || store.set_note_read(¬e_id, read)).await } "list_child_notes" => { let store = get_store(store_mutex)?; let parent_project_note_id: String = arg(&args, "parentProjectNoteId")?; - let notes = store - .list_child_notes(&parent_project_note_id) - .map_err(|e| e.to_string())?; - let items: Vec = notes - .into_iter() - .map(|n| crate::note_commands::note_to_timeline_item(&store, n)) - .collect(); - Ok(serde_json::to_value(items).unwrap()) + blocking_json(move || -> Result<_, String> { + let notes = store + .list_child_notes(&parent_project_note_id) + .map_err(|e| e.to_string())?; + let items: Vec = notes + .into_iter() + .map(|n| crate::note_commands::note_to_timeline_item(&store, n)) + .collect(); + Ok(items) + }) + .await } "create_project_note" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; let title: String = arg(&args, "title")?; let content: String = arg(&args, "content")?; - let note = crate::store::ProjectNote::new(&project_id, &title, &content); - store - .create_project_note(¬e) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = crate::store::ProjectNote::new(&project_id, &title, &content); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await } "list_project_notes" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; - let notes = store - .list_project_notes_with_status(&project_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(notes).unwrap()) + blocking_store_json(move || store.list_project_notes_with_status(&project_id)).await } "get_project_search_index" => { let store = get_store(store_mutex)?; - let items = store - .list_project_search_items() - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(items).unwrap()) + blocking_store_json(move || store.list_project_search_items()).await } "get_project_note" => { let store = get_store(store_mutex)?; let project_note_id: String = arg(&args, "projectNoteId")?; - let note = store - .get_project_note_with_status(&project_note_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = store + .get_project_note_with_status(&project_note_id) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await } "get_project_note_by_session" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - let note = store - .get_project_note_by_session_with_status(&session_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = store + .get_project_note_by_session_with_status(&session_id) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await } "get_branch_note_by_session" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - let note = store - .get_note_by_session(&session_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = store + .get_note_by_session(&session_id) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await } "set_project_note_read" => { let store = get_store(store_mutex)?; let project_note_id: String = arg(&args, "projectNoteId")?; let read: bool = arg(&args, "read")?; - let note = store - .set_project_note_read(&project_note_id, read) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(note).unwrap()) + blocking_json(move || -> Result<_, String> { + let note = store + .set_project_note_read(&project_note_id, read) + .map_err(|e| e.to_string())?; + Ok(note) + }) + .await } "delete_project_note" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; let registry = Arc::clone(session_registry); - tauri::async_runtime::spawn_blocking(move || { + blocking_json(move || { crate::note_commands::delete_project_note_impl(&store, ®istry, ¬e_id) }) .await - .map_err(|e| format!("Project note delete task failed: {e}"))??; - Ok(Value::Null) } "get_note_repo" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; - let repo = crate::note_transfer::branch_note_repo(&store, ¬e_id)?; - Ok(serde_json::to_value(repo).unwrap()) + blocking_json(move || -> Result<_, String> { + let repo = crate::note_transfer::branch_note_repo(&store, ¬e_id)?; + Ok(repo) + }) + .await } "move_note_to_project" | "copy_note_to_project" => { let store = get_store(store_mutex)?; @@ -3250,51 +3351,52 @@ pub(crate) async fn dispatch( "get_image_path" => { let store = get_store(store_mutex)?; let image_id: String = arg(&args, "imageId")?; - let image = store - .get_image(&image_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Image not found: {image_id}"))?; - let path = crate::store::images::image_file_path( - &image.project_id, - &image.id, - &image.filename, - ) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(path.to_string_lossy().to_string()).unwrap()) + blocking_json(move || -> Result<_, String> { + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) + .map_err(|e| e.to_string())?; + Ok(path.to_string_lossy().to_string()) + }) + .await } "get_image_data" => { let store = get_store(store_mutex)?; let image_id: String = arg(&args, "imageId")?; - let image = store - .get_image(&image_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Image not found: {image_id}"))?; - let path = crate::store::images::image_file_path( - &image.project_id, - &image.id, - &image.filename, - ) - .map_err(|e| e.to_string())?; - let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - let data_url = format!("data:{};base64,{}", image.mime_type, encoded); - Ok(serde_json::to_value(data_url).unwrap()) + blocking_json(move || -> Result<_, String> { + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) + .map_err(|e| e.to_string())?; + let bytes = + std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let data_url = format!("data:{};base64,{}", image.mime_type, encoded); + Ok(data_url) + }) + .await } "read_image_file" => { let file_path: String = arg(&args, "filePath")?; - crate::image_commands::read_image_file(file_path).map(Value::String) + blocking_json(move || crate::image_commands::read_image_file_impl(file_path)).await } "delete_image" => { let store = get_store(store_mutex)?; let image_id: String = arg(&args, "imageId")?; - let image = store - .get_image(&image_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Image not found: {image_id}"))?; - store.delete_image(&image_id).map_err(|e| e.to_string())?; - crate::store::images::remove_image_files(&image.project_id, &image.id, &image.filename); - Ok(Value::Null) + blocking_json(move || crate::image_commands::delete_image_impl(&store, &image_id)).await } "get_attachment_thumbnail" => { let store = get_store(store_mutex)?; @@ -3309,10 +3411,13 @@ pub(crate) async fn dispatch( "list_branch_images" => { let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; - let images = store - .list_images_for_branch(&branch_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(images).unwrap()) + blocking_json(move || -> Result<_, String> { + let images = store + .list_images_for_branch(&branch_id) + .map_err(|e| e.to_string())?; + Ok(images) + }) + .await } "get_media_dimensions" => { let store = get_store(store_mutex)?; @@ -3332,26 +3437,33 @@ pub(crate) async fn dispatch( let store = get_store(store_mutex)?; let review_id: String = arg(&args, "reviewId")?; let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; - crate::timeline::delete_review_impl( - &store, - app_handle, - &review_id, - delete_session_flag.unwrap_or(false), - )?; - Ok(Value::Null) + let app_handle = app_handle.clone(); + blocking_json(move || { + crate::timeline::delete_review_impl( + &store, + &app_handle, + &review_id, + delete_session_flag.unwrap_or(false), + ) + }) + .await } "delete_pending_commit" => { let store = get_store(store_mutex)?; let commit_id: String = arg(&args, "commitId")?; let delete_session_flag: Option = opt_arg(&args, "deleteSession")?; - crate::timeline::delete_pending_commit_impl( - &store, - session_registry, - app_handle, - &commit_id, - delete_session_flag.unwrap_or(false), - )?; - Ok(Value::Null) + let registry = Arc::clone(session_registry); + let app_handle = app_handle.clone(); + blocking_json(move || { + crate::timeline::delete_pending_commit_impl( + &store, + ®istry, + &app_handle, + &commit_id, + delete_session_flag.unwrap_or(false), + ) + }) + .await } "delete_commit" => { let store = get_store(store_mutex)?; @@ -3576,22 +3688,30 @@ pub(crate) async fn dispatch( } "get_active_sessions" => { let store = get_store(store_mutex)?; - let mut sessions = - crate::session_commands::get_active_sessions_impl(&store, session_registry)?; + let registry = Arc::clone(session_registry); // A project-scoped control credential sees only its own // project's sessions. - if let Some(project_id) = control_scope::project_filter(&caller) { - sessions.retain(|session| session.project_id.as_deref() == Some(project_id)); - } - Ok(serde_json::to_value(sessions).unwrap()) + let only_project = control_scope::project_filter(&caller).map(str::to_owned); + blocking_json(move || { + let mut sessions = + crate::session_commands::get_active_sessions_impl(&store, ®istry)?; + if let Some(project_id) = only_project.as_deref() { + sessions.retain(|session| session.project_id.as_deref() == Some(project_id)); + } + Ok::<_, String>(sessions) + }) + .await } "list_child_sessions" => { let store = get_store(store_mutex)?; let parent_session_id: String = arg(&args, "parentSessionId")?; - let children = store - .list_active_child_sessions(&parent_session_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(children).unwrap()) + blocking_json(move || -> Result<_, String> { + let children = store + .list_active_child_sessions(&parent_session_id) + .map_err(|e| e.to_string())?; + Ok(children) + }) + .await } "get_session" => { let store = get_store(store_mutex)?; @@ -3626,18 +3746,19 @@ pub(crate) async fn dispatch( let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; let after_timestamp: i64 = arg(&args, "afterTimestamp")?; - let count = store - .count_assistant_messages_after(&session_id, after_timestamp) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(count).unwrap()) + blocking_json(move || -> Result<_, String> { + let count = store + .count_assistant_messages_after(&session_id, after_timestamp) + .map_err(|e| e.to_string())?; + Ok(count) + }) + .await } "mark_session_chat_read" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - store - .mark_session_chat_read(&session_id) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + // `()` serializes to `null`, the response the arm always gave. + blocking_store_json(move || store.mark_session_chat_read(&session_id)).await } "get_session_acp_metadata_messages" => { let store = get_store(store_mutex)?; @@ -3665,10 +3786,13 @@ pub(crate) async fn dispatch( "get_session_acp_initialization" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - let metadata = store - .get_session_acp_initialization(&session_id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(metadata).unwrap()) + blocking_json(move || -> Result<_, String> { + let metadata = store + .get_session_acp_initialization(&session_id) + .map_err(|e| e.to_string())?; + Ok(metadata) + }) + .await } // The browser normally loads `/api/tool-images/…` straight into an // ``; this arm keeps the command callable from both transports. @@ -3783,15 +3907,15 @@ pub(crate) async fn dispatch( let content: String = arg(&args, "content")?; let image_ids: Option> = opt_arg(&args, "imageIds")?; let branch_id: Option = opt_arg(&args, "branchId")?; - let message = store - .add_queued_session_message( + blocking_store_json(move || { + store.add_queued_session_message( &session_id, &content, &image_ids.unwrap_or_default(), branch_id.as_deref(), ) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(message).unwrap()) + }) + .await } "list_queued_session_messages" => { let store = get_store(store_mutex)?; @@ -3801,10 +3925,13 @@ pub(crate) async fn dispatch( "delete_queued_session_message" => { let store = get_store(store_mutex)?; let id: String = arg(&args, "id")?; - let deleted = store - .delete_queued_session_message(&id) - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(deleted).unwrap()) + blocking_json(move || -> Result<_, String> { + let deleted = store + .delete_queued_session_message(&id) + .map_err(|e| e.to_string())?; + Ok(deleted) + }) + .await } "send_queued_session_message" => { let store = get_store(store_mutex)?; @@ -3846,9 +3973,16 @@ pub(crate) async fn dispatch( "get_session_live_delivery" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - let capability = - session_commands::live_delivery_for_session(&store, session_registry, &session_id)?; - Ok(serde_json::to_value(capability).unwrap()) + let session_registry = Arc::clone(session_registry); + blocking_json(move || -> Result<_, String> { + let capability = session_commands::live_delivery_for_session( + &store, + &session_registry, + &session_id, + )?; + Ok(capability) + }) + .await } "build_note_followup_message" => { let store = get_store(store_mutex)?; @@ -4021,13 +4155,12 @@ pub(crate) async fn dispatch( "delete_session" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; - session_commands::delete_session_impl( - session_registry, - &store, - app_handle, - &session_id, - )?; - Ok(Value::Null) + let registry = Arc::clone(session_registry); + let app_handle = app_handle.clone(); + blocking_json(move || { + session_commands::delete_session_impl(®istry, &store, &app_handle, &session_id) + }) + .await } // ===================================================================== // PRs @@ -4085,8 +4218,11 @@ pub(crate) async fn dispatch( "cancel_deferred_push" => { let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; - crate::prs::cancel_deferred_push_for_branch(&store, &branch_id)?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + crate::prs::cancel_deferred_push_for_branch(&store, &branch_id)?; + Ok(()) + }) + .await } "rebase_branch" => { let store = get_store(store_mutex)?; @@ -4151,10 +4287,13 @@ pub(crate) async fn dispatch( let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; let pr_number: Option = opt_arg(&args, "prNumber")?; - store - .update_branch_pr_number(&branch_id, pr_number, None) - .map_err(|e| e.to_string())?; - Ok(Value::Null) + blocking_json(move || -> Result<_, String> { + store + .update_branch_pr_number(&branch_id, pr_number, None) + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await } "recover_branch_pr" => { let store = get_store(store_mutex)?; @@ -4299,8 +4438,12 @@ pub(crate) async fn dispatch( "clear_branch_pr_status" => { let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; - crate::prs::clear_branch_pr_status_impl(&store, app_handle, &branch_id)?; - Ok(Value::Null) + let app_handle = app_handle.clone(); + blocking_json(move || -> Result<_, String> { + crate::prs::clear_branch_pr_status_impl(&store, &app_handle, &branch_id)?; + Ok(()) + }) + .await } // ===================================================================== @@ -4359,23 +4502,26 @@ pub(crate) async fn dispatch( } "read_text_file" => { let file_path: String = arg(&args, "filePath")?; - let path = std::path::Path::new(&file_path); - if !path.exists() { - return Err(format!("File does not exist: {file_path}")); - } - if !path.is_file() { - return Err(format!("Not a file: {file_path}")); - } - let content = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?; - Ok(serde_json::to_value(content).unwrap()) + blocking_json(move || -> Result<_, String> { + let path = std::path::Path::new(&file_path); + if !path.exists() { + return Err(format!("File does not exist: {file_path}")); + } + if !path.is_file() { + return Err(format!("Not a file: {file_path}")); + } + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read file: {e}"))?; + Ok(content) + }) + .await } "resolve_path_aliases" => { let paths: Vec = arg(&args, "paths")?; - Ok( - serde_json::to_value(crate::util_commands::resolve_path_aliases_impl(paths)) - .unwrap(), - ) + blocking_json(move || -> Result<_, String> { + Ok(crate::util_commands::resolve_path_aliases_impl(paths)) + }) + .await } "preferences_store_path" => { let path = crate::preferences_store_path_buf() diff --git a/apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs b/apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs index 6d75161c8..6d9884097 100644 --- a/apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs +++ b/apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs @@ -16,6 +16,24 @@ //! `ALLOWED` ones. `git` and `blox` fns are walked like any other, so a pure //! helper there passes and one that spawns git fails. Plain method calls are //! not followed, which keeps store reads and other in-process work out of it. +//! +//! The commands that are still plain `fn`s run on the main thread, where any +//! wait freezes the window. `synchronous_commands_touch_only_in_process_state` +//! holds them to in-process state: each is listed by name, and the same walk +//! looks for the store, a child process or the filesystem in what it runs. +//! +//! What these tests do not cover: a store call made inline on the runtime by +//! an `async fn` command or a `dispatch` arm that is not in +//! `OFFLOADED_COMMANDS`. The walk looks for child-process waits only, and the +//! handoff-only rule is applied by name to the commands that used to be +//! synchronous. So the guards keep the store off the main thread; they do not +//! keep it off the runtime workers. Commands that were `async` before those +//! conversions still call the store inline (`ensure_review`, `find_review` +//! and `add_comment` in `review_commands.rs` are three, and the +//! `ensure_repo_badges`, `list_repos_for_home` and `list_all_repo_actions` +//! arms do the same), where a slow holder of the store lock parks a worker. +//! A new `async fn` command that does so passes every test here unless it is +//! added to `OFFLOADED_COMMANDS`. use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; @@ -87,7 +105,7 @@ fn async_commands_and_arms_keep_blocking_calls_off_the_runtime() { let mut targets = Vec::new(); let mut defined = BTreeSet::new(); for (file, (path, code)) in src.files.iter().enumerate() { - for (name, is_async, body) in command_fns(code) { + for (name, is_async, _, body) in command_fns(code) { if is_async { targets.push(( format!("`{name}` command ({path})"), @@ -165,6 +183,473 @@ fn async_commands_and_arms_keep_blocking_calls_off_the_runtime() { ); } +/// Commands that were synchronous and froze the window: on the main thread +/// they waited on the store, on `gh` or on a login shell. Each is now an +/// `async fn` whose whole body runs under `spawn_blocking`, as (file, name). +/// +/// Not every async command that reaches the store: the ones that were +/// already `async` are not listed, and some of them call the store inline. +const OFFLOADED_COMMANDS: &[(&str, &str)] = &[ + ("util_commands.rs", "is_sq_available"), + ("lib.rs", "create_project"), + ("lib.rs", "pin_repo"), + // The deletes: a media scan per image, or a cascade over every message + // of a session. + ("note_commands.rs", "delete_note"), + ("note_commands.rs", "delete_project_note"), + ("session_commands.rs", "delete_session"), + ("timeline.rs", "delete_review"), + ("timeline.rs", "delete_pending_commit"), + ("image_commands.rs", "delete_image"), + // The reads the frontend sends at launch, on timers and on every store + // event, which waited behind whatever held the store. + ("lib.rs", "list_projects"), + ("lib.rs", "list_project_repos"), + ("lib.rs", "get_all_repo_badges"), + ("branches.rs", "list_branches_for_project"), + ("session_commands.rs", "poll_session"), + ("session_commands.rs", "get_session"), + ("session_commands.rs", "get_session_messages"), + ("session_commands.rs", "get_session_messages_since"), + ("session_commands.rs", "get_active_sessions"), + ("note_commands.rs", "list_project_notes"), + ("project_search.rs", "get_project_search_index"), + // The writes sent on every note save, note open, chat open or close and + // queued follow-up. The frontend keeps the order of the ones it does not + // await (the `writeOrder` queue in `commands.ts`). + ("note_commands.rs", "create_note"), + ("note_commands.rs", "update_note"), + ("note_commands.rs", "set_note_read"), + ("session_commands.rs", "mark_session_chat_read"), + ("session_commands.rs", "queue_session_message"), + // Every other command that reached the store, a child process or the + // filesystem from the main thread. What is still a plain `fn` is in + // `SYNCHRONOUS_COMMANDS` below. + // + // Store reset and quit: file deletes and a schema build, and the read of + // the sessions a quit would stop. + ("lib.rs", "confirm_reset_store"), + ("app_lifecycle.rs", "quit_app"), + ("launch_resume/mod.rs", "resume_interrupted_sessions"), + // Repos, badges and pins. + ("lib.rs", "list_recent_repos"), + ("lib.rs", "get_suggested_repos"), + ("lib.rs", "clear_project_repo_reason"), + ("lib.rs", "set_primary_project_repo"), + ("lib.rs", "update_project_repo_branch_name"), + ("lib.rs", "delete_repo_badge"), + ("lib.rs", "update_repo_badge"), + ("lib.rs", "unpin_repo"), + ("lib.rs", "reorder_pinned_repos"), + ("lib.rs", "set_repo_default_branch"), + // `git remote show origin` or `gh repo view`. + ("lib.rs", "detect_default_branch"), + // Actions. The two lists insert the action context on first use. + ("lib.rs", "list_project_actions"), + ("lib.rs", "update_project_action"), + ("lib.rs", "delete_project_action"), + ("lib.rs", "list_action_contexts"), + ("lib.rs", "list_repo_actions"), + ("lib.rs", "create_repo_action"), + ("lib.rs", "delete_all_repo_actions"), + ("lib.rs", "delete_action_context"), + // Branches. `create_branch` asks GitHub for the default branch. + ("branches.rs", "get_branch"), + ("branches.rs", "create_branch"), + // Images: store rows, and whole files read and base64-encoded. + ("image_commands.rs", "get_image_path"), + ("image_commands.rs", "list_branch_images"), + ("image_commands.rs", "get_image_data"), + ("image_commands/tool_results.rs", "read_image_file"), + // Notes. + ("note_commands.rs", "get_branch_note_by_session"), + ("note_commands.rs", "get_note"), + ("note_commands.rs", "list_child_notes"), + ("note_commands.rs", "create_project_note"), + ("note_commands.rs", "get_project_note"), + ("note_commands.rs", "get_project_note_by_session"), + ("note_commands.rs", "set_project_note_read"), + ("note_transfer.rs", "get_note_repo"), + // Pull requests. + ("prs/commands.rs", "update_branch_pr"), + ("prs/commands.rs", "clear_branch_pr_status"), + ("prs/commands.rs", "cancel_deferred_push"), + // Sessions. `queue_branch_session` can discover the installed agents + // through login shells. + ("session_commands.rs", "list_child_sessions"), + ("session_commands.rs", "get_session_acp_metadata_messages"), + ( + "session_commands.rs", + "get_session_acp_metadata_messages_since", + ), + ("session_commands.rs", "get_tool_image"), + ("session_commands.rs", "get_tool_call_body"), + ("session_commands.rs", "get_session_acp_initialization"), + ("session_commands.rs", "count_assistant_messages_after"), + ("session_commands.rs", "list_queued_session_messages"), + ("session_commands.rs", "delete_queued_session_message"), + ("session_commands.rs", "get_session_live_delivery"), + ("session_commands.rs", "queue_branch_session"), + // Tags: one store transaction each. + ("tag_commands.rs", "get_tag_state"), + ("tag_commands.rs", "create_tag_group"), + ("tag_commands.rs", "rename_tag_group"), + ("tag_commands.rs", "set_icon_tag_group"), + ("tag_commands.rs", "set_tag_group_sidebar"), + ("tag_commands.rs", "reorder_tag_groups"), + ("tag_commands.rs", "delete_tag_group"), + ("tag_commands.rs", "create_tag"), + ("tag_commands.rs", "update_tag"), + ("tag_commands.rs", "move_tag"), + ("tag_commands.rs", "delete_tag"), + ("tag_commands.rs", "set_project_group_tag"), + ("tag_commands.rs", "set_project_tag_assigned"), + // Connected apps: grant rows in the store. + ("control_server/commands.rs", "list_control_grants"), + ("control_server/commands.rs", "revoke_control_grant"), + // The opener spawns `open`; the other two read the filesystem. + ("util_commands.rs", "open_url"), + ("util_commands.rs", "read_text_file"), + ("util_commands.rs", "resolve_path_aliases"), +]; + +/// What an [`OFFLOADED_COMMANDS`] body may call on the runtime: the store +/// and registry handle lookups, and the mapping of the join error. +const OFFLOADED_COMMAND_INLINE: &[&str] = + &["get_store", "crate::get_store", "Arc::clone", "map_err"]; + +/// What the `dispatch` arm of an [`OFFLOADED_COMMANDS`] entry may call on +/// the runtime: the handle lookups, argument parsing, the caller's project +/// scope, and the handling of a bare `spawn_blocking`'s result. +const OFFLOADED_ARM_INLINE: &[&str] = &[ + "get_store", + "arg", + "opt_arg", + "unwrap_or_default", + "Arc::clone", + "clone", + "control_scope::project_filter", + "map", + "map_err", + "to_string", + "serde_json::to_value", + "unwrap", +]; + +/// [`OFFLOADED_COMMANDS`] entries whose `dispatch` arm still calls the store +/// on the runtime, each with why. +const ARMS_NOT_OFFLOADED: &[&str] = &[ + // Not the Tauri command's body: no `sq` check and no prerun actions. It + // offloads its default-branch lookup and makes its store writes inline. + "create_project", +]; + +/// [`OFFLOADED_COMMANDS`] entries `dispatch` refuses in web mode (or leaves +/// to the browser), so their arm has nothing to hand off. +const ARMS_WITH_NOTHING_TO_OFFLOAD: &[&str] = &[ + "confirm_reset_store", + "quit_app", + "resume_interrupted_sessions", + "list_control_grants", + "revoke_control_grant", + "open_url", +]; + +/// The walk above only looks for child-process waits, and only in commands +/// that are already async. It passes a command that goes back to a plain +/// `fn`, which runs on the main thread again, and one that calls the store +/// inline, where a lock wait parks a runtime worker. +/// +/// This holds the [`OFFLOADED_COMMANDS`] entries to the handoff, and only +/// them. An async command outside the list is not checked for inline store +/// calls by any test (see the module docs). +#[test] +fn offloaded_commands_run_only_the_handoff_on_the_runtime() { + let src = Crate::load(); + for (path, command) in OFFLOADED_COMMANDS { + let code = &src.files[src.file(path)].1; + let (_, is_async, _, body) = command_fns(code) + .into_iter() + .find(|(name, ..)| name == command) + .unwrap_or_else(|| panic!("{path} no longer defines the `{command}` command")); + assert!( + is_async, + "`{command}` must stay an `async fn`: a plain `fn` command runs on the main \ + thread and freezes the window while it waits" + ); + let (offloads, stray) = + left_on_runtime(&body, &["spawn_blocking("], OFFLOADED_COMMAND_INLINE); + assert!( + offloads, + "`{command}` must run its body through `spawn_blocking`" + ); + assert!( + stray.is_empty(), + "`{command}` runs these on a runtime worker; move them into its \ + `spawn_blocking` closure: {stray:?}" + ); + } +} + +/// `dispatch` runs on the same runtime as the async commands, so the arm of +/// a command that had to leave the main thread has the same rule: the store +/// call goes through `blocking_json`, `blocking_store_json` or +/// `spawn_blocking`, and only the handoff stays inline. +#[test] +fn offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime() { + let arms = dispatch_arms(include_str!("../web_server.rs")); + for (_, command) in OFFLOADED_COMMANDS { + let (_, body) = arms + .iter() + .find(|(commands, _)| commands.iter().any(|name| name == command)) + .unwrap_or_else(|| panic!("`dispatch` no longer has a `{command}` arm")); + let (offloads, stray) = left_on_runtime(&code_only(body), OFFLOADED, OFFLOADED_ARM_INLINE); + if ARMS_NOT_OFFLOADED.contains(command) { + assert!( + !stray.is_empty(), + "the `{command}` dispatch arm now runs only the handoff on the runtime; \ + remove it from ARMS_NOT_OFFLOADED" + ); + continue; + } + if ARMS_WITH_NOTHING_TO_OFFLOAD.contains(command) { + assert!( + !offloads && stray.is_empty(), + "the `{command}` dispatch arm now does work; remove it from \ + ARMS_WITH_NOTHING_TO_OFFLOAD: {stray:?}" + ); + continue; + } + assert!( + offloads, + "the `{command}` dispatch arm must run its body through `blocking_json`, \ + `blocking_store_json` or `spawn_blocking`" + ); + assert!( + stray.is_empty(), + "the `{command}` dispatch arm runs these on a runtime worker; move them into \ + its blocking closure: {stray:?}" + ); + } + for command in ARMS_NOT_OFFLOADED + .iter() + .chain(ARMS_WITH_NOTHING_TO_OFFLOAD) + { + assert!( + OFFLOADED_COMMANDS.iter().any(|(_, name)| name == command), + "`{command}` is listed as an arm exception but is not an OFFLOADED_COMMANDS \ + entry; remove it" + ); + } +} + +/// The commands that stay plain `fn`s, as (file, name). A plain `fn` command +/// runs on the main thread, so the window is frozen for as long as it takes. +/// Each of these only reads or flips state held in memory: a registry or +/// scheduler behind its own short-lived mutex, an atomic flag, an environment +/// variable, a path computed without touching the disk. +/// +/// A new command belongs here only if that is all it does. One that reads or +/// writes the store, waits on a child process or touches the filesystem is +/// an `async fn` that hands its body to `spawn_blocking` (see +/// [`OFFLOADED_COMMANDS`]). The walk cannot see through method calls, so +/// read what a new entry's methods do before listing it. +const SYNCHRONOUS_COMMANDS: &[(&str, &str)] = &[ + // The running-action registry and executor. Stop sends one signal and + // leaves the escalation to a thread. + ("actions/commands.rs", "stop_branch_action"), + ("actions/commands.rs", "get_running_branch_actions"), + ("actions/commands.rs", "get_all_running_actions"), + ("actions/commands.rs", "clear_action_execution"), + // An environment variable. + ("branches.rs", "get_blox_env"), + // The pending connection requests, held in memory. An approval is only + // recorded here; the grant row is written when the CLI collects it. + ( + "control_server/commands.rs", + "list_pending_control_authorizations", + ), + ("control_server/commands.rs", "answer_control_authorization"), + // The launch's offer and the store-incompatibility flag. + ("launch_resume/mod.rs", "get_interrupted_sessions"), + ("lib.rs", "get_store_status"), + // Paths joined onto the home directory; nothing is opened. + ("lib.rs", "get_repo_clone_path"), + ("util_commands.rs", "preferences_store_path"), + // Hints for the PR poll loop, which does its own store reads. + ("pr_poll_scheduler.rs", "set_foreground_project"), + ("pr_poll_scheduler.rs", "set_focus"), + ("pr_poll_scheduler.rs", "set_branch_pending"), + ("pr_poll_scheduler.rs", "refresh_now"), + ("pr_poll_scheduler.rs", "disconnect_client"), + // The transfer and preflight registries: a flag set, a list cloned. + ("project_transfer/commands.rs", "cancel_project_transfer"), + ("project_transfer/commands.rs", "list_transferring_projects"), + ( + "project_transfer/preflight_scan.rs", + "cancel_project_transfer_preflight", + ), + // The session registry's hold snapshot, and the reconciler's row states. + // `reconcile_session` claims the row and runs the attempt on a thread. + ("session_commands.rs", "get_session_background_hold"), + ("session_runner/reconcile.rs", "reconcile_session"), + ( + "session_runner/reconcile.rs", + "get_session_reconcile_states", + ), + // Per-window seeds and the updater claim. + ("window_commands.rs", "take_window_seed"), + ("window_commands.rs", "claim_updater_ownership"), +]; + +/// Plain `fn` commands that do reach the store, a child process or the +/// filesystem and stay on the main thread anyway, as (file, name), each with +/// why. Keep this short: every entry is a window freeze waiting for a slow +/// holder of the store. +const SYNCHRONOUS_BLOCKING_ALLOWED: &[(&str, &str)] = &[ + // Stop. For a running, registered turn it only fires the session's + // cancellation token in the registry. The store is reached when no runner + // owns the row (queued, paused, disconnected, or already finished): the + // row is read, and if it is still live it is written `cancelled`, its + // pipeline dependents are skipped and the queue is kicked. That is + // several store lock waits on the main thread, each normally short. + // + // It stays synchronous for its ordering. The registry and that fallback + // write are keyed by session id alone, not by turn. On the main thread a + // Stop finishes before any command sent after it is dispatched, so it can + // only end the turn that was live when the user pressed it. The case + // that matters is a turn that ended by itself as Stop was pressed, with + // the next message sent straight after: on the pool, a Stop that lost + // the store lock to that resume would cancel the new turn in the + // registry, or, between the resume's claim of the row and its + // registration, write `cancelled` over a turn that is starting. + // Converting it needs a turn token in the registry and the store write, + // or the frontend sending Stop and every turn-starting command for a + // session in order. + ("session_commands.rs", "cancel_session"), +]; + +/// Calls whose argument list runs off the main thread: the [`OFFLOADED`] +/// ones, and tasks spawned onto the runtime. +const OFF_MAIN_THREAD: &[&str] = &[ + "spawn_blocking(", + "thread::spawn(", + "blocking_json(", + "blocking_store_json(", + "run_blox_blocking(", + "async_runtime::spawn(", + "tokio::spawn(", +]; + +/// The store: its type, the handle lookups, and a call on a `store` value. +/// `store::` paths (models, `now_timestamp`) are not the database. +const STORE_MARKERS: &[&str] = &["Store", "get_store", "store_of", "store."]; + +/// The filesystem and the processes the [`BLOCKING`] list does not cover. +const FILESYSTEM_AND_OPENER_MARKERS: &[&str] = &[ + "fs::", + "File::", + "canonicalize(", + "read_dir(", + ".exists()", + ".is_file()", + ".is_dir()", + ".metadata()", + // The preferences file, loaded and saved through the store plugin. + "preferences_store(", + // `open`, spawned by the opener plugin. + ".opener()", +]; + +/// A plain `fn` command runs on the main thread, and the window is frozen +/// until it returns. The freezes this file's other tests keep away came from +/// such commands waiting on the store lock, on `gh`, on a login shell or on +/// the disk. So every plain `fn` command is listed, and none may reach the +/// store, a child process or the filesystem: in its own signature and body, +/// or in the crate fns it calls inline (the walk of the async check, here +/// for the main thread). +/// +/// The walk does not follow method calls, so a registry method that took the +/// store would pass it. That is why the list is by name: a new synchronous +/// command fails here until someone has read what it calls. +#[test] +fn synchronous_commands_touch_only_in_process_state() { + let src = Crate::load(); + let mut offences = Vec::new(); + let mut synchronous = BTreeSet::new(); + for (file, (path, code)) in src.files.iter().enumerate() { + for (name, is_async, params, body) in command_fns(code) { + if is_async { + continue; + } + let reached = src.main_thread_reach(file, &format!("{params} {body}")); + let entry = (path.as_str(), name.as_str()); + let listed = SYNCHRONOUS_COMMANDS.contains(&entry); + let allowed = SYNCHRONOUS_BLOCKING_ALLOWED.contains(&entry); + let reaches = reached.iter().cloned().collect::>().join("; "); + match (listed, allowed, reached.is_empty()) { + (true, true, _) => offences.push(format!( + "`{name}` ({path}) is in both SYNCHRONOUS_COMMANDS and \ + SYNCHRONOUS_BLOCKING_ALLOWED" + )), + (true, false, true) | (false, true, false) => {} + (true, false, false) => offences.push(format!( + "`{name}` ({path}) is listed as in-process but reaches {reaches}. Make it \ + an `async fn` that runs its body under `spawn_blocking` and move it to \ + OFFLOADED_COMMANDS" + )), + (false, true, true) => offences.push(format!( + "`{name}` ({path}) no longer reaches the store, a process or the \ + filesystem; move it from SYNCHRONOUS_BLOCKING_ALLOWED to \ + SYNCHRONOUS_COMMANDS" + )), + (false, false, false) => offences.push(format!( + "`{name}` ({path}) is a plain `fn` command, so it runs on the main thread, \ + and it reaches {reaches}. Make it an `async fn` that runs its body under \ + `spawn_blocking` and add it to OFFLOADED_COMMANDS" + )), + (false, false, true) => offences.push(format!( + "`{name}` ({path}) is a new plain `fn` command, so it runs on the main \ + thread. The scan found nothing that blocks, but it does not follow \ + method calls: if every call it makes stays in memory, add it to \ + SYNCHRONOUS_COMMANDS; otherwise make it an `async fn` that runs its \ + body under `spawn_blocking`" + )), + } + synchronous.insert((path.clone(), name)); + } + } + for (path, name) in SYNCHRONOUS_COMMANDS + .iter() + .chain(SYNCHRONOUS_BLOCKING_ALLOWED) + { + if !synchronous.contains(&(path.to_string(), name.to_string())) { + offences.push(format!( + "`{name}` ({path}) is listed as a synchronous command but is no longer a \ + plain `fn` command there; remove the entry" + )); + } + } + assert!( + offences.is_empty(), + "synchronous commands must touch in-process state only:\n{}", + offences.join("\n") + ); +} + +/// What `body` runs on the runtime once the argument list of every +/// `offloaders` call is cut out: whether there was such a call at all, and +/// the calls left over that `inline` does not allow. +fn left_on_runtime(body: &str, offloaders: &[&str], inline: &[&str]) -> (bool, Vec) { + let kept = cut_offloaded(body, offloaders); + let stray = calls(&kept) + .into_iter() + .map(|call| call.path) + .filter(|path| !inline.contains(&path.as_str())) + .collect(); + (kept != body, stray) +} + /// The crate's source, test code removed, and every fn in it. struct Crate { /// Each file's path under `src/`, and its code (see `code_only`). @@ -184,6 +669,7 @@ struct FnItem { is_async: bool, /// Takes `self`, so it is reachable only as a method call. is_method: bool, + params: String, body: String, } @@ -273,6 +759,49 @@ impl Crate { scan } + /// What code starting at `text`, in `file`, reaches on the calling thread + /// that can hold it up: the store, a child process, the filesystem. Each + /// is reported with the chain of crate fns that led to it. `text` is a + /// command's parameter list and body; the fns it calls inline are walked + /// the same way, up to `MAX_DEPTH` deep. + fn main_thread_reach(&self, file: usize, text: &str) -> BTreeSet { + let mut reached = BTreeSet::new(); + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([(file, text.to_string(), Vec::new())]); + while let Some((file, text, via)) = queue.pop_front() { + let code = cut_offloaded(&text, OFF_MAIN_THREAD); + let markers = (STORE_MARKERS.iter()) + .chain(FILESYSTEM_AND_OPENER_MARKERS) + .filter(|marker| names(&code, marker)) + .chain(BLOCKING.iter().filter(|marker| code.contains(**marker))); + for marker in markers { + reached.insert(format!("`{marker}`{}", self.describe(&via))); + } + for call in calls(&code) { + let callees = self.resolve(file, &call); + if callees.is_empty() + && BLOCKING_MODULES.contains(&call.qualifier().unwrap_or_default()) + { + reached.insert(format!("`{}(`{}", call.path, self.describe(&via))); + } + if via.len() == MAX_DEPTH { + continue; + } + for callee in callees { + if seen.insert(callee) { + let f = &self.fns[callee]; + queue.push_back(( + f.file, + format!("{} {}", f.params, f.body), + [via.as_slice(), &[callee]].concat(), + )); + } + } + } + } + reached + } + /// The fns `call`, made in `file`, can reach. An awaited method call /// reaches every async method of that name; a plain one is not followed. fn resolve(&self, file: usize, call: &Call) -> Vec { @@ -509,6 +1038,7 @@ fn fn_items(code: &str) -> Vec { name: name.to_string(), is_async: code[..at].trim_end().ends_with("async"), is_method: receiver.is_some_and(|r| r == "self" || r.starts_with("self:")), + params: params.to_string(), body: body.to_string(), }) }) @@ -516,15 +1046,21 @@ fn fn_items(code: &str) -> Vec { .collect() } -/// Every `#[tauri::command]` fn in `code` as (name, is `async`, body). -fn command_fns(code: &str) -> Vec<(String, bool, String)> { +/// Every `#[tauri::command]` fn in `code` as (name, is `async`, parameter +/// list, body). +fn command_fns(code: &str) -> Vec<(String, bool, String, String)> { code.match_indices("#[tauri::command") .filter_map(|(at, _)| { let fn_at = at + code[at..].find("fn ")?; let is_async = code[at..fn_at].split_whitespace().any(|w| w == "async"); let (name, after_name) = ident_at(code, fn_at + "fn ".len()); - let (_, body) = fn_params_and_body(code, after_name)?; - Some((name.to_string(), is_async, body.to_string())) + let (params, body) = fn_params_and_body(code, after_name)?; + Some(( + name.to_string(), + is_async, + params.to_string(), + body.to_string(), + )) }) .collect() } @@ -640,6 +1176,18 @@ fn char_literal_len(rest: &[char]) -> Option { } } +/// Whether `code` names `marker`: as text, and where the marker starts with +/// an identifier, not as the tail of a longer one (`Store` is not in +/// `StoreChange`, nor `store.` in `needs_restore.`). +fn names(code: &str, marker: &str) -> bool { + let whole_word = marker.starts_with(is_ident_char); + code.match_indices(marker).any(|(at, _)| { + let after = &code[at + marker.len()..]; + let ends = !marker.ends_with(is_ident_char) || !after.starts_with(is_ident_char); + ends && !(whole_word && code[..at].ends_with(is_ident_char)) + }) +} + fn is_ident_char(c: char) -> bool { c.is_alphanumeric() || c == '_' } diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 705bab6c6..96db37ac1 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -1047,3 +1047,339 @@ describe('persisted branch timelines (desktop)', () => { expect(commands.getBranchTimelineWithRevalidation('b1').cached).toEqual(timeline('b1-v2')); }); }); + +/** + * `update_note`, `create_note`, `set_note_read`, `mark_session_chat_read` + * and `queue_session_message` run on the backend's thread pool, where two in + * flight land in either order. Each transport call here is settled by hand, + * so a test can leave the first in flight and see whether the second is sent. + */ +describe('writes sent without awaiting keep their send order', () => { + type Sent = { command: string; args: Record; settle: (value?: unknown) => void }; + + async function load() { + vi.resetModules(); + const sent: Sent[] = []; + const invokeCommand = vi.fn( + (command: string, args: Record) => + new Promise((resolve) => { + sent.push({ command, args, settle: resolve }); + }) + ); + vi.doMock('./transport', () => ({ invokeCommand, isTauri: true })); + const commands = await import('./commands'); + return { commands, sent }; + } + + async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + afterEach(() => { + vi.doUnmock('./transport'); + }); + + it('sends a note’s second save only once the first has landed', async () => { + const { commands, sent } = await load(); + + void commands.updateNote('note-1', 'Plan', 'older'); + void commands.updateNote('note-1', 'Plan', 'newer'); + await flush(); + expect(sent.map((call) => call.args.content)).toEqual(['older']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.args.content)).toEqual(['older', 'newer']); + }); + + it('sends the read mark only once the unread mark before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.setNoteRead('note-1', false); + void commands.setNoteRead('note-1', true); + await flush(); + expect(sent.map((call) => call.args.read)).toEqual([false]); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.args.read)).toEqual([false, true]); + }); + + it('orders a note’s save and its read mark against each other', async () => { + const { commands, sent } = await load(); + + void commands.setNoteRead('note-1', true); + void commands.updateNote('note-1', 'Plan', 'body'); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_note_read']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_note_read', 'update_note']); + }); + + it('sends a note’s delete only once the unread mark before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.setNoteRead('note-1', false); + void commands.deleteNote('note-1'); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_note_read']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_note_read', 'delete_note']); + }); + + it('creates a branch’s dropped notes one at a time, in drop order', async () => { + const { commands, sent } = await load(); + + void commands.createNote('branch-1', 'a.md', 'A'); + void commands.createNote('branch-1', 'b.md', 'B'); + void commands.createNote('branch-1', 'c.md', 'C'); + await flush(); + expect(sent.map((call) => call.args.title)).toEqual(['a.md']); + + sent[0].settle({}); + await flush(); + sent[1].settle({}); + await flush(); + expect(sent.map((call) => call.args.title)).toEqual(['a.md', 'b.md', 'c.md']); + }); + + it('queues a session’s second message only once the first is queued', async () => { + const { commands, sent } = await load(); + + void commands.queueSessionMessage('session-1', 'first'); + void commands.queueSessionMessage('session-1', 'second'); + await flush(); + expect(sent.map((call) => call.args.content)).toEqual(['first']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.args.content)).toEqual(['first', 'second']); + }); + + it('sends a chat’s close mark only once its open mark has landed', async () => { + const { commands, sent } = await load(); + + void commands.markSessionChatRead('session-1'); + void commands.markSessionChatRead('session-1'); + await flush(); + expect(sent).toHaveLength(1); + + sent[0].settle(); + await flush(); + expect(sent).toHaveLength(2); + }); + + it('sends a repo badge’s second save only once the first has landed', async () => { + const { commands, sent } = await load(); + + void commands.updateRepoBadge('acme/app', '', 'AP', 10); + void commands.updateRepoBadge('acme/app', '', 'APP', 10); + await flush(); + expect(sent.map((call) => call.args.shortName)).toEqual(['AP']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.args.shortName)).toEqual(['AP', 'APP']); + }); + + it('sends a repo badge’s delete only once the save before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.updateRepoBadge('acme/app', 'web', 'AP', 10); + void commands.deleteRepoBadge('acme/app', 'web'); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['update_repo_badge']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['update_repo_badge', 'delete_repo_badge']); + }); + + it('sends pinned repo reorders one at a time, in drop order', async () => { + const { commands, sent } = await load(); + + void commands.reorderPinnedRepos([ + ['acme/a', ''], + ['acme/b', ''], + ]); + void commands.reorderPinnedRepos([ + ['acme/b', ''], + ['acme/a', ''], + ]); + await flush(); + expect(sent).toHaveLength(1); + + sent[0].settle(); + await flush(); + expect(sent.map((call) => (call.args.orderedKeys as [string, string][])[0][0])).toEqual([ + 'acme/a', + 'acme/b', + ]); + }); + + it('sends a project note’s read mark only once the unread mark before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.setProjectNoteRead('pnote-1', false); + void commands.setProjectNoteRead('pnote-1', true); + await flush(); + expect(sent.map((call) => call.args.read)).toEqual([false]); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.args.read)).toEqual([false, true]); + }); + + it('sends a project note’s delete only once the unread mark before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.setProjectNoteRead('pnote-1', false); + void commands.deleteProjectNote('pnote-1'); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_project_note_read']); + + sent[0].settle({}); + await flush(); + expect(sent.map((call) => call.command)).toEqual([ + 'set_project_note_read', + 'delete_project_note', + ]); + }); + + it('sends a tag’s catalog edit only once the assignment before it has landed', async () => { + const { commands, sent } = await load(); + + void commands.setProjectTagAssigned('project-1', 'tag-1', true); + void commands.moveTag('tag-1', 'group-1', 0); + void commands.setProjectGroupTag('project-2', 'group-1', 'tag-2'); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_project_tag_assigned']); + + sent[0].settle(); + await flush(); + expect(sent.map((call) => call.command)).toEqual(['set_project_tag_assigned', 'move_tag']); + + sent[1].settle(); + await flush(); + expect(sent.map((call) => call.command)).toEqual([ + 'set_project_tag_assigned', + 'move_tag', + 'set_project_group_tag', + ]); + }); + + it('sends the next write after one that failed', async () => { + vi.resetModules(); + const invokeCommand = vi + .fn() + .mockRejectedValueOnce(new Error('Note not found: note-1')) + .mockResolvedValueOnce({ id: 'note-1' }); + vi.doMock('./transport', () => ({ invokeCommand, isTauri: true })); + const commands = await import('./commands'); + + const failed = commands.updateNote('note-1', 'Plan', 'older'); + const next = commands.updateNote('note-1', 'Plan', 'newer'); + + await expect(failed).rejects.toThrow('Note not found'); + await expect(next).resolves.toEqual({ id: 'note-1' }); + }); + + it('does not hold a write back for another note, branch or session', async () => { + const { commands, sent } = await load(); + + void commands.updateNote('note-1', 'Plan', 'one'); + void commands.updateNote('note-2', 'Plan', 'two'); + void commands.setNoteRead('note-3', true); + void commands.createNote('branch-1', 'a.md', 'A'); + void commands.createNote('branch-2', 'a.md', 'A'); + void commands.queueSessionMessage('session-1', 'first'); + void commands.queueSessionMessage('session-2', 'first'); + void commands.markSessionChatRead('session-1'); + void commands.updateRepoBadge('acme/app', '', 'AP', 10); + void commands.updateRepoBadge('acme/app', 'web', 'WB', 10); + void commands.setProjectNoteRead('pnote-1', true); + void commands.setProjectNoteRead('pnote-2', true); + void commands.reorderPinnedRepos([]); + void commands.setProjectTagAssigned('project-1', 'tag-1', true); + await flush(); + + expect(sent).toHaveLength(14); + }); + + describe('when the write ahead never settles', () => { + beforeEach(() => { + // Only the timers the wait bound uses, so module loading is untouched. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + async function loadFor(transport: 'tauri' | 'web') { + vi.resetModules(); + const sent: Sent[] = []; + const invokeCommand = vi.fn( + (command: string, args: Record) => + new Promise((resolve) => { + sent.push({ command, args, settle: resolve }); + }) + ); + vi.doMock('./transport', () => ({ invokeCommand, isTauri: transport === 'tauri' })); + const commands = await import('./commands'); + return { commands, sent }; + } + + it('over HTTP, sends the next write for the key once the wait bound runs out', async () => { + const { commands, sent } = await loadFor('web'); + + // A request on a dropped connection: `fetch` has no timeout. + const hung = commands.updateNote('note-1', 'Plan', 'older'); + void commands.updateNote('note-1', 'Plan', 'newer'); + await vi.advanceTimersByTimeAsync(commands.WEB_WRITE_ORDER_MAX_WAIT_MS - 1); + expect(sent.map((call) => call.args.content)).toEqual(['older']); + + await vi.advanceTimersByTimeAsync(1); + expect(sent.map((call) => call.args.content)).toEqual(['older', 'newer']); + + // The hung request was not failed or cancelled: it can still answer. + sent[0].settle({ id: 'note-1' }); + await expect(hung).resolves.toEqual({ id: 'note-1' }); + }); + + it('over HTTP, keeps the writes queued behind a hung one in the order they were sent', async () => { + const { commands, sent } = await loadFor('web'); + + void commands.updateNote('note-1', 'Plan', 'hung'); + void commands.updateNote('note-1', 'Plan', 'second'); + void commands.updateNote('note-1', 'Plan', 'third'); + await vi.advanceTimersByTimeAsync(commands.WEB_WRITE_ORDER_MAX_WAIT_MS); + // The bound ran out for both at the same moment, but only the write + // directly behind the hung one is sent beside it. + expect(sent.map((call) => call.args.content)).toEqual(['hung', 'second']); + + sent[1].settle({}); + await vi.advanceTimersByTimeAsync(0); + expect(sent.map((call) => call.args.content)).toEqual(['hung', 'second', 'third']); + }); + + it('under Tauri, keeps the next write back for as long as the one ahead is out', async () => { + const { commands, sent } = await loadFor('tauri'); + + void commands.updateNote('note-1', 'Plan', 'older'); + void commands.updateNote('note-1', 'Plan', 'newer'); + await vi.advanceTimersByTimeAsync(100 * commands.WEB_WRITE_ORDER_MAX_WAIT_MS); + expect(sent.map((call) => call.args.content)).toEqual(['older']); + + sent[0].settle({}); + await vi.advanceTimersByTimeAsync(0); + expect(sent.map((call) => call.args.content)).toEqual(['older', 'newer']); + }); + }); +}); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index f8e5ada70..9ee0a21cb 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -16,6 +16,7 @@ import { readCachedEntries, } from './cache'; import { readSnapshot, writeSnapshot, clearSnapshot, SNAPSHOT_KEYS } from './shared/webSnapshot'; +import { SendOrder } from './shared/sendOrder'; import type { AcpConfigSelection, ActiveSessionInfo, @@ -77,6 +78,50 @@ import type { export type DiffScope = 'branch' | 'commit' | 'worktree'; +/** + * How long an ordered write waits, over HTTP, for the one sent before it for + * the same key. The ordered writes are single store writes that take + * milliseconds, and `delete_note`, which can take a few seconds. Well past + * those, so only a request that has hung is given up on, and it is only + * given up waiting for: the request itself is left to finish. The writes + * queued behind it are then sent one after another as before, not all at + * once. + */ +export const WEB_WRITE_ORDER_MAX_WAIT_MS = 30_000; + +/** + * Keeps the writes the frontend sends without awaiting in the order it sent + * them. `update_note`, `create_note`, `set_note_read`, + * `mark_session_chat_read` and `queue_session_message` were synchronous + * commands, which ran one at a time in arrival order; they now run on the + * backend's thread pool, like every command over HTTP, where two in flight + * land in either order. Each wrapper sends through here under the key of + * what it writes to, so a write waits for the one sent before it for the same + * note, branch or session and for nothing else. `delete_note` shares its + * note's key, so it cannot overtake a write sent before it. + * + * The rest of the synchronous writes followed them onto the pool. The ones a + * call site can have two of in flight, or sends without awaiting, are ordered + * the same way: a repo badge's saves and its delete (`repo-badge:`), pinned + * repo reorders (`pinned-repos`), a project note's read marks and its delete + * (`project-note:`), and every tag write (`tags`). + * + * Only these wrappers are ordered. A command sent through another wrapper + * while one of these is in flight is not held back for it. + * + * Under Tauri a write waits for the one ahead of it however long that takes. + * Over HTTP it waits at most {@link WEB_WRITE_ORDER_MAX_WAIT_MS}: a request + * on a dropped connection can stay pending until the page is reloaded, and + * would otherwise stop every later write for its key (every tag write, for + * `tags`). See {@link SendOrder}. + */ +const writeOrder = new SendOrder(isTauri ? {} : { maxWaitMs: WEB_WRITE_ORDER_MAX_WAIT_MS }); + +/** A branch note's saves and read marks share one order. */ +function noteWriteKey(noteId: string): string { + return `note:${noteId}`; +} + export interface WorktreeChangesPreview { revertPaths: string[]; removePaths: string[]; @@ -464,8 +509,14 @@ export function getProjectNoteBySession( return invokeCommand('get_project_note_by_session', { sessionId }); } +/** + * Sent behind the note's read marks (see {@link writeOrder}), so a mark still + * in flight cannot land after the delete and fail on the missing note. + */ export function deleteProjectNote(noteId: string): Promise { - return invokeCommand('delete_project_note', { noteId }); + return writeOrder.run(`project-note:${noteId}`, () => + invokeCommand('delete_project_note', { noteId }) + ); } /** @@ -481,17 +532,29 @@ export function getNote(noteId: string): Promise { - return invokeCommand('set_note_read', { noteId, read }); + return writeOrder.run(noteWriteKey(noteId), () => + invokeCommand('set_note_read', { noteId, read }) + ); } -/** The project-note counterpart of {@link setNoteRead}. */ +/** + * The project-note counterpart of {@link setNoteRead}, ordered the same way + * (see {@link writeOrder}): "Mark as unread" from the row menu is not awaited, + * and the viewer's own read mark can be sent right behind it. + */ export function setProjectNoteRead( projectNoteId: string, read: boolean ): Promise { - return invokeCommand('set_project_note_read', { projectNoteId, read }); + return writeOrder.run(`project-note:${projectNoteId}`, () => + invokeCommand('set_project_note_read', { projectNoteId, read }) + ); } /** @@ -1464,9 +1527,16 @@ export function countAssistantMessagesAfter( return invokeCommand('count_assistant_messages_after', { sessionId, afterTimestamp }); } -/** Record that the user has read the session's chat, clearing its note row's unread dot. */ +/** + * Record that the user has read the session's chat, clearing its note row's + * unread dot. The note viewer sends one when the chat opens and one when it + * closes, awaiting neither; they are sent in that order (see + * {@link writeOrder}). + */ export function markSessionChatRead(sessionId: string): Promise { - return invokeCommand('mark_session_chat_read', { sessionId }); + return writeOrder.run(`chat-read:${sessionId}`, () => + invokeCommand('mark_session_chat_read', { sessionId }) + ); } /** Create a session and immediately start the agent. */ @@ -1510,18 +1580,27 @@ export function resumeSession( }); } +/** + * Queue a follow-up behind a running session's turn. The queue drains in the + * order its rows were stamped, and the composer accepts the next message + * while this one is still being queued, so a session's messages are sent one + * at a time (see {@link writeOrder}): the agent gets them in the order they + * were typed. + */ export function queueSessionMessage( sessionId: string, content: string, imageIds?: string[], branchId?: string | null ): Promise { - return invokeCommand('queue_session_message', { - sessionId, - content, - imageIds: imageIds ?? null, - branchId: branchId ?? null, - }); + return writeOrder.run(`queue:${sessionId}`, () => + invokeCommand('queue_session_message', { + sessionId, + content, + imageIds: imageIds ?? null, + branchId: branchId ?? null, + }) + ); } export function listQueuedSessionMessages(sessionId: string): Promise { @@ -1709,6 +1788,14 @@ export function startQueuedSessionNow(branchId: string, sessionId: string): Prom * * `subtype` is `'written'` when the user authored the note in the editor * dialog; the drag-drop and save-action-output paths leave it unset. + * + * A branch's creates are sent one at a time (see {@link writeOrder}), in the + * order this is called in. A drop of several files creates its notes without + * awaiting each, and the order they land in is the order the timeline lists + * them in and the order same-titled ones get their ` (2)`, ` (3)` suffixes + * in. Keeping the call order is all this does: a caller that reads each file + * before creating its note has to finish the reads first, or the reads + * decide the order (see `addTextFileNotes` in BranchCard). */ export function createNote( branchId: string, @@ -1716,7 +1803,9 @@ export function createNote( content: string, subtype: import('./types').NoteSubtype = null ): Promise { - return invokeCommand('create_note', { branchId, title, content, subtype }); + return writeOrder.run(`branch-notes:${branchId}`, () => + invokeCommand('create_note', { branchId, title, content, subtype }) + ); } /** @@ -1726,18 +1815,33 @@ export function createNote( * Like the other note mutations this publishes `notes-changed`, so other views * refresh on their own; the calling card still invalidates locally so its own * timeline updates without waiting for the round trip. + * + * A note's saves are sent in order (see {@link writeOrder}): the editor's + * save before a transfer does not hold its Save button, so a second save can + * be sent while the first is in flight, and the older one must not land last. */ export function updateNote( noteId: string, title: string, content: string ): Promise { - return invokeCommand('update_note', { noteId, title, content }); + return writeOrder.run(noteWriteKey(noteId), () => + invokeCommand('update_note', { noteId, title, content }) + ); } -/** Delete a note and optionally its linked session. */ +/** + * Delete a note and optionally its linked session. + * + * Sent after the note's saves and read marks still in flight (see + * {@link writeOrder}). The timeline's "Mark as unread" is not awaited, and + * landing after a delete sent behind it would fail on the missing note and + * toast an error for a note the user had just deleted. + */ export function deleteNote(noteId: string, deleteSession = true): Promise { - return invokeCommand('delete_note', { noteId, deleteSession }); + return writeOrder.run(noteWriteKey(noteId), () => + invokeCommand('delete_note', { noteId, deleteSession }) + ); } /** @@ -2589,18 +2693,37 @@ export function ensureRepoBadges( return invokeCommand('ensure_repo_badges', { repos, provider: provider ?? null }); } -/** Update the short name and hue of an existing repo badge. */ +/** A repo badge's saves and its delete share one order. */ +function repoBadgeWriteKey(githubRepo: string, subpath: string): string { + return `repo-badge:${githubRepo}\u0000${subpath}`; +} + +/** + * Update the short name and hue of an existing repo badge. + * + * A badge's saves are sent in order (see {@link writeOrder}): the settings + * panel saves on every keystroke without awaiting, each save carries the + * whole name and hue, and the last one to land stays. + */ export function updateRepoBadge( githubRepo: string, subpath: string, shortName: string, hue: number ): Promise { - return invokeCommand('update_repo_badge', { githubRepo, subpath, shortName, hue }); + return writeOrder.run(repoBadgeWriteKey(githubRepo, subpath), () => + invokeCommand('update_repo_badge', { githubRepo, subpath, shortName, hue }) + ); } +/** + * Sent behind the badge's saves (see {@link writeOrder}), so a save still in + * flight cannot land after the delete and fail on the missing badge. + */ export function deleteRepoBadge(githubRepo: string, subpath: string): Promise { - return invokeCommand('delete_repo_badge', { githubRepo, subpath }); + return writeOrder.run(repoBadgeWriteKey(githubRepo, subpath), () => + invokeCommand('delete_repo_badge', { githubRepo, subpath }) + ); } // ============================================================================= @@ -2611,6 +2734,18 @@ export function deleteRepoBadge(githubRepo: string, subpath: string): Promise(command: string, args: Record): Promise { + return writeOrder.run('tags', () => invokeCommand(command, args)); +} + /** The tag catalog and every project's tags. */ export function getTagState(options: ForceRefreshOptions = {}): Promise> { return cachedCommand('get_tag_state', undefined, { @@ -2621,36 +2756,36 @@ export function getTagState(options: ForceRefreshOptions = {}): Promise { - const group = await invokeCommand('create_tag_group', { name, tags }); + const group = await tagWrite('create_tag_group', { name, tags }); await invalidateCache('get_tag_state'); return group; } export async function renameTagGroup(groupId: string, name: string): Promise { - await invokeCommand('rename_tag_group', { groupId, name }); + await tagWrite('rename_tag_group', { groupId, name }); await invalidateCache('get_tag_state'); } /** Make a group the icon group, or turn the icon group off with null. */ export async function setIconTagGroup(groupId: string | null): Promise { - await invokeCommand('set_icon_tag_group', { groupId }); + await tagWrite('set_icon_tag_group', { groupId }); await invalidateCache('get_tag_state'); } /** Pin a group's board to the projects sidebar, or unpin it. */ export async function setTagGroupSidebar(groupId: string, shown: boolean): Promise { - await invokeCommand('set_tag_group_sidebar', { groupId, shown }); + await tagWrite('set_tag_group_sidebar', { groupId, shown }); await invalidateCache('get_tag_state'); } /** Reorder groups; `groupIds` must name every group exactly once. */ export async function reorderTagGroups(groupIds: string[]): Promise { - await invokeCommand('reorder_tag_groups', { groupIds }); + await tagWrite('reorder_tag_groups', { groupIds }); await invalidateCache('get_tag_state'); } export async function deleteTagGroup(groupId: string, mode: DeleteTagGroupMode): Promise { - await invokeCommand('delete_tag_group', { groupId, mode }); + await tagWrite('delete_tag_group', { groupId, mode }); await invalidateCache('get_tag_state'); } @@ -2666,7 +2801,7 @@ export async function createTag(input: { icon?: string | null; assignProjectId?: string | null; }): Promise { - const tag = await invokeCommand('create_tag', { + const tag = await tagWrite('create_tag', { name: input.name, groupId: input.groupId ?? null, color: input.color ?? null, @@ -2682,18 +2817,18 @@ export async function updateTag( tagId: string, value: { name: string; color: TagColor; icon: string | null } ): Promise { - await invokeCommand('update_tag', { tagId, ...value }); + await tagWrite('update_tag', { tagId, ...value }); await invalidateCache('get_tag_state'); } /** Move a tag to `index` within a group (or ungrouped, with null). */ export async function moveTag(tagId: string, groupId: string | null, index: number): Promise { - await invokeCommand('move_tag', { tagId, groupId, index }); + await tagWrite('move_tag', { tagId, groupId, index }); await invalidateCache('get_tag_state'); } export async function deleteTag(tagId: string): Promise { - await invokeCommand('delete_tag', { tagId }); + await tagWrite('delete_tag', { tagId }); await invalidateCache('get_tag_state'); } @@ -2703,7 +2838,7 @@ export async function setProjectGroupTag( groupId: string, tagId: string | null ): Promise { - await invokeCommand('set_project_group_tag', { projectId, groupId, tagId }); + await tagWrite('set_project_group_tag', { projectId, groupId, tagId }); await invalidateCache('get_tag_state'); } @@ -2713,7 +2848,7 @@ export async function setProjectTagAssigned( tagId: string, assigned: boolean ): Promise { - await invokeCommand('set_project_tag_assigned', { projectId, tagId, assigned }); + await tagWrite('set_project_tag_assigned', { projectId, tagId, assigned }); await invalidateCache('get_tag_state'); } @@ -2731,9 +2866,16 @@ export function unpinRepo(githubRepo: string, subpath: string): Promise { return invokeCommand('unpin_repo', { githubRepo, subpath }); } -/** Bulk-update pin sort order from an ordered list of [githubRepo, subpath] keys. */ +/** + * Bulk-update pin sort order from an ordered list of [githubRepo, subpath] keys. + * + * Reorders are sent one at a time (see {@link writeOrder}): each carries the + * whole order, so of two drops in a row the first must not land last. + */ export function reorderPinnedRepos(orderedKeys: [string, string][]): Promise { - return invokeCommand('reorder_pinned_repos', { orderedKeys }); + return writeOrder.run('pinned-repos', () => + invokeCommand('reorder_pinned_repos', { orderedKeys }) + ); } /** List all repo badges ordered: pinned first by sort order, then unpinned by project count. */ diff --git a/apps/staged/src/lib/features/actions/actionRunner.svelte.ts b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts index bbf801474..73f6a2693 100644 --- a/apps/staged/src/lib/features/actions/actionRunner.svelte.ts +++ b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts @@ -31,6 +31,7 @@ import { } from './actions'; import { onBranchActionStatus, onBranchRunPhaseChanged } from '../../services/branchEventService'; import { getPinnedActions, getSecondaryRunningActions, groupActionsByType } from './actionGroups'; +import { LatestWins } from '../../shared/latestWins'; export type RunningAction = { executionId: string; @@ -83,6 +84,12 @@ export class ActionRunner { private load: () => Promise = undefined!; private run: (actionId: string) => Promise = undefined!; private loadRunning: (() => Promise) | undefined; + /** + * Action lists are read concurrently on the backend, so a reload sent after + * a change can answer before the read sent ahead of it. The later-sent + * answer stays. + */ + private loadOrder = new LatestWins<'actions'>(); actions = $state([]); runningActions = $state([]); @@ -228,12 +235,14 @@ export class ActionRunner { * affordance would otherwise show it after a transient IPC error. */ async loadActions(): Promise { + const ticket = this.loadOrder.issue(); try { - this.actions = await this.load(); + const actions = await this.load(); + if (this.loadOrder.admit('actions', ticket)) this.actions = actions; return true; } catch (e) { console.error('Failed to load actions:', e); - this.actions = []; + if (this.loadOrder.admit('actions', ticket)) this.actions = []; return false; } } @@ -244,6 +253,8 @@ export class ActionRunner { * them, so the Detect Actions flow needs no second read. */ setActions(actions: ProjectAction[]): void { + // Newer than any read still in flight, which must not replace it. + this.loadOrder.wrote('actions'); this.actions = actions; } diff --git a/apps/staged/src/lib/features/actions/actionRunner.test.ts b/apps/staged/src/lib/features/actions/actionRunner.test.ts index ab00a6a11..6dc2029f8 100644 --- a/apps/staged/src/lib/features/actions/actionRunner.test.ts +++ b/apps/staged/src/lib/features/actions/actionRunner.test.ts @@ -138,6 +138,39 @@ describe('ActionRunner', () => { } }); + it('keeps the list from the reload sent last when an earlier read answers after it', async () => { + const pending: Array<(actions: unknown[]) => void> = []; + const runner = await newRunner({ + loadActions: () => new Promise((resolve) => pending.push(resolve)), + }); + const stale = { id: 'action-old', name: 'old', actionType: 'run' }; + const fresh = { id: 'action-new', name: 'new', actionType: 'run' }; + + const mountRead = runner.loadActions(); + const reload = runner.loadActions(); + pending[1]([fresh]); + await reload; + pending[0]([stale]); + await mountRead; + + expect(runner.actions).toEqual([fresh]); + }); + + it('keeps an adopted list when a read sent before it answers afterwards', async () => { + const pending: Array<(actions: unknown[]) => void> = []; + const runner = await newRunner({ + loadActions: () => new Promise((resolve) => pending.push(resolve)), + }); + const detected = { id: 'action-detected', name: 'detected', actionType: 'run' }; + + const mountRead = runner.loadActions(); + runner.setActions([detected] as never); + pending[0]([]); + await mountRead; + + expect(runner.actions).toEqual([detected]); + }); + it('hands each pinned button its own execution, and none to an idle one', async () => { const runner = await newRunner({}); runner.runningActions.push( diff --git a/apps/staged/src/lib/features/branches/BranchCard.component.test.ts b/apps/staged/src/lib/features/branches/BranchCard.component.test.ts index 1c5baf135..e6f038dbe 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.component.test.ts +++ b/apps/staged/src/lib/features/branches/BranchCard.component.test.ts @@ -266,6 +266,80 @@ describe('BranchCard file drops', () => { expect(cmd.createNote).toHaveBeenCalledWith('branch-1', 'Dropped', 'body'); expect(cmd.createImage).toHaveBeenCalledWith('branch-1', 'project-1', '/tmp/b.png'); }); + + it('creates the notes in drop order when the files are read out of order', async () => { + // The backend reads the files concurrently: the big one dropped first + // answers last. + const reads = new Map void>(); + cmd.readTextFile.mockImplementation( + (filePath) => new Promise((resolve) => reads.set(filePath, resolve)) + ); + mountCard(remoteBranch('stopped')); + + drop(['/tmp/big.md', '/tmp/small.md', '/tmp/tiny.md']); + await vi.waitFor(() => expect(reads.size).toBe(3)); + reads.get('/tmp/tiny.md')!('tiny body'); + reads.get('/tmp/small.md')!('small body'); + // Give a note created as soon as its own read answered the time to be. + await new Promise((resolve) => setTimeout(resolve, 0)); + reads.get('/tmp/big.md')!('big body'); + await settle(() => expect(cmd.createNote).toHaveResolvedTimes(3)); + + expect(cmd.createNote.mock.calls.map(([, title]) => title)).toEqual(['big', 'small', 'tiny']); + }); + + it('stores the rest in drop order, and reports the one file that could not be read', async () => { + const reads = new Map< + string, + { answer: (content: string) => void; fail: (reason: Error) => void } + >(); + cmd.readTextFile.mockImplementation( + (filePath) => new Promise((answer, fail) => reads.set(filePath, { answer, fail })) + ); + mountCard(remoteBranch('running')); + await timelineShown(); + // Hold the reload after the stores open, so the stored notes keep their + // "adding..." rows and the failed file's row can be told from them. + let landReload!: () => void; + cmd.getBranchTimeline.mockImplementationOnce( + () => new Promise((resolve) => (landReload = () => resolve(emptyTimeline))) + ); + + drop(['/tmp/first.md', '/tmp/bad.md', '/tmp/third.md', '/tmp/fourth.md']); + await vi.waitFor(() => expect(reads.size).toBe(4)); + expect(pendingRows()).toEqual(['first', 'bad', 'third', 'fourth']); + + // The reads come back out of order, the failure among them, and the + // first file's last. Until it is back nothing is created or reported. + reads.get('/tmp/fourth.md')!.answer('fourth body'); + reads.get('/tmp/bad.md')!.fail(new Error('unreadable')); + reads.get('/tmp/third.md')!.answer('third body'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cmd.createNote).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + expect(pendingRows()).toEqual(['first', 'bad', 'third', 'fourth']); + + reads.get('/tmp/first.md')!.answer('first body'); + await vi.waitFor(() => expect(cmd.getBranchTimeline).toHaveBeenCalledTimes(2)); + + // The failed read skipped its own file only. + expect(cmd.createNote.mock.calls.map(([, title]) => title)).toEqual([ + 'first', + 'third', + 'fourth', + ]); + expect(cmd.createNote).toHaveResolvedTimes(3); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledWith('Error', { + description: 'Could not read "bad" \u2014 unreadable', + }); + // Its row is gone as it fails; the stored notes keep theirs for the reload. + expect(pendingRows()).toEqual(['first', 'third', 'fourth']); + + landReload(); + await settle(() => expect(cmd.getBranchTimeline).toHaveResolvedTimes(2)); + expect(toast.error).toHaveBeenCalledTimes(1); + }); }); describe('BranchCard while it is being deleted', () => { diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 8dc43b94e..66b04ca01 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -41,6 +41,7 @@ import Spinner from '../../shared/Spinner.svelte'; import { isSessionActive } from '../../shared/sessionStatus'; import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; + import { LatestWins } from '../../shared/latestWins'; import { subscribeDragDrop } from './dragDrop'; import { rebaseInFlight } from './rebaseInFlight'; import type { @@ -230,6 +231,9 @@ : [] ); let projectNoteHashtagItems = $state([]); + // The two project-note fetches below run concurrently in the backend, so an + // older list can land after a newer one; only the latest sent is applied. + const projectNoteFetchOrder = new LatestWins(); $effect(() => { const projectId = branch.projectId; if (!projectId) { @@ -237,8 +241,9 @@ return; } let stale = false; + const ticket = projectNoteFetchOrder.issue(); commands.listProjectNotes(projectId).then((notes) => { - if (stale) return; + if (stale || !projectNoteFetchOrder.admit(projectId, ticket)) return; projectNoteHashtagItems = projectNotesToHashtagItems(notes); }); return () => { @@ -947,7 +952,9 @@ const projectId = branch.projectId; if (!projectId) return; const handler = () => { + const ticket = projectNoteFetchOrder.issue(); commands.listProjectNotes(projectId).then((notes) => { + if (!projectNoteFetchOrder.admit(projectId, ticket)) return; projectNoteHashtagItems = projectNotesToHashtagItems(notes); }); }; @@ -1865,15 +1872,33 @@ })); pendingAdds = [...pendingAdds, ...placeholders]; + // Every file is read before any note is created. The reads run + // concurrently on the backend and answer in any order, and `createNote` + // keeps the order it is called in (see `writeOrder` in commands.ts). So a + // note created as soon as its own file had been read would be created in + // the order the reads answered: a small file dropped after a large one + // would be listed first, and take the plain title two same-named files + // compete for. + const reads = await Promise.all( + textPaths.map((filePath) => + commands.readTextFile(filePath).then( + (content) => ({ ok: true as const, content }), + (error: unknown) => ({ ok: false as const, error }) + ) + ) + ); const stored = await Promise.all( - textPaths.map(async (filePath, i) => { + // Each callback runs up to its `createNote` call before the next one + // starts, so the creates are called in drop order. + reads.map(async (read, i) => { + const filePath = textPaths[i]; try { - const content = await commands.readTextFile(filePath); + if (!read.ok) throw read.error; // Through the same split the editor saves through, so a dropped file // is stored like any other note: its own leading H1 becomes the // title (and leaves the body, which the viewer would otherwise show // straight under the file name), and the file name titles the rest. - const note = splitNoteMarkdown(content, fileNameFromPath(filePath)); + const note = splitNoteMarkdown(read.content, fileNameFromPath(filePath)); await commands.createNote(branch.id, note.title, note.body); return true; } catch (e) { diff --git a/apps/staged/src/lib/features/notes/noteReadActions.test.ts b/apps/staged/src/lib/features/notes/noteReadActions.test.ts index 3acbd9044..edd1b327c 100644 --- a/apps/staged/src/lib/features/notes/noteReadActions.test.ts +++ b/apps/staged/src/lib/features/notes/noteReadActions.test.ts @@ -231,6 +231,59 @@ describe('setNoteReadState', () => { warn.mockRestore(); }); + describe('while the note’s project is being looked up', () => { + /** Mark a note unread whose branch no view has loaded, holding the lookup open. */ + async function markUnreadHoldingLookup() { + noteBranches.set('note-1', 'elsewhere-branch'); + let answerLookup!: (branch: Branch | null) => void; + commands.getBranch.mockImplementationOnce( + () => new Promise((resolve) => (answerLookup = resolve)) + ); + const marked = setNoteReadState({ kind: 'branch', noteId: 'note-1' }, false); + await flush(); + expect(commands.getBranch).toHaveBeenCalledTimes(1); + return { + marked, + answerLookup: () => answerLookup({ projectId: 'project-elsewhere' } as Branch), + }; + } + + it('does not flag the project of a note that was read in the meantime', async () => { + const { marked, answerLookup } = await markUnreadHoldingLookup(); + + // The viewer opens the note: nothing is flagged yet for it to release. + await writeNoteRead('branch', 'note-1', true); + answerLookup(); + await marked; + + expect(projectStateStore.isUnread('project-elsewhere')).toBe(false); + }); + + it('does not flag the project of a note that was deleted in the meantime', async () => { + const { marked, answerLookup } = await markUnreadHoldingLookup(); + + forgetDeletedNote('branch', 'note-1'); + answerLookup(); + await marked; + + expect(projectStateStore.isUnread('project-elsewhere')).toBe(false); + }); + + it('still flags it when the note is marked unread again in the meantime', async () => { + const { marked, answerLookup } = await markUnreadHoldingLookup(); + + commands.getBranch.mockResolvedValue({ projectId: 'project-elsewhere' } as Branch); + await setNoteReadState({ kind: 'branch', noteId: 'note-1' }, false); + answerLookup(); + await marked; + + expect(projectStateStore.isUnread('project-elsewhere')).toBe(true); + // One hold, released by one read. + await writeNoteRead('branch', 'note-1', true); + expect(projectStateStore.isUnread('project-elsewhere')).toBe(false); + }); + }); + it('clears the project flag when the note is marked read again', async () => { await setBranchNote('note-1', 'project-1', false); diff --git a/apps/staged/src/lib/features/notes/noteReadActions.ts b/apps/staged/src/lib/features/notes/noteReadActions.ts index 25de9fe4f..dc4b289b8 100644 --- a/apps/staged/src/lib/features/notes/noteReadActions.ts +++ b/apps/staged/src/lib/features/notes/noteReadActions.ts @@ -32,21 +32,45 @@ function noteKey(kind: NoteKind, noteId: string): string { return `${kind}:${noteId}`; } +/** + * "Mark as unread" flags the note's project only after it has found which + * project that is, and for a branch note no view has loaded that is a backend + * lookup. Until it answers the flag is not set, so a read mark or a delete + * landing in that time finds nothing to release, and the flag set afterwards + * would stay on a note that is read, or gone. Each such flag is held here, by + * note, as a claim taken when its unread write lands. Any later write or + * delete of the note takes the claim away, and a flag whose claim is gone is + * not set. + */ +const unreadFlagClaims = new Map(); + /** * Write a note's read state. Every way a note gets read goes through here — the * viewer showing it, the editor opening it, "Mark as read" — so marking it read * also releases the project unread flag its "Mark as unread" set. */ -export async function writeNoteRead( +export function writeNoteRead(kind: NoteKind, noteId: string, read: boolean): Promise { + return landNoteRead(kind, noteId, read, null); +} + +/** + * {@link writeNoteRead}, leaving `claim` as the note's unread flag claim once + * the write has landed (or none, for null). In the same step as the landing, + * so no other write's landing can come between the two. + */ +async function landNoteRead( kind: NoteKind, noteId: string, - read: boolean + read: boolean, + claim: symbol | null ): Promise { const row = await (kind === 'project' ? commands.setProjectNoteRead(noteId, read) : commands.setNoteRead(noteId, read)); + const key = noteKey(kind, noteId); + if (claim) unreadFlagClaims.set(key, claim); + else unreadFlagClaims.delete(key); if (read) { - const key = noteKey(kind, noteId); for (const projectId of projectStateStore.projectsFlaggedByNote(key)) { if (!projectsDataStore.isProjectDeleting(projectId)) { projectStateStore.releaseNoteUnread(projectId, key); @@ -62,7 +86,9 @@ export async function writeNoteRead( * so unlike a read this needn't skip one. */ export function forgetDeletedNote(kind: NoteKind, noteId: string): void { - projectStateStore.forgetNote(noteKey(kind, noteId)); + const key = noteKey(kind, noteId); + unreadFlagClaims.delete(key); + projectStateStore.forgetNote(key); } /** @@ -91,19 +117,25 @@ export async function setNoteReadState( target: NoteReadTarget, read: boolean ): Promise { + const key = noteKey(target.kind, target.noteId); + const claim = read ? null : Symbol(key); let row: NoteReadRow; try { - row = await writeNoteRead(target.kind, target.noteId, read); + row = await landNoteRead(target.kind, target.noteId, read, claim); } catch (e) { toast.error(read ? 'Unable to mark note as read' : 'Unable to mark note as unread', { description: e instanceof Error ? e.message : String(e), }); return null; } - if (!read) { + if (claim) { const projectId = await owningProjectId(row); + // The note was read, deleted or marked again while its project was being + // looked up (see unreadFlagClaims): that write decides the flag. + if (unreadFlagClaims.get(key) !== claim) return row; + unreadFlagClaims.delete(key); if (projectId && !projectsDataStore.isProjectDeleting(projectId)) { - projectStateStore.markUnreadForNote(projectId, noteKey(target.kind, target.noteId)); + projectStateStore.markUnreadForNote(projectId, key); } } return row; diff --git a/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts b/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts index 357570f75..c86ffdfe1 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts +++ b/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts @@ -584,6 +584,33 @@ describe('ProjectSection note rows', () => { unmount(section); }); + it('does not put a deleted note back when a list read before the delete lands after it', async () => { + const section = mountSection(); + await settle(); + + // A list load is in flight, and read the note before it was deleted. + let answerList!: (notes: ProjectNote[]) => void; + commandMocks.listProjectNotes.mockImplementationOnce( + () => new Promise((resolve) => (answerList = resolve)) + ); + window.dispatchEvent(new CustomEvent('project-notes-invalidated')); + await settle(); + expect(commandMocks.listProjectNotes).toHaveBeenCalledTimes(2); + + listNotes(); + clickDelete('session-1', { altKey: true }); + await settle(); + expect(document.querySelector('[data-testid="timeline-row"]')).toBeNull(); + + answerList([completedNote]); + await settle(); + expect(document.querySelector('[data-testid="timeline-row"]')).toBeNull(); + // The list is read again rather than dropped, for whatever else it held. + expect(commandMocks.listProjectNotes).toHaveBeenCalledTimes(3); + + unmount(section); + }); + it('keeps the note when the confirmation is cancelled', async () => { const section = mountSection(); await settle(); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 836e50b42..8b3a5d8b4 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -27,6 +27,7 @@ import BranchCard from '../branches/BranchCard.svelte'; import { isSessionActive } from '../../shared/sessionStatus'; import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; + import { LatestWins } from '../../shared/latestWins'; import ConfirmDeleteDialog, { type ConfirmDeleteRequest, } from '../../shared/ConfirmDeleteDialog.svelte'; @@ -427,16 +428,31 @@ let resumingNoteIds = $state>(new Set()); let confirmDelete = $state(null); /** - * Per-session latest note refetch, numbered from one counter that never - * resets: an older fetch landing late is dropped, and clearing a landed - * fetch's entry can't let a later fetch inherit its number. + * Orders the note fetches by when they were sent. The list load and the + * per-session row refetch run concurrently in the backend, so either can + * land after a fetch sent later than it. A row removed locally once its + * delete has landed is recorded under `row` too. + */ + const noteFetchOrder = new LatestWins<'list' | 'row'>(); + /** + * Per-session latest note refetch, numbered by `noteFetchOrder`, whose + * counter never resets: an older fetch landing late is dropped, and + * clearing a landed fetch's entry can't let a later fetch inherit its + * number. */ - let noteRefreshSeq = 0; const latestNoteRefreshes = new Map(); - async function loadProjectNotes() { + async function loadProjectNotes(): Promise { + const ticket = noteFetchOrder.issue(); try { - projectNotes = await commands.listProjectNotes(project.id); + const notes = await commands.listProjectNotes(project.id); + // A list sent after this one has already landed: this one is older. + if (!noteFetchOrder.admit('list', ticket)) return; + // A row refetch sent after this list landed first, so the list may + // predate what that refetch read. Applying it would put the old row + // back, and dropping it would lose its other rows: read the list again. + if (noteFetchOrder.overtaken('row', ticket)) return loadProjectNotes(); + projectNotes = notes; } catch (e) { console.error('[ProjectSection] Failed to load project notes:', e); } @@ -444,7 +460,7 @@ /** Surgically refetch the note a session writes instead of reloading them all. */ async function refreshNoteForSession(sessionId: string) { - const seq = ++noteRefreshSeq; + const seq = noteFetchOrder.issue(); latestNoteRefreshes.set(sessionId, seq); let updatedNote: ProjectNote | null; let superseded = false; @@ -461,6 +477,9 @@ if (!superseded) latestNoteRefreshes.delete(sessionId); } if (superseded) return; + // A list load sent after this refetch has landed, and read the row later. + if (noteFetchOrder.overtaken('list', seq)) return; + noteFetchOrder.admit('row', seq); if (updatedNote) { projectNotes = projectNotes.map((n) => (n.id === updatedNote.id ? updatedNote : n)); @@ -490,6 +509,10 @@ forgetDeletedNote('project', noteId); // Optimistic removal; the delete's notes-changed event re-fetches every // window's project-note surfaces (including this one) to confirm. + // A list load still in flight was sent before the delete landed and + // may have read the note. Counting the removal as a row landing makes + // that load read the list again instead of putting the row back. + noteFetchOrder.wrote('row'); projectNotes = projectNotes.filter((n) => n.id !== noteId); hashtagVersion++; } catch (e) { diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index de290ab80..f4fb72c80 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -196,6 +196,7 @@ import { viewport } from '../../shared/viewport.svelte'; import { autofocusOnOpen } from '../../shared/focusAfterDialogEnter'; import { observeSizeChanges } from '../../shared/observeSizeChanges'; + import { LatestWins } from '../../shared/latestWins'; import { isImeKeydown } from '../../shared/imeKeydown'; import { displayRootKey, @@ -377,6 +378,13 @@ let composerView = $state>(); let pollTimer: ReturnType | null = null; let pollInFlight = false; + /** + * The follow-up queue is read by the load, by each poll and by a refresh + * after every status event and queue write, and the backend answers those + * concurrently. The read sent last stays: an earlier refresh answering after + * the one sent behind a delete would put the deleted message back. + */ + const queuedReadOrder = new LatestWins<'queue'>(); /** A terminal status arrived while a tick was in flight; poll once more * when that tick settles. */ let finalPollPending = false; @@ -1456,6 +1464,7 @@ statusLoadedForSessionId = null; const statusVersionBeforeFetch = statusEventVersion; const id = sessionId; + const queuedTicket = queuedReadOrder.issue(); try { // Only the newest page of the transcript; older pages arrive through // `loadOlderMessages` as the user scrolls up. @@ -1494,7 +1503,7 @@ const page = transcriptPageFromRows(msgsResult.data, TRANSCRIPT_PAGE_ROWS); messages = page.messages; hasOlderMessages = page.hasMore; - queuedMessages = queued; + if (queuedReadOrder.admit('queue', queuedTicket)) queuedMessages = queued; if (msgsResult.revalidating) { msgsResult.revalidating .then((fresh) => { @@ -1615,6 +1624,7 @@ // An empty transcript polls from 0, which returns every visible row, so // there is nothing older to page in: `hasOlderMessages` stays false. const request = sessionPollRequest(messages, acpMetadataMessages); + const queuedTicket = queuedReadOrder.issue(); const result = await pollSession(sessionId, request); if (closed) return 'skipped'; const s = result.session; @@ -1622,7 +1632,10 @@ if (s && !statusEventDuringFetch && JSON.stringify(s) !== JSON.stringify(session)) { session = s; } - if (JSON.stringify(result.queuedMessages) !== JSON.stringify(queuedMessages)) { + if ( + queuedReadOrder.admit('queue', queuedTicket) && + JSON.stringify(result.queuedMessages) !== JSON.stringify(queuedMessages) + ) { queuedMessages = result.queuedMessages; } refreshLiveDelivery(s?.status ?? session?.status); @@ -1710,8 +1723,13 @@ async function refreshQueuedMessages() { if (!sessionId || closed) return; + const id = sessionId; + const ticket = queuedReadOrder.issue(); try { - const queued = await listQueuedSessionMessages(sessionId); + const queued = await listQueuedSessionMessages(id); + // The pane may show another session by now; its own load reads the queue. + if (closed || sessionId !== id) return; + if (!queuedReadOrder.admit('queue', ticket)) return; if (JSON.stringify(queued) !== JSON.stringify(queuedMessages)) { queuedMessages = queued; } diff --git a/apps/staged/src/lib/features/sessions/testStubs/ButtonStub.svelte b/apps/staged/src/lib/features/sessions/testStubs/ButtonStub.svelte index 97d709a3b..934775a8f 100644 --- a/apps/staged/src/lib/features/sessions/testStubs/ButtonStub.svelte +++ b/apps/staged/src/lib/features/sessions/testStubs/ButtonStub.svelte @@ -1,20 +1,24 @@ - diff --git a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte index 5b955dab6..f150bcd6f 100644 --- a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte @@ -33,6 +33,8 @@ import { navigation } from '../layout/navigation.svelte'; import { consumeRepoSettingsTarget } from './repoSettingsTarget'; import { viewport, watchViewport } from '../../shared/viewport.svelte'; + import { LatestWins } from '../../shared/latestWins'; + import { RepoActionList } from './repoActionList.svelte'; type RepoAttachment = { projectId: string; @@ -63,8 +65,13 @@ let repoSearch = $state(''); let sidebarEl: HTMLElement | null = null; - let actions = $state([]); - let loadingActions = $state(false); + /** The selected repo's actions, ordered against reads and local writes. */ + const actionList = new RepoActionList( + () => selectedContext, + (githubRepo, subpath) => commands.listRepoActions(githubRepo, subpath) + ); + const actions = $derived(actionList.actions); + const loadingActions = $derived(actionList.loading); let detecting = $state(false); let deletingRepo = $state(false); let showDeleteAllConfirm = $state(false); @@ -254,11 +261,16 @@ const requestedKey = requested ? repoKey(requested.githubRepo, requested.subpath) : null; let revealRequested = false; + const ticket = loadOrder.issue(); loadingContexts = true; try { const nextContexts = await commands.listActionContexts(); - contexts = nextContexts; - await loadRepoAttachments(nextContexts); + // A load sent after this one has already landed with a fresher list. + // Keep that, and still honor this load's parked target against it. + if (loadOrder.admit('contexts', ticket)) { + contexts = nextContexts; + await loadRepoAttachments(nextContexts); + } // Honor the parked target now that the entry list is resolved. if (requestedKey && mergedEntries.some((e) => e.key === requestedKey)) { openEntry(requestedKey); @@ -285,23 +297,15 @@ sidebarEl?.querySelector('.context-item.selected')?.scrollIntoView({ block: 'nearest' }); } - async function loadActions() { - if (!selectedContext) { - actions = []; - return; - } - loadingActions = true; - try { - actions = await commands.listRepoActions( - selectedContext.githubRepo, - selectedContext.subpath ?? undefined - ); - } catch (e) { - console.error('Failed to load actions:', e); - actions = []; - } finally { - loadingActions = false; - } + /** + * The context reads run concurrently on the backend, so two sent in a row + * can answer in either order: the list sent last stays. The action list + * keeps its own order (see RepoActionList). + */ + const loadOrder = new LatestWins<'contexts'>(); + + function loadActions(): Promise { + return actionList.load(); } $effect(() => { @@ -335,7 +339,7 @@ // only adopt the list while we're still viewing the one we detected for; // the backend writes were scoped to the captured values regardless. if (selectedRepoKey === entryKey) { - actions = detected; + actionList.write(detected); } await loadContexts(); @@ -384,7 +388,7 @@ values.icon ); if (selectedRepoKey === entryKey) { - actions = [...actions, newAction]; + actionList.write([...actions, newAction]); } } else { const actionId = actionToEdit.id; @@ -399,17 +403,19 @@ values.icon ); if (selectedRepoKey === entryKey) { - actions = actions.map((a) => - a.id === actionId - ? { - ...a, - name: values.name, - command: values.command, - actionType: values.actionType, - pinned: values.pinned, - icon: values.icon, - } - : a + actionList.write( + actions.map((a) => + a.id === actionId + ? { + ...a, + name: values.name, + command: values.command, + actionType: values.actionType, + pinned: values.pinned, + icon: values.icon, + } + : a + ) ); } } @@ -420,7 +426,7 @@ async function deleteAction(actionId: string) { try { await commands.deleteProjectAction(actionId); - actions = actions.filter((a) => a.id !== actionId); + actionList.write(actions.filter((a) => a.id !== actionId)); window.dispatchEvent(new CustomEvent('project-actions-changed')); } catch (e) { console.error('Failed to delete action:', e); @@ -438,7 +444,7 @@ try { await commands.deleteAllRepoActions(repoContextId); if (selectedRepoKey === entryKey) { - actions = []; + actionList.write([]); } showDeleteAllConfirm = false; window.dispatchEvent(new CustomEvent('project-actions-changed')); @@ -469,7 +475,7 @@ await commands.deleteRepoBadge(entry.githubRepo, entry.subpath); repoBadgeStore.remove(entry.githubRepo, entry.subpath); if (selectedRepoKey === entryKey) { - actions = []; + actionList.write([]); } showDeleteRepoConfirm = false; window.dispatchEvent(new CustomEvent('project-actions-changed')); diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts index 7eeeca86e..d1c889d17 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts @@ -106,6 +106,11 @@ function buttonsNamed(label: string): HTMLButtonElement[] { return Array.from(host.querySelectorAll('button')).filter((b) => b.textContent?.trim() === label); } +/** Whether Refresh is disabled, which it is while a load is out. */ +function refreshDisabled(): boolean { + return buttonsNamed('Refresh')[0].dataset.disabled !== undefined; +} + describe('ConnectedAppsPanel', () => { it('lists each grant with client, access, scope, last use and expiry', async () => { commands.listControlGrants.mockResolvedValue([ @@ -148,6 +153,80 @@ describe('ConnectedAppsPanel', () => { expect(rows()).toHaveLength(1); }); + it('keeps the list from the load sent last when an earlier load answers after it', async () => { + const pending: Array<(list: ControlGrantSummary[]) => void> = []; + commands.listControlGrants.mockImplementation( + () => new Promise((resolve) => pending.push(resolve)) + ); + panel = mount(ConnectedAppsPanel, { target: host }); + flushSync(); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + + // An approval lands while the mount's load is still in flight. + transport.listeners.get('control-grants-changed')?.(undefined); + await vi.waitFor(() => expect(pending).toHaveLength(2)); + pending[1]([grant()]); + await flushPromises(); + expect(rows()).toHaveLength(1); + + pending[0]([]); + await flushPromises(); + expect(rows()).toHaveLength(1); + }); + + it('ignores the failure of an earlier load once a later load has answered', async () => { + const pending: Array<{ + resolve: (list: ControlGrantSummary[]) => void; + reject: (reason: Error) => void; + }> = []; + commands.listControlGrants.mockImplementation( + () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + ); + panel = mount(ConnectedAppsPanel, { target: host }); + flushSync(); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + + transport.listeners.get('control-grants-changed')?.(undefined); + await vi.waitFor(() => expect(pending).toHaveLength(2)); + pending[1].resolve([grant()]); + await flushPromises(); + expect(rows()).toHaveLength(1); + + pending[0].reject(new Error('Database not initialized')); + await flushPromises(); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(rows()).toHaveLength(1); + }); + + it('stays loading while the newest load is out, though an older one has failed', async () => { + const pending: Array<{ + resolve: (list: ControlGrantSummary[]) => void; + reject: (reason: Error) => void; + }> = []; + commands.listControlGrants.mockImplementation( + () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + ); + panel = mount(ConnectedAppsPanel, { target: host }); + flushSync(); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + transport.listeners.get('control-grants-changed')?.(undefined); + await vi.waitFor(() => expect(pending).toHaveLength(2)); + expect(host.querySelector('.loading-state')).not.toBeNull(); + + // Nothing later has answered, so this failure is the newest response and + // is shown; but the load sent after it is still out. + pending[0].reject(new Error('Database not initialized')); + await flushPromises(); + expect(host.querySelector('[role="alert"]')?.textContent).toContain('Database not initialized'); + expect(host.querySelector('.loading-state')).not.toBeNull(); + + pending[1].resolve([grant()]); + await flushPromises(); + expect(host.querySelector('.loading-state')).toBeNull(); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(rows()).toHaveLength(1); + }); + it('revokes behind a confirm and drops the row', async () => { commands.listControlGrants.mockResolvedValue([grant(), grant({ id: 'g-2', client: 'other' })]); await render(); @@ -165,6 +244,91 @@ describe('ConnectedAppsPanel', () => { expect(host.textContent).not.toContain('Revoke access for'); }); + /** Revoke the first listed grant through the confirm. */ + async function revokeFirst() { + buttonsNamed('Revoke')[0].click(); + flushSync(); + const confirm = buttonsNamed('Revoke'); + confirm[confirm.length - 1].click(); + await flushPromises(); + } + + it('does not put a revoked row back when a load sent before the revoke answers after it', async () => { + const pending: Array<{ + resolve: (list: ControlGrantSummary[]) => void; + reject: (reason: Error) => void; + }> = []; + const other = grant({ id: 'g-2', client: 'other' }); + commands.listControlGrants.mockResolvedValueOnce([grant(), other]); + commands.listControlGrants.mockImplementation( + () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + ); + await render(); + expect(rows()).toHaveLength(2); + + // Another change sends a load, which reads the store before the revoke. + transport.listeners.get('control-grants-changed')?.(undefined); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + await revokeFirst(); + expect(rows().map((r) => r.dataset.grantId)).toEqual(['g-2']); + + // That load answers with the grant still in it. It is read again, not + // applied and not dropped: the change that sent it is still to show. + pending[0].resolve([grant(), other]); + await flushPromises(); + expect(rows().map((r) => r.dataset.grantId)).toEqual(['g-2']); + expect(pending).toHaveLength(2); + // The re-read is the newest load, so Refresh waits for it. + expect(refreshDisabled()).toBe(true); + + pending[1].resolve([other, grant({ id: 'g-3', client: 'approved-meanwhile' })]); + await flushPromises(); + expect(rows().map((r) => r.dataset.grantId)).toEqual(['g-2', 'g-3']); + expect(refreshDisabled()).toBe(false); + }); + + it('reads again, without an error, when a load sent before the revoke fails after it', async () => { + const pending: Array<{ + resolve: (list: ControlGrantSummary[]) => void; + reject: (reason: Error) => void; + }> = []; + commands.listControlGrants.mockResolvedValueOnce([grant()]); + commands.listControlGrants.mockImplementation( + () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + ); + await render(); + + transport.listeners.get('control-grants-changed')?.(undefined); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + await revokeFirst(); + pending[0].reject(new Error('Database not initialized')); + await flushPromises(); + expect(pending).toHaveLength(2); + expect(host.querySelector('[role="alert"]')).toBeNull(); + + pending[1].resolve([]); + await flushPromises(); + expect(host.querySelector('[data-testid="connected-apps-empty"]')).not.toBeNull(); + expect(refreshDisabled()).toBe(false); + }); + + it('applies a load sent after the revoke landed without reading again', async () => { + commands.listControlGrants.mockResolvedValue([grant({ id: 'g-2', client: 'other' })]); + commands.listControlGrants.mockResolvedValueOnce([ + grant(), + grant({ id: 'g-2', client: 'other' }), + ]); + await render(); + await revokeFirst(); + + // The revoke's own change event: one load, applied as it answers. + transport.listeners.get('control-grants-changed')?.(undefined); + await flushPromises(); + expect(commands.listControlGrants).toHaveBeenCalledTimes(2); + expect(rows().map((r) => r.dataset.grantId)).toEqual(['g-2']); + expect(refreshDisabled()).toBe(false); + }); + it('cancelling or dismissing the confirm revokes nothing', async () => { commands.listControlGrants.mockResolvedValue([grant()]); await render(); diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte index b11009046..02989eaa2 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte @@ -17,6 +17,7 @@ import Spinner from '../../shared/Spinner.svelte'; import * as commands from '../../api/commands'; import { listenToEvent } from '../../transport'; + import { LatestWins } from '../../shared/latestWins'; import { formatRelativeTime } from '../../shared/relativeTime.svelte'; import type { ControlGrantSummary } from '../../types'; import { accessLabel, scopeLabel } from './controlAuthorization.svelte'; @@ -31,18 +32,48 @@ let revoking = $state(false); let mounted = true; - async function load() { + /** + * `grants`: each `control-grants-changed` sends a load, and the backend + * reads them concurrently: the load sent for the latest change stays, + * whichever answers last. + * + * `revoked`: a revoke drops its row here as soon as it lands. A load sent + * before that may have read the grant while it was still there, and no + * later load has landed to refuse it, so it reads again instead of putting + * the row back. + */ + const loadOrder = new LatestWins<'grants' | 'revoked'>(); + + async function load(): Promise { + const ticket = loadOrder.issue(); loading = true; try { - const list = await commands.listControlGrants(); - if (!mounted) return; - grants = list; - error = null; - } catch (e) { - if (!mounted) return; - error = e instanceof Error ? e.message : String(e); + let list: ControlGrantSummary[] | null = null; + let failure: string | null = null; + try { + list = await commands.listControlGrants(); + } catch (e) { + failure = e instanceof Error ? e.message : String(e); + } + // A failure is a response too: one from a load that a later load has + // already answered must not put its error above that load's list. + if (!mounted || !loadOrder.admit('grants', ticket)) return; + // Sent before a revoke landed here, so it may predate it. Whether it + // answered or failed, the list as it is now is one read away. + if (loadOrder.overtaken('revoked', ticket)) return await load(); + if (list) { + grants = list; + error = null; + } else { + error = failure; + } } finally { - if (mounted) loading = false; + // `loading` is about the newest load. An older one settling while that + // is still out leaves the Refresh button disabled, and so does a load + // that went on to read again: its re-read is the newest. A revoke is + // not a load (`wrote`), so one landing does not keep the button + // disabled once the newest load is back. + if (mounted && loadOrder.isNewest(ticket)) loading = false; } } @@ -62,6 +93,8 @@ try { await commands.revokeControlGrant(grant.id); // The event refreshes too; this keeps the row gone even if it is late. + // A load still out was sent before the revoke landed: mark it stale. + loadOrder.wrote('revoked'); grants = grants?.filter((g) => g.id !== grant.id) ?? null; confirming = null; } catch (e) { diff --git a/apps/staged/src/lib/features/settings/repoActionList.svelte.ts b/apps/staged/src/lib/features/settings/repoActionList.svelte.ts new file mode 100644 index 000000000..2e6d669da --- /dev/null +++ b/apps/staged/src/lib/features/settings/repoActionList.svelte.ts @@ -0,0 +1,83 @@ +/** + * The actions the repo settings panel lists for its selected repo: read from + * the backend when the selection changes, and written locally from the result + * of each create, edit, delete and detection so the panel needs no reload. + * + * Both race. The backend serves reads concurrently, so two sent in a row can + * answer in either order: picking repo A and then B could leave A's actions + * listed under B, where Edit and Delete act on A's action ids. And a read + * sent before a write landed may have read the store before it: applied + * after the local write, it would put a deleted action back, and the panel + * does not reload after a delete, so the row would stay and its Edit and + * Delete fail with "Action not found". + */ + +import type { ProjectAction } from '../../api/commands'; +import { LatestWins } from '../../shared/latestWins'; + +/** The repo whose actions are listed. */ +export interface RepoActionScope { + githubRepo: string; + subpath?: string | null; +} + +export class RepoActionList { + actions = $state([]); + loading = $state(false); + + /** `read`: the read responses. `written`: the local writes. */ + private order = new LatestWins<'read' | 'written'>(); + + /** + * @param selected The repo to list, asked afresh for every read. + * @param read Reads a repo's actions from the backend. + */ + constructor( + private readonly selected: () => RepoActionScope | null, + private readonly read: (githubRepo: string, subpath?: string) => Promise + ) {} + + /** Read the selected repo's actions. A failed read empties the list. */ + async load(): Promise { + const ticket = this.order.issue(); + const scope = this.selected(); + if (!scope) { + // Nothing to read, but a read still in flight must not fill the list. + // This is the newest load and nothing of it is out, so that read must + // not leave the spinner up either: it will not clear it (see below). + this.order.admit('read', ticket); + this.actions = []; + this.loading = false; + return; + } + this.loading = true; + try { + let loaded: ProjectAction[]; + try { + loaded = await this.read(scope.githubRepo, scope.subpath ?? undefined); + } catch (e) { + console.error('Failed to load actions:', e); + loaded = []; + } + // A read sent after this one has already landed. + if (!this.order.admit('read', ticket)) return; + // A write landed after this read was sent, so the read may predate it. + // It is not just dropped: it may be the read for a repo selected since, + // whose list the write (to the rows still on screen) knows nothing of. + // Read again, now that the write is in the store. + if (this.order.overtaken('written', ticket)) return await this.load(); + this.actions = loaded; + } finally { + // `loading` is about the newest load. An older read settling while + // that one is still out leaves the spinner up. A read that went on to + // read again is not the newest either: the re-read clears it. + if (this.order.isNewest(ticket)) this.loading = false; + } + } + + /** Replace the list with the result of a write that has landed. */ + write(actions: ProjectAction[]): void { + this.order.wrote('written'); + this.actions = actions; + } +} diff --git a/apps/staged/src/lib/features/settings/repoActionList.test.ts b/apps/staged/src/lib/features/settings/repoActionList.test.ts new file mode 100644 index 000000000..267312381 --- /dev/null +++ b/apps/staged/src/lib/features/settings/repoActionList.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProjectAction } from '../../api/commands'; +import type { RepoActionScope } from './repoActionList.svelte'; + +function action(id: string): ProjectAction { + return { + id, + name: id, + command: `just ${id}`, + actionType: 'run', + sortOrder: 1, + pinned: false, + icon: null, + } as unknown as ProjectAction; +} + +describe('RepoActionList', () => { + /** The reads sent, in order, each answered by hand. */ + let reads: Array<{ + githubRepo: string; + answer: (actions: ProjectAction[]) => void; + fail: (reason: Error) => void; + }>; + let selected: RepoActionScope | null; + + beforeEach(() => { + vi.resetModules(); + // A .svelte.ts module compiled without the Svelte plugin here, so the + // rune call resolves to this pass-through global. + vi.stubGlobal('$state', (initial: unknown) => initial); + vi.spyOn(console, 'error').mockImplementation(() => {}); + reads = []; + selected = { githubRepo: 'acme/app' }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function newList() { + const { RepoActionList } = await import('./repoActionList.svelte'); + return new RepoActionList( + () => selected, + (githubRepo) => + new Promise((answer, fail) => reads.push({ githubRepo, answer, fail })) + ); + } + + const ids = (actions: ProjectAction[]) => actions.map((a) => a.id); + + it('lists what the read returns', async () => { + const list = await newList(); + const load = list.load(); + expect(list.loading).toBe(true); + + reads[0].answer([action('build'), action('test')]); + await load; + expect(ids(list.actions)).toEqual(['build', 'test']); + expect(list.loading).toBe(false); + }); + + it('keeps the read sent last when an earlier one answers after it', async () => { + const list = await newList(); + const first = list.load(); + selected = { githubRepo: 'acme/web' }; + const second = list.load(); + + reads[1].answer([action('web-dev')]); + await second; + reads[0].answer([action('app-dev')]); + await first; + expect(ids(list.actions)).toEqual(['web-dev']); + }); + + it('does not put a deleted action back when a read sent before the delete lands after it', async () => { + const list = await newList(); + const initial = list.load(); + reads[0].answer([action('build'), action('test')]); + await initial; + + // A reload is in flight and read the store before the delete. + const reload = list.load(); + // The delete lands: the panel drops the row. + list.write(list.actions.filter((a) => a.id !== 'test')); + reads[1].answer([action('build'), action('test')]); + await vi.waitFor(() => expect(reads).toHaveLength(3)); + expect(ids(list.actions)).toEqual(['build']); + + // The list is read again, now with the delete in the store, rather than + // dropped: this read brings an action the first two did not have. + reads[2].answer([action('build'), action('lint')]); + await reload; + expect(ids(list.actions)).toEqual(['build', 'lint']); + expect(list.loading).toBe(false); + }); + + it('reads the repo selected by then when a write lands under an earlier read', async () => { + const list = await newList(); + const initial = list.load(); + reads[0].answer([action('app-dev'), action('app-test')]); + await initial; + + // Another repo is picked while the first one's rows are still listed, + // and a delete of one of those rows lands before its read does. + selected = { githubRepo: 'acme/web' }; + const switched = list.load(); + list.write(list.actions.filter((a) => a.id !== 'app-test')); + reads[1].answer([action('web-dev')]); + await vi.waitFor(() => expect(reads).toHaveLength(3)); + + expect(reads[2].githubRepo).toBe('acme/web'); + reads[2].answer([action('web-dev')]); + await switched; + expect(ids(list.actions)).toEqual(['web-dev']); + }); + + it('applies a read sent after the write landed', async () => { + const list = await newList(); + list.write([action('detected')]); + const load = list.load(); + reads[0].answer([action('detected'), action('added-elsewhere')]); + await load; + + expect(reads).toHaveLength(1); + expect(ids(list.actions)).toEqual(['detected', 'added-elsewhere']); + }); + + it('empties the list when the read fails, and when no repo is selected', async () => { + const list = await newList(); + list.write([action('build')]); + const failed = list.load(); + reads[0].fail(new Error('Database not initialized')); + await failed; + expect(list.actions).toEqual([]); + expect(list.loading).toBe(false); + + list.write([action('build')]); + const inFlight = list.load(); + selected = null; + await list.load(); + expect(list.actions).toEqual([]); + // The read still out must not fill a list with no repo selected. + // Nor is anything loading: no read is out for the cleared selection. + expect(list.loading).toBe(false); + reads[1].answer([action('build')]); + await inFlight; + expect(list.actions).toEqual([]); + expect(list.loading).toBe(false); + }); + + it('keeps loading set while the newest read is out, though an older one has settled', async () => { + const list = await newList(); + const first = list.load(); + selected = { githubRepo: 'acme/web' }; + const second = list.load(); + + // The older read answers while the one for the repo now selected is + // still out: that repo's rows are not there to show yet. + reads[0].answer([action('app-dev')]); + await first; + expect(list.loading).toBe(true); + + reads[1].answer([action('web-dev')]); + await second; + expect(ids(list.actions)).toEqual(['web-dev']); + expect(list.loading).toBe(false); + }); + + it('clears loading for the newest read when a local write landed while it was out', async () => { + const list = await newList(); + const initial = list.load(); + reads[0].answer([action('build'), action('test')]); + await initial; + + const older = list.load(); + const newest = list.load(); + list.write([action('build')]); + // The newest read answers first and reads again; the older one is then + // refused. Neither may leave the spinner up once the re-read is back. + reads[2].answer([action('build'), action('test')]); + await vi.waitFor(() => expect(reads).toHaveLength(4)); + reads[1].answer([action('build'), action('test')]); + await older; + expect(list.loading).toBe(true); + + reads[3].answer([action('build')]); + await newest; + expect(ids(list.actions)).toEqual(['build']); + expect(list.loading).toBe(false); + }); +}); diff --git a/apps/staged/src/lib/features/tags/tagActions.svelte.ts b/apps/staged/src/lib/features/tags/tagActions.svelte.ts index dfc4f7e9b..7ffa459a5 100644 --- a/apps/staged/src/lib/features/tags/tagActions.svelte.ts +++ b/apps/staged/src/lib/features/tags/tagActions.svelte.ts @@ -5,15 +5,19 @@ * Assignment writes target a slot (see TagSlot): a group's single choice, or * one ungrouped tag. Every surface repaints as soon as a pick is made, from * the intent tagsStore layers over its snapshot. Writes to different slots - * commute in the database, so they run concurrently; writes to one slot are - * chained per project, so the backend sees them in click order. A failed - * write drops its intent, if no newer pick in the slot has replaced it, and - * the view falls back to server truth. + * commute in the database, so nothing here holds one back for another; + * writes to one slot are chained per project, so the backend sees them in + * click order. A failed write drops its intent, if no newer pick in the slot + * has replaced it, and the view falls back to server truth. * * Catalog writes are rare, so they await the backend and then patch the * snapshot with the result; a failure toasts and refetches. They run one at * a time, in call order, so two quick edits reach the backend in the order * they were made and each sees the snapshot the one before it left. + * + * The two queues are not ordered against each other here. The command + * wrappers send every tag write in call order, which is what keeps an + * assignment still in flight ahead of a catalog edit to its tag or group. */ import { toast } from 'svelte-sonner'; diff --git a/apps/staged/src/lib/shared/latestWins.test.ts b/apps/staged/src/lib/shared/latestWins.test.ts new file mode 100644 index 000000000..56edda67a --- /dev/null +++ b/apps/staged/src/lib/shared/latestWins.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { LatestWins } from './latestWins'; + +describe('LatestWins', () => { + it('admits responses that land in the order they were sent', () => { + const order = new LatestWins(); + const first = order.issue(); + const second = order.issue(); + + expect(order.admit('p1', first)).toBe(true); + expect(order.admit('p1', second)).toBe(true); + }); + + it('refuses a response that lands after a later-sent one', () => { + const order = new LatestWins(); + const first = order.issue(); + const second = order.issue(); + + expect(order.admit('p1', second)).toBe(true); + expect(order.admit('p1', first)).toBe(false); + // The refused response must not lower the mark it lost to. + const third = order.issue(); + expect(order.admit('p1', third)).toBe(true); + expect(order.admit('p1', second)).toBe(false); + }); + + it('orders each key on its own', () => { + const order = new LatestWins(); + const first = order.issue(); + const second = order.issue(); + + expect(order.admit('p2', second)).toBe(true); + expect(order.admit('p1', first)).toBe(true); + }); + + it('compares tickets across keys', () => { + const order = new LatestWins<'list' | 'row'>(); + const list = order.issue(); + const row = order.issue(); + + expect(order.overtaken('row', list)).toBe(false); + expect(order.admit('row', row)).toBe(true); + expect(order.overtaken('row', list)).toBe(true); + // `overtaken` only asks; the list response can still be recorded. + expect(order.admit('list', list)).toBe(true); + }); + + it('knows the newest request sent, whatever has landed', () => { + const order = new LatestWins(); + const first = order.issue(); + expect(order.isNewest(first)).toBe(true); + + const second = order.issue(); + expect(order.isNewest(first)).toBe(false); + expect(order.isNewest(second)).toBe(true); + order.admit('p1', first); + expect(order.isNewest(second)).toBe(true); + }); + + it('lets a local write mark the reads sent before it landed', () => { + const order = new LatestWins<'list' | 'written'>(); + const before = order.issue(); + order.wrote('written'); + const after = order.issue(); + + expect(order.overtaken('written', before)).toBe(true); + expect(order.overtaken('written', after)).toBe(false); + // The read is still the newest list response to land. + expect(order.admit('list', before)).toBe(true); + }); + + it('does not count a local write as a request newer than the one still out', () => { + const order = new LatestWins<'list' | 'written'>(); + const read = order.issue(); + order.wrote('written'); + + // The read is still the newest request, so it may clear a spinner. + expect(order.isNewest(read)).toBe(true); + expect(order.overtaken('written', read)).toBe(true); + expect(order.isNewest(order.issue())).toBe(true); + expect(order.isNewest(read)).toBe(false); + }); + + it('lets a later local write outrank a response already landed for the same key', () => { + const order = new LatestWins(); + const read = order.issue(); + order.wrote('actions'); + + expect(order.admit('actions', read)).toBe(false); + expect(order.admit('actions', order.issue())).toBe(true); + }); +}); diff --git a/apps/staged/src/lib/shared/latestWins.ts b/apps/staged/src/lib/shared/latestWins.ts new file mode 100644 index 000000000..03ee03b4e --- /dev/null +++ b/apps/staged/src/lib/shared/latestWins.ts @@ -0,0 +1,75 @@ +/** + * Latest-wins ordering for responses to the same read. + * + * Backend reads run concurrently: always over HTTP, and under Tauri since the + * hot store reads moved off the main thread, where they used to run one at a + * time and answer in the order they were sent. Two requests for the same + * thing can now land in either order. A request sent later was sent after + * whatever change prompted it, so its response is at least as fresh as that + * change; a response that lands after a later-sent one has been applied would + * put older state back, and nothing would refetch it. + * + * Take a ticket with `issue()` when sending, and apply the response only if + * `admit(key, ticket)` allows it. Responses that land in the order they were + * sent are all admitted. Tickets come from one counter that never resets, so + * tickets issued for different keys can be compared with `overtaken`. + * + * This orders reads against reads only. State that is also written locally, + * from the result of a write, needs one more step: a read sent before that + * write landed may predate it, and nothing above refuses it, since no later + * read has landed. Record the local write as a landing under a key of its + * own (`wrote(writeKey)`), and have a read that finds itself + * `overtaken(writeKey, ticket)` read again. Reading again, where dropping + * would be simpler, keeps whatever else the dropped response carried. Only + * drop when the local write replaced the whole of the state. + */ +export class LatestWins { + /** The last place handed out in the order, to a request or a local write. */ + private sent = 0; + /** The ticket of the last request sent. */ + private newestRequest = 0; + private landed = new Map(); + + /** A ticket for a request about to be sent. */ + issue(): number { + this.newestRequest = ++this.sent; + return this.newestRequest; + } + + /** + * Record a local write as a landing for `key`, placed after every request + * sent so far and before every one sent from now on. It is not a request: + * `isNewest` still answers for the last request sent, which this write may + * be about to send back for another read. + */ + wrote(key: K): void { + this.landed.set(key, ++this.sent); + } + + /** + * Record that the response for `ticket` landed for `key`. False when a + * response sent after it has already landed there: this one is stale and + * must not be applied. + */ + admit(key: K, ticket: number): boolean { + if (this.overtaken(key, ticket)) return false; + this.landed.set(key, ticket); + return true; + } + + /** Whether a response sent after `ticket` has already landed for `key`. */ + overtaken(key: K, ticket: number): boolean { + return (this.landed.get(key) ?? 0) > ticket; + } + + /** + * Whether no request has been sent since the one `ticket` stands for. For + * state that describes the requests still out, such as a spinner, which an + * older request settling must not clear. Local writes (`wrote`) do not + * count: nothing of theirs is out, so a write landing while the newest + * request is still out must not leave that state set for good. + */ + isNewest(ticket: number): boolean { + return ticket === this.newestRequest; + } +} diff --git a/apps/staged/src/lib/shared/sendOrder.test.ts b/apps/staged/src/lib/shared/sendOrder.test.ts new file mode 100644 index 000000000..2a15ce8e7 --- /dev/null +++ b/apps/staged/src/lib/shared/sendOrder.test.ts @@ -0,0 +1,302 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SendOrder } from './sendOrder'; + +/** A request the test settles by hand, recording when it was sent. */ +function request(log: string[], name: string) { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const settled = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const send = () => { + log.push(`${name} sent`); + return settled; + }; + return { send, resolve, reject }; +} + +/** Let every queued promise reaction run. */ +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('SendOrder', () => { + it('sends an uncontended request in the caller’s own tick', () => { + const order = new SendOrder(); + const log: string[] = []; + void order.run('note:1', request(log, 'a').send); + expect(log).toEqual(['a sent']); + }); + + it('holds a request back until the one sent before it for the same key settles', async () => { + const order = new SendOrder(); + const log: string[] = []; + const first = request(log, 'first'); + const second = request(log, 'second'); + const third = request(log, 'third'); + + const results = [ + order.run('note:1', first.send), + order.run('note:1', second.send), + order.run('note:1', third.send), + ]; + await flush(); + expect(log).toEqual(['first sent']); + + first.resolve('one'); + await flush(); + expect(log).toEqual(['first sent', 'second sent']); + + second.resolve('two'); + third.resolve('three'); + expect(await Promise.all(results)).toEqual(['one', 'two', 'three']); + expect(log).toEqual(['first sent', 'second sent', 'third sent']); + }); + + it('does not make requests for different keys wait on each other', async () => { + const order = new SendOrder(); + const log: string[] = []; + void order.run('note:1', request(log, 'one').send); + void order.run('note:2', request(log, 'two').send); + await flush(); + expect(log).toEqual(['one sent', 'two sent']); + }); + + it('sends the next request after a failed one, and reports the failure to its caller only', async () => { + const order = new SendOrder(); + const log: string[] = []; + const first = request(log, 'first'); + const second = request(log, 'second'); + + const failed = order.run('note:1', first.send); + const next = order.run('note:1', second.send); + first.reject(new Error('save failed')); + await expect(failed).rejects.toThrow('save failed'); + await flush(); + expect(log).toEqual(['first sent', 'second sent']); + + second.resolve('saved'); + expect(await next).toBe('saved'); + }); + + it('turns a send that throws into a rejection and still releases the key', async () => { + const order = new SendOrder(); + const log: string[] = []; + const thrown = order.run('note:1', () => { + throw new Error('no transport'); + }); + const next = order.run('note:1', request(log, 'next').send); + await expect(thrown).rejects.toThrow('no transport'); + await flush(); + expect(log).toEqual(['next sent']); + void next; + }); + + it('forgets a key once nothing is in flight for it', async () => { + const order = new SendOrder(); + const log: string[] = []; + const first = request(log, 'first'); + const pending = order.run('note:1', first.send); + expect(order.pendingKeys).toBe(1); + first.resolve(); + await pending; + await flush(); + expect(order.pendingKeys).toBe(0); + + // The key is idle again, so the next request is sent at once. + void order.run('note:1', request(log, 'again').send); + expect(log).toEqual(['first sent', 'again sent']); + }); +}); + +/** + * The bound is for the web transport, where a request on a dropped + * connection never settles. Timers are faked, so "never" costs nothing. + */ +describe('SendOrder with a wait bound', () => { + let warn: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + warn.mockRestore(); + }); + + it('sends a request once the bound runs out on one ahead that never settles', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const hung = request(log, 'hung'); + const next = request(log, 'next'); + + void order.run('tags', hung.send); + const result = order.run('tags', next.send); + await vi.advanceTimersByTimeAsync(999); + expect(log).toEqual(['hung sent']); + + await vi.advanceTimersByTimeAsync(1); + expect(log).toEqual(['hung sent', 'next sent']); + expect(warn).toHaveBeenCalledTimes(1); + + // The key is the later request's now: once it settles the key is idle, + // though the hung request is still out. + next.resolve('done'); + await expect(result).resolves.toBe('done'); + await vi.advanceTimersByTimeAsync(0); + expect(order.pendingKeys).toBe(0); + void order.run('tags', request(log, 'after').send); + expect(log).toEqual(['hung sent', 'next sent', 'after sent']); + }); + + it('keeps the requests queued behind one hung request in order among themselves', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const second = request(log, 'second'); + const third = request(log, 'third'); + + // All four in one tick, as the creates of a multi-file drop are. + void order.run('tags', request(log, 'hung').send); + void order.run('tags', second.send); + void order.run('tags', third.send); + void order.run('tags', request(log, 'fourth').send); + + // Only the request directly behind the hung one gives up on it. + await vi.advanceTimersByTimeAsync(1000); + expect(log).toEqual(['hung sent', 'second sent']); + expect(warn).toHaveBeenCalledTimes(1); + + // The others still wait for the one sent before them, well inside a + // bound that restarted when it was sent. + await vi.advanceTimersByTimeAsync(900); + expect(log).toEqual(['hung sent', 'second sent']); + second.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(log).toEqual(['hung sent', 'second sent', 'third sent']); + await vi.advanceTimersByTimeAsync(900); + expect(log).toEqual(['hung sent', 'second sent', 'third sent']); + third.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(log).toEqual(['hung sent', 'second sent', 'third sent', 'fourth sent']); + + expect(warn).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('drains a chain of hung requests, one bound for each', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const last = request(log, 'fourth'); + + void order.run('tags', request(log, 'first').send); + void order.run('tags', request(log, 'second').send); + void order.run('tags', request(log, 'third').send); + const result = order.run('tags', last.send); + + // Every request hangs. Each one behind is sent a bound after the one + // ahead of it was, not all of them a bound after their own call. + await vi.advanceTimersByTimeAsync(1000); + expect(log).toEqual(['first sent', 'second sent']); + await vi.advanceTimersByTimeAsync(999); + expect(log).toEqual(['first sent', 'second sent']); + await vi.advanceTimersByTimeAsync(1); + expect(log).toEqual(['first sent', 'second sent', 'third sent']); + await vi.advanceTimersByTimeAsync(1000); + expect(log).toEqual(['first sent', 'second sent', 'third sent', 'fourth sent']); + expect(warn).toHaveBeenCalledTimes(3); + + // Nothing is left ticking for the three that never answer. + expect(vi.getTimerCount()).toBe(0); + last.resolve('done'); + await expect(result).resolves.toBe('done'); + await vi.advanceTimersByTimeAsync(0); + expect(order.pendingKeys).toBe(0); + }); + + it('counts the wait from the request’s own call when the one ahead is already out', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + + void order.run('tags', request(log, 'hung').send); + await vi.advanceTimersByTimeAsync(600); + void order.run('tags', request(log, 'next').send); + + // 1000 ms after `next` was called, not 1000 ms after `hung` was sent. + await vi.advanceTimersByTimeAsync(999); + expect(log).toEqual(['hung sent']); + await vi.advanceTimersByTimeAsync(1); + expect(log).toEqual(['hung sent', 'next sent']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('starts no timer for a request whose predecessor has not been sent yet', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const first = request(log, 'first'); + + void order.run('tags', first.send); + void order.run('tags', request(log, 'second').send); + void order.run('tags', request(log, 'third').send); + // One clock: `second` waiting on `first`. `third` waits on a request + // that is not out yet. + expect(vi.getTimerCount()).toBe(1); + + first.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(log).toEqual(['first sent', 'second sent']); + expect(vi.getTimerCount()).toBe(1); + }); + + it('leaves the request it stopped waiting for to finish', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const slow = request(log, 'slow'); + + const result = order.run('note:1', slow.send); + void order.run('note:1', request(log, 'next').send); + await vi.advanceTimersByTimeAsync(1000); + expect(log).toEqual(['slow sent', 'next sent']); + + slow.resolve('saved'); + await expect(result).resolves.toBe('saved'); + }); + + it('keeps the order and leaves no timer behind when requests settle inside the bound', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + const first = request(log, 'first'); + const second = request(log, 'second'); + + void order.run('note:1', first.send); + void order.run('note:1', second.send); + void order.run('note:1', request(log, 'third').send); + await vi.advanceTimersByTimeAsync(900); + expect(log).toEqual(['first sent']); + + first.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(log).toEqual(['first sent', 'second sent']); + + second.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(log).toEqual(['first sent', 'second sent', 'third sent']); + expect(vi.getTimerCount()).toBe(0); + expect(warn).not.toHaveBeenCalled(); + }); + + it('waits without limit when no bound is given, as under Tauri', async () => { + const order = new SendOrder(); + const log: string[] = []; + + void order.run('note:1', request(log, 'held').send); + void order.run('note:1', request(log, 'next').send); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + + expect(log).toEqual(['held sent']); + expect(vi.getTimerCount()).toBe(0); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/shared/sendOrder.ts b/apps/staged/src/lib/shared/sendOrder.ts new file mode 100644 index 000000000..d9fe6c231 --- /dev/null +++ b/apps/staged/src/lib/shared/sendOrder.ts @@ -0,0 +1,128 @@ +/** + * Send-order execution for writes to the same thing. + * + * Backend commands run concurrently: always over HTTP, and under Tauri once + * a command is an `async fn`, which runs on a thread pool. The synchronous + * commands ran one at a time on the main thread, in the order they were + * sent, so two writes sent without awaiting the first still landed in that + * order. On the pool they land in either order: the older of two saves of a + * note can land last and stay, "mark as unread" can overtake the read mark + * sent after it, and the second of two queued messages can be queued first. + * + * The backend cannot restore the order. It first sees an async command on a + * runtime worker, after the order of arrival is gone, and HTTP requests never + * had one. Only the sender knows it, so the sender keeps it: `run(key, send)` + * holds each request back until the one sent before it for the same key has + * settled, however it settled. Requests for different keys do not wait on + * each other. + * + * With nothing in flight for the key the request is sent at once, in the + * caller's own tick, so an uncontended call behaves as it did unqueued. + * + * A request that never settles holds back every later one for its key. Under + * Tauri that is the old behaviour in miniature: a synchronous command that + * never returned held back every command. Over HTTP it is new. Requests there + * were independent before they were ordered, and a `fetch` on a dropped + * connection can stay pending for as long as the page is open, which would + * stop every later write for the key until a reload. + * + * `maxWaitMs` bounds that. A request waits at most that long for the one + * ahead of it, and is then sent beside it. The request it stopped waiting for + * is not cancelled or failed, so a command that is only slow still completes; + * the two can then land in either order, as every pair did over HTTP before. + * + * The clock runs only while the one ahead is out: it starts at the call to + * `run`, or when the one ahead is sent if that is later. Requests queued + * behind one hung request therefore do not all run out together. The first + * gives up and is sent; the second then waits for that send, with a full + * bound of its own, and so on, so the requests that are not hung keep their + * order among themselves. A chain still drains, one bound for each request in + * it that hangs. + * + * Leave `maxWaitMs` unset under Tauri, where the wait must have no bound: a + * store lock held for longer than any bound would otherwise let a later + * write overtake an earlier one. + */ +export class SendOrder { + private tails = new Map(); + private readonly maxWaitMs: number | undefined; + + constructor({ maxWaitMs }: { maxWaitMs?: number } = {}) { + this.maxWaitMs = maxWaitMs; + } + + /** Send through `send` once every earlier request for `key` has settled. */ + run(key: K, send: () => Promise): Promise { + const earlier = this.tails.get(key); + const request: InFlight = { settled: SETTLED, sent: false, onSent: [] }; + const sendNow = (): Promise => { + request.sent = true; + // Only a bounded wait registers here, so this is empty under Tauri. + for (const startClock of request.onSent.splice(0)) startClock(); + return send(); + }; + const result = earlier + ? this.settledOrOverdue(key, earlier).then(sendNow) + : // Inside an executor so a `send` that throws rejects like one that + // fails later, and still releases the key. + new Promise((resolve) => resolve(sendNow())); + request.settled = result.then(noop, noop); + this.tails.set(key, request); + void request.settled.then(() => { + if (this.tails.get(key) === request) this.tails.delete(key); + }); + return result; + } + + /** How many keys have a request in flight or waiting. */ + get pendingKeys(): number { + return this.tails.size; + } + + /** + * `earlier.settled` itself when the wait has no bound, so that path gains + * no timer and no extra tick. With a bound, a promise that resolves when + * `earlier` settles, or when it has been out for the bound since this call, + * whichever is first. While `earlier` is itself still waiting to be sent + * there is no timer: the wait for it is bounded by the timers ahead. + */ + private settledOrOverdue(key: K, earlier: InFlight): Promise { + const maxWaitMs = this.maxWaitMs; + if (maxWaitMs === undefined) return earlier.settled; + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const startClock = () => { + timer = setTimeout(() => { + timer = undefined; + console.warn( + `[sendOrder] A write for "${String(key)}" has waited ${maxWaitMs} ms for the one ` + + 'sent before it to answer; sending it without waiting any longer.' + ); + resolve(); + }, maxWaitMs); + }; + if (earlier.sent) startClock(); + else earlier.onSent.push(startClock); + // `earlier` cannot settle before it is sent, so by now the clock has + // either started or already run out. + void earlier.settled.then(() => { + if (timer !== undefined) clearTimeout(timer); + resolve(); + }); + }); + } +} + +/** A request `run` has taken: waiting for the ones ahead of it, or sent. */ +interface InFlight { + /** Resolves once the request has settled, however it settled. */ + settled: Promise; + /** Whether `send` has been called for it. */ + sent: boolean; + /** Called when it is sent: the clocks of the bounded waits behind it. */ + onSent: Array<() => void>; +} + +const SETTLED: Promise = Promise.resolve(); + +function noop(): void {} diff --git a/apps/staged/src/lib/stores/projectsData.component.test.ts b/apps/staged/src/lib/stores/projectsData.component.test.ts index e2ebe408b..cdacfc1d2 100644 --- a/apps/staged/src/lib/stores/projectsData.component.test.ts +++ b/apps/staged/src/lib/stores/projectsData.component.test.ts @@ -812,6 +812,111 @@ describe('refreshProject', () => { }); }); +// The backend serves these reads concurrently, so within one generation a +// response can land after one that was sent later. Sent later means sent after +// whatever change prompted it, so the later-sent response is the one to keep. +describe('reads landing out of order', () => { + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + it('drops a refresh whose branches land after a branch refetch sent later', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + store.startListeners(); + + const stale = deferred>(); + listBranchesForProject.mockReturnValueOnce(stale.promise); + const refresh = store.refreshProject('p1'); + + listBranchesForProject.mockResolvedValue(swr([branch({ branchName: 'fresh' })])); + emit('branch-changed', { branchId: 'b1', projectId: 'p1' }); + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fresh'); + }); + + invalidateProjectBranchTimelines.mockClear(); + stale.resolve(swr([branch({ branchName: 'stale' })])); + expect(await refresh).toBe(true); + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fresh'); + // The dropped list names no branches to invalidate. + expect(invalidateProjectBranchTimelines).not.toHaveBeenCalled(); + }); + + it('drops a hydration that lands after a refresh sent later', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + + const staleBranches = deferred>(); + const staleRepos = deferred>(); + listBranchesForProject.mockReturnValueOnce(staleBranches.promise); + listProjectRepos.mockReturnValueOnce(staleRepos.promise); + const hydration = store.hydrateProject('p1'); + + // Unlike refresh(), this keeps the generation, so only the send order + // tells the two responses apart. + listBranchesForProject.mockResolvedValue(swr([branch({ branchName: 'fresh' })])); + listProjectRepos.mockResolvedValue(swr([projectRepo({ branchName: 'fresh' })])); + await store.refreshProject('p1'); + + staleBranches.resolve(swr([branch({ branchName: 'stale' })])); + staleRepos.resolve(swr([projectRepo({ branchName: 'stale' })])); + await hydration; + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fresh'); + expect(store.reposByProject.get('p1')![0].branchName).toBe('fresh'); + }); + + it('keeps the project list of a refresh over a revalidation sent before it', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + const stale = deferred>(); + listProjects.mockReturnValueOnce(stale.promise); + // Loaded already, so this soft-revalidates in the background. + await store.ensureLoaded(); + await vi.waitFor(() => expect(listProjects).toHaveBeenCalledTimes(2)); + + listProjects.mockResolvedValue(swr([project({ name: 'Renamed' })])); + await store.refreshProject('p1'); + expect(store.projects[0].name).toBe('Renamed'); + + stale.resolve(swr([project({ name: 'Alpha' })])); + await tick(); + expect(store.projects[0].name).toBe('Renamed'); + expect(store.branchesByProject.get('p1')).toHaveLength(1); + }); + + it('applies a transferred project’s branch list even when a later refetch landed first', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + store.startListeners(); + expect(store.branchesByProject.get('p1')![0].worktreePath).toBe('/wt/b1'); + + const replace = deferred>(); + listBranchesForProject.mockReturnValueOnce(replace.promise); + const refresh = store.refreshTransferredProject('p1'); + + // The refetch merges over the old list, which carries the old path on. + listBranchesForProject.mockResolvedValue(swr([branch({ worktreePath: null })])); + emit('branch-changed', { branchId: 'b1', projectId: 'p1' }); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(3)); + await tick(); + expect(store.branchesByProject.get('p1')![0].worktreePath).toBe('/wt/b1'); + + // Only the replace list drops the path the transfer removed. + replace.resolve(swr([branch({ worktreePath: null })])); + await refresh; + expect(store.branchesByProject.get('p1')![0].worktreePath).toBeNull(); + }); +}); + describe('projectCreated', () => { it('registers the project immediately and hydrates it in the background', async () => { const store = await importStore(); @@ -855,6 +960,321 @@ describe('setBranchesByProject', () => { expect(store.branchesByProject.get('p1')).toEqual([renamed]); }); + + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + /** Whether `promise` has settled, once every queued reaction has run. */ + async function settled(promise: Promise): Promise { + let done = false; + void promise.then( + () => (done = true), + () => (done = true) + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + return done; + } + + it('reads the branches again when a read sent before a local write lands after it', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + + // A read is in flight, and read the store before the rename. + const stale = deferred>(); + listBranchesForProject.mockReturnValueOnce(stale.promise); + const refresh = store.refreshProject('p1'); + + // The rename lands and the view paints it. + const renamed = { ...store.branchesByProject.get('p1')![0], branchName: 'renamed' }; + store.setBranchesByProject(new Map(store.branchesByProject).set('p1', [renamed])); + + const again = deferred>(); + listBranchesForProject.mockReturnValueOnce(again.promise); + const reads = listBranchesForProject.mock.calls.length; + invalidateProjectBranchTimelines.mockClear(); + stale.resolve(swr([branch({ branchName: 'before-rename' })])); + + // The older read does not put the old name back, and the refresh has not + // reported a result: what it has is not a list it could apply. + expect(await settled(refresh)).toBe(false); + expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual(['renamed']); + expect(invalidateProjectBranchTimelines).not.toHaveBeenCalled(); + // What else that read carried is not lost: the refresh reads the list + // again, once, with nothing else sent for it in the background. + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 1); + expect(listBranchesForProject).toHaveBeenLastCalledWith('p1', { force: true }); + + again.resolve( + swr([branch({ branchName: 'renamed' }), branch({ id: 'b2', branchName: 'other' })]) + ); + expect(await refresh).toBe(true); + expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual([ + 'renamed', + 'other', + ]); + // The timelines invalidated are those of the list that was applied. + expect(invalidateProjectBranchTimelines).toHaveBeenCalledTimes(1); + expect(invalidateProjectBranchTimelines).toHaveBeenCalledWith(['b1', 'b2']); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 1); + }); + + it('does not report a refresh applied while its list is set aside by an unrelated write', async () => { + const store = await importStore(); + listBranchesForProject.mockResolvedValue( + swr([branch(), branch({ id: 'b2', branchName: 'other' })]) + ); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + + // b1 has been deleted in the backend, and the caller awaits the refresh + // before it clears the card's "Deleting…" state. + const afterDelete = deferred>(); + listBranchesForProject.mockReturnValueOnce(afterDelete.promise); + const refresh = store.refreshProject('p1'); + + // While that read is out, a workspace status change lands on b2. + const [first, second] = store.branchesByProject.get('p1')!; + store.setBranchesByProject( + new Map(store.branchesByProject).set('p1', [ + first, + { ...second, workspaceStatus: 'running' as const }, + ]) + ); + + // The read is fresh, but nothing tells it from one that predates the + // status change, so it is set aside. The deleted branch is still listed, + // and the refresh must not say otherwise. + const again = deferred>(); + listBranchesForProject.mockReturnValueOnce(again.promise); + afterDelete.resolve(swr([branch({ id: 'b2', branchName: 'other' })])); + expect(await settled(refresh)).toBe(false); + expect(store.branchesByProject.get('p1')!.map((b) => b.id)).toEqual(['b1', 'b2']); + + again.resolve(swr([branch({ id: 'b2', branchName: 'other', workspaceStatus: 'running' })])); + expect(await refresh).toBe(true); + expect(store.branchesByProject.get('p1')!.map((b) => b.id)).toEqual(['b2']); + }); + + it('does not report a refresh applied when a set-aside event refetch answers ahead of it', async () => { + const store = await importStore(); + listBranchesForProject.mockResolvedValue( + swr([branch(), branch({ id: 'b2', branchName: 'other' })]) + ); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + store.startListeners(); + const ids = () => store.branchesByProject.get('p1')!.map((b) => b.id); + + const refreshRead = deferred>(); + const eventRead = deferred>(); + const backgroundRead = deferred>(); + const again = deferred>(); + listBranchesForProject + .mockReturnValueOnce(refreshRead.promise) + .mockReturnValueOnce(eventRead.promise) + .mockReturnValueOnce(backgroundRead.promise) + .mockReturnValueOnce(again.promise); + const reads = listBranchesForProject.mock.calls.length; + + // b1 has been deleted in the backend. The caller awaits the refresh, and + // the delete's own change event sends a refetch behind it. + const refresh = store.refreshProject('p1'); + emit('branch-changed', { branchId: 'b1', projectId: 'p1' }); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 2)); + + // While both reads are out, a workspace status change lands on b2. + const [first, second] = store.branchesByProject.get('p1')!; + store.setBranchesByProject( + new Map(store.branchesByProject).set('p1', [ + first, + { ...second, workspaceStatus: 'running' as const }, + ]) + ); + + // The event refetch, sent later, answers first. It is set aside for the + // status change and sends a read of its own in the background. + const afterDelete = [branch({ id: 'b2', branchName: 'other' })]; + eventRead.resolve(swr(afterDelete)); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 3)); + expect(ids()).toEqual(['b1', 'b2']); + + // The refresh's own read lands next. No list sent after it has been + // applied, only set aside, so it is not refused: the deleted branch is + // still listed, and the refresh reads again without reporting a result. + refreshRead.resolve(swr(afterDelete)); + expect(await settled(refresh)).toBe(false); + expect(ids()).toEqual(['b1', 'b2']); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 4); + + again.resolve(swr([branch({ id: 'b2', branchName: 'other', workspaceStatus: 'running' })])); + expect(await refresh).toBe(true); + expect(ids()).toEqual(['b2']); + + // The background read was sent before the one just applied, so it is + // refused, and nothing further is read. + backgroundRead.resolve(swr([branch(), branch({ id: 'b2', branchName: 'other' })])); + await tick(); + expect(ids()).toEqual(['b2']); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 4); + }); + + it('reports a refresh applied, with no further read, once a list sent after it has been applied', async () => { + const store = await importStore(); + listBranchesForProject.mockResolvedValue( + swr([branch(), branch({ id: 'b2', branchName: 'other' })]) + ); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + store.startListeners(); + const ids = () => store.branchesByProject.get('p1')!.map((b) => b.id); + + const refreshRead = deferred>(); + const eventRead = deferred>(); + const backgroundRead = deferred>(); + listBranchesForProject + .mockReturnValueOnce(refreshRead.promise) + .mockReturnValueOnce(eventRead.promise) + .mockReturnValueOnce(backgroundRead.promise); + const reads = listBranchesForProject.mock.calls.length; + + // The same start: a refresh, an event refetch behind it, then a local + // write, and the event refetch set aside. + const refresh = store.refreshProject('p1'); + emit('branch-changed', { branchId: 'b1', projectId: 'p1' }); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 2)); + const [first, second] = store.branchesByProject.get('p1')!; + store.setBranchesByProject( + new Map(store.branchesByProject).set('p1', [ + first, + { ...second, workspaceStatus: 'running' as const }, + ]) + ); + const afterDelete = [branch({ id: 'b2', branchName: 'other' })]; + eventRead.resolve(swr(afterDelete)); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 3)); + + // This time the background read, sent after the write, lands and is + // applied while the refresh's read is still out. + backgroundRead.resolve( + swr([branch({ id: 'b2', branchName: 'other', workspaceStatus: 'running' })]) + ); + await vi.waitFor(() => expect(ids()).toEqual(['b2'])); + + // The refresh's read is refused, and here the refusal is true: a list + // sent after it is what the store shows. It resolves without another read. + refreshRead.resolve(swr(afterDelete)); + expect(await refresh).toBe(true); + expect(ids()).toEqual(['b2']); + await tick(); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 3); + }); + + it('keeps reading while local writes keep landing under the refresh’s reads', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + const rename = (branchName: string) => { + const renamed = { ...store.branchesByProject.get('p1')![0], branchName }; + store.setBranchesByProject(new Map(store.branchesByProject).set('p1', [renamed])); + }; + + const first = deferred>(); + const second = deferred>(); + const third = deferred>(); + listBranchesForProject + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(third.promise); + const reads = listBranchesForProject.mock.calls.length; + const refresh = store.refreshProject('p1'); + + rename('one'); + first.resolve(swr([branch({ branchName: 'before' })])); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 2)); + rename('two'); + second.resolve(swr([branch({ branchName: 'one' })])); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 3)); + expect(await settled(refresh)).toBe(false); + expect(store.branchesByProject.get('p1')![0].branchName).toBe('two'); + + third.resolve(swr([branch({ branchName: 'two' })])); + expect(await refresh).toBe(true); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 3); + }); + + it('reports the refresh as not applied when the read after a local write fails', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const stale = deferred>(); + listBranchesForProject.mockReturnValueOnce(stale.promise); + const refresh = store.refreshProject('p1'); + const renamed = { ...store.branchesByProject.get('p1')![0], branchName: 'renamed' }; + store.setBranchesByProject(new Map(store.branchesByProject).set('p1', [renamed])); + + listBranchesForProject.mockRejectedValueOnce(new Error('Database not initialized')); + stale.resolve(swr([branch({ branchName: 'before-rename' })])); + expect(await refresh).toBe(false); + expect(store.branchesByProject.get('p1')![0].branchName).toBe('renamed'); + }); + + it('reads again in the background when an event’s refetch is set aside', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + store.startListeners(); + + // Nothing awaits this read: the refetch a branch-changed event sends. + const stale = deferred>(); + listBranchesForProject.mockReturnValueOnce(stale.promise); + const reads = listBranchesForProject.mock.calls.length; + emit('branch-changed', { branchId: 'b1', projectId: 'p1' }); + await vi.waitFor(() => expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 1)); + + const renamed = { ...store.branchesByProject.get('p1')![0], branchName: 'renamed' }; + store.setBranchesByProject(new Map(store.branchesByProject).set('p1', [renamed])); + listBranchesForProject.mockResolvedValue( + swr([branch({ branchName: 'renamed' }), branch({ id: 'b2', branchName: 'other' })]) + ); + stale.resolve(swr([branch({ branchName: 'before-rename' })])); + + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual([ + 'renamed', + 'other', + ]); + }); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads + 2); + }); + + it('lets a read in flight apply after a call that changed no branch', async () => { + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureProjectsHydrated(); + + const inFlight = deferred>(); + listBranchesForProject.mockReturnValueOnce(inFlight.promise); + const refresh = store.refreshProject('p1'); + const reads = listBranchesForProject.mock.calls.length; + + // A status event for a branch this project does not have: the caller + // still rebuilds the array. + const unchanged = store.branchesByProject.get('p1')!.map((b) => b); + store.setBranchesByProject(new Map(store.branchesByProject).set('p1', unchanged)); + + inFlight.resolve(swr([branch({ branchName: 'fetched' })])); + await refresh; + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fetched'); + expect(listBranchesForProject).toHaveBeenCalledTimes(reads); + }); }); describe('refresh', () => { diff --git a/apps/staged/src/lib/stores/projectsData.svelte.ts b/apps/staged/src/lib/stores/projectsData.svelte.ts index e9d6a71a0..d4e0d171b 100644 --- a/apps/staged/src/lib/stores/projectsData.svelte.ts +++ b/apps/staged/src/lib/stores/projectsData.svelte.ts @@ -38,6 +38,7 @@ import { listenToEvent, type UnlistenFn } from '../transport'; import * as commands from '../commands'; import { repoBadgeStore } from './repoBadges.svelte'; +import { LatestWins } from '../shared/latestWins'; import type { Branch, BranchChangedEvent, @@ -63,6 +64,12 @@ import type { */ const BACKGROUND_HYDRATION_IDLE_TIMEOUT_MS = 3000; +/** + * What offering a fetched branch list resolves to when a local write landed + * after the read was sent: neither applied nor refused, to be read again. + */ +const BRANCH_READ_SET_ASIDE = 'set-aside'; + /** * Merge incoming branches with existing ones, preserving worktreePath when * a stale async response would overwrite an already-populated value with null. @@ -85,6 +92,13 @@ export function mergeBranchesPreservingWorktree(existing: Branch[], incoming: Br }); } +/** Whether two branch lists hold the same branch objects in the same order. */ +function sameBranchList(existing: Branch[] | undefined, next: Branch[]): boolean { + const current = existing ?? []; + if (current === next) return true; + return current.length === next.length && current.every((branch, i) => branch === next[i]); +} + /** * Where a project transfer is: its latest `project-transfer-progress`, or the * confirm that started it (no transfer id yet) until the first event lands. @@ -170,6 +184,16 @@ class ProjectsDataStore { * fresher state. Same role as ProjectHome's loadGeneration. */ private loadGeneration = 0; + /** + * Orders the reads within one generation by when they were sent. The + * refresh, hydrate and branch-refetch paths can each have a read of the + * same project in flight, and the backend serves them concurrently, so an + * older response can land last. Keyed per list: `projects`, + * `branches:` and `repos:`. `branch-writes:` + * records the view's own writes to a branch list (setBranchesByProject), + * which a branch read sent before them must not be applied over. + */ + private readOrder = new LatestWins(); private initialLoad: Promise | null = null; private revalidatePending = false; private revalidateQueued = false; @@ -421,12 +445,21 @@ class ProjectsDataStore { * `replaceBranches` applies the branch list as fetched, without carrying * worktree paths over from the old one. Resolves whether the result was * applied: false when a newer load overtook it, or the fetch failed. + * + * Callers await this to know the store shows what their mutation left + * (the branch delete clears its "Deleting…" state on it). So when the + * branch list is set aside because a local write landed while it was out + * (see offerProjectBranches), this reads the list again itself and resolves + * only once a list read after that write has been offered. Any local write + * to the project sets the read aside, related to the refresh or not, such + * as a workspace status change on another branch. */ async refreshProject( projectId: string, { replaceBranches = false }: { replaceBranches?: boolean } = {} ): Promise { const generation = this.loadGeneration; + const ticket = this.readOrder.issue(); try { const [projectsResult, branchesResult, reposResult] = await Promise.all([ commands.listProjects({ force: true }), @@ -434,15 +467,25 @@ class ProjectsDataStore { commands.listProjectRepos(projectId, { force: true }), ]); if (generation !== this.loadGeneration) return false; - this._projects = projectsResult.data; - const mergedBranches = this.applyProjectBranches(projectId, branchesResult.data, generation, { + if (this.readOrder.admit('projects', ticket)) { + this._projects = projectsResult.data; + } + let offered = this.offerProjectBranches(projectId, branchesResult.data, generation, ticket, { replace: replaceBranches, }); - if (mergedBranches) { - commands.invalidateProjectBranchTimelines(mergedBranches.map((b) => b.id)); - } - this.applyProjectRepos(projectId, reposResult.data, generation); + this.applyProjectRepos(projectId, reposResult.data, generation, ticket); this.markProjectHydrated(projectId, generation); + // Each further round needs another local write to land during a read, + // so this ends as soon as one read comes back with none. + while (offered === BRANCH_READ_SET_ASIDE) { + const again = this.readOrder.issue(); + const { data } = await commands.listBranchesForProject(projectId, { force: true }); + if (generation !== this.loadGeneration) return false; + offered = this.offerProjectBranches(projectId, data, generation, again); + } + if (offered) { + commands.invalidateProjectBranchTimelines(offered.map((b) => b.id)); + } return true; } catch (e) { console.error(`[projectsData] Failed to refresh project '${projectId}':`, e); @@ -494,6 +537,16 @@ class ProjectsDataStore { * through the generation-guarded paths instead. */ setBranchesByProject(next: Map): void { + // A read of a project's branches still in flight was sent before this + // write and may have read the store before it. Mark the write so that + // read is not applied over it (see offerProjectBranches). Only lists that + // really differ count: callers rebuild the array even when no branch in + // it matched, and such a call must not turn a first hydration away. + for (const [projectId, branches] of next) { + if (!sameBranchList(this._branchesByProject.get(projectId), branches)) { + this.readOrder.wrote(`branch-writes:${projectId}`); + } + } this._branchesByProject = next; } @@ -546,20 +599,23 @@ class ProjectsDataStore { this._loading = true; } this._error = null; + const ticket = this.readOrder.issue(); try { const [{ data, revalidating }] = await Promise.all([ commands.listProjects({ force }), repoBadgeStore.loadAll({ force }), ]); if (generation !== this.loadGeneration) return; - this.applyProjectList(data, generation, { scheduleHydration: !soft }); + this.applyProjectList(data, generation, ticket, { scheduleHydration: !soft }); this._loaded = true; if (revalidating) { // Applied outside the awaited chain so callers aren't blocked on the // SWR refresh — they already have renderable data. revalidating - .then((fresh) => this.applyProjectList(fresh, generation, { scheduleHydration: !soft })) + .then((fresh) => + this.applyProjectList(fresh, generation, ticket, { scheduleHydration: !soft }) + ) .catch((e) => { console.error('[projectsData] Failed to revalidate project list:', e); }); @@ -585,9 +641,13 @@ class ProjectsDataStore { private applyProjectList( projectList: Project[], generation: number, + ticket: number, { scheduleHydration = true }: { scheduleHydration?: boolean } = {} ): void { if (generation !== this.loadGeneration) return; + // A list a refresh sent after this one has already landed (see + // readOrder): keep that list, and seed, prune and hydrate against it. + if (!this.readOrder.admit('projects', ticket)) projectList = this._projects; const previousProjectIds = new Set(this._projects.map((project) => project.id)); const newlySeenProjectIds = projectList .map((project) => project.id) @@ -662,6 +722,7 @@ class ProjectsDataStore { } private async hydrateProjectInternal(projectId: string, generation: number): Promise { + const ticket = this.readOrder.issue(); try { const [branchesResult, reposResult] = await Promise.all([ commands.listBranchesForProject(projectId), @@ -669,13 +730,13 @@ class ProjectsDataStore { ]); if (generation !== this.loadGeneration) return; - this.applyProjectBranches(projectId, branchesResult.data, generation); - this.applyProjectRepos(projectId, reposResult.data, generation); + this.applyProjectBranches(projectId, branchesResult.data, generation, ticket); + this.applyProjectRepos(projectId, reposResult.data, generation, ticket); if (branchesResult.revalidating) { branchesResult.revalidating .then((fresh) => { - this.applyProjectBranches(projectId, fresh, generation); + this.applyProjectBranches(projectId, fresh, generation, ticket); }) .catch((e) => { console.error(`[projectsData] Failed to revalidate branches for '${projectId}':`, e); @@ -684,7 +745,7 @@ class ProjectsDataStore { if (reposResult.revalidating) { reposResult.revalidating - .then((fresh) => this.applyProjectRepos(projectId, fresh, generation)) + .then((fresh) => this.applyProjectRepos(projectId, fresh, generation, ticket)) .catch((e) => { console.error(`[projectsData] Failed to revalidate repos for '${projectId}':`, e); }); @@ -703,13 +764,40 @@ class ProjectsDataStore { this._hydratedProjects = new Map(this._hydratedProjects).set(projectId, generation); } - private applyProjectBranches( + /** + * Offer a fetched branch list to the store. Resolves to the list applied, + * or to null when it was refused: a newer load has started, or a read sent + * after this one (`ticket`, see readOrder) has already been applied. A + * `replace` list is applied even then: it is read after the transfer that + * asked for it, and it alone drops the worktree paths the transfer removed. + * + * A list sent before a local write landed (setBranchesByProject) is not + * applied either: it may predate the rename, delete or status change the + * view has already painted, and would put the old row back until the + * write's change event refetched. That is BRANCH_READ_SET_ASIDE, not a + * refusal, since the list may be the only read carrying some other change: + * the caller must have the list read again. + */ + private offerProjectBranches( projectId: string, branches: Branch[], generation: number, + ticket: number, { replace = false }: { replace?: boolean } = {} - ): Branch[] | null { + ): Branch[] | typeof BRANCH_READ_SET_ASIDE | null { if (generation !== this.loadGeneration || !this.hasProject(projectId)) return null; + const readKey = `branches:${projectId}`; + if (!replace) { + if (this.readOrder.overtaken(readKey, ticket)) return null; + if (this.readOrder.overtaken(`branch-writes:${projectId}`, ticket)) { + return BRANCH_READ_SET_ASIDE; + } + } + // Only a list that is applied counts as landed, so a read is refused + // only for a later-sent list the store really shows. A list set aside + // above leaves no mark: an older read still out is then set aside too, + // not refused, and its caller reads again (refreshProject relies on it). + this.readOrder.admit(readKey, ticket); const mergedBranches = replace ? branches @@ -718,8 +806,30 @@ class ProjectsDataStore { return mergedBranches; } - private applyProjectRepos(projectId: string, repos: ProjectRepo[], generation: number): void { + /** + * Offer a fetched branch list (see offerProjectBranches), and have the + * list read again in the background when it is set aside. For the reads + * nothing awaits: hydration and the event-driven refetch. refreshProject, + * which callers do await, reads again itself. + */ + private applyProjectBranches( + projectId: string, + branches: Branch[], + generation: number, + ticket: number + ): void { + const offered = this.offerProjectBranches(projectId, branches, generation, ticket); + if (offered === BRANCH_READ_SET_ASIDE) void this.refetchProjectBranches(projectId); + } + + private applyProjectRepos( + projectId: string, + repos: ProjectRepo[], + generation: number, + ticket: number + ): void { if (generation !== this.loadGeneration || !this.hasProject(projectId)) return; + if (!this.readOrder.admit(`repos:${projectId}`, ticket)) return; this._reposByProject = new Map(this._reposByProject).set(projectId, repos); void repoBadgeStore.ensureForRepos( repos.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) @@ -1220,15 +1330,16 @@ class ProjectsDataStore { } this.branchRefetchInFlight.add(projectId); const generation = this.loadGeneration; + const ticket = this.readOrder.issue(); try { const { data: branches, revalidating } = await commands.listBranchesForProject(projectId, { force: true, }); - this.applyProjectBranches(projectId, branches, generation); + this.applyProjectBranches(projectId, branches, generation, ticket); if (revalidating) { revalidating .then((fresh) => { - this.applyProjectBranches(projectId, fresh, generation); + this.applyProjectBranches(projectId, fresh, generation, ticket); }) .catch((e) => { console.error(