From 19bc582b2a2b504f878cb62f7cb4e30a21cdd388 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 12:24:30 +1100 Subject: [PATCH 1/8] fix(staged): run sq check, project create and repo pin off the main thread `is_sq_available`, `create_project` and `pin_repo` were plain `fn` Tauri commands, so they ran on the main thread and froze the window while they waited: on the login-shell `sq` lookup at every launch, on `gh repo view` for up to its 60 s timeout, on git, and on store writes and lock waits. This is steps 1 and 2 of the recommended order in the synchronous-commands note, done as its Fix A. Each is now an `async fn` that only looks up the store handle and hands its whole body to `tauri::async_runtime::spawn_blocking`: - `is_sq_available` copies the web arm and now returns `Result`. The frontend already treats a rejection as "not available". - `create_project` runs the new `create_project_blocking`, which is the old body with one reorder (below). - `pin_repo` runs the new `pin_repo_impl`, which the web arm now shares instead of carrying a copy of the same body. The two risks the note lists: - Ordering. Every caller awaits the result: NewProjectForm (behind its `saving` flag), NoteTransferDialog, and RepoCard (behind `togglingPin`, which also keeps `unpin_repo` from racing the pin). `is_sq_available` is pure and cached. One new interleaving did exist: off the main thread, the refetches that each write's change event triggers are served while `create_project` is still running. The default branch is therefore resolved before the first write, as the web arm already does, so a slow `gh` no longer sits between the project and its first branch. Each write still publishes its own change, so the views converge. - The wrong pool. Nothing but `get_store` runs inline in the three async fns. The existing blocking guard only looks for child-process waits in commands that are already async, so a new source test, `offloaded_commands_run_only_the_handoff_on_the_runtime`, pins these three as `async fn` with nothing but the handoff outside `spawn_blocking`. Not changed: the web `create_project` arm, which differs from the Tauri command (no `sq` check, no prerun actions) and still runs its store writes inline; Fix B; and steps 3 to 7. The note's references were rechecked at 4cc7a044. The code matches its description; only line numbers moved (`is_sq_available` is at util_commands.rs:65, `create_project` at lib.rs:498, `pin_repo` at lib.rs:932, the `gh` timeout at git/github.rs:287). No test called any of the three command fns directly, so none needed a runtime. Verified, with the pinned 1.99.1 toolchain: - `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass. - `cargo test --workspace --lib --bins --tests`: 2095 passed, 2 failed. Both failures are 1 s shell-capture timeouts in `shell_env::capture_limit_tests`, hit at a machine load near 100. The module is untouched and all 16 of its tests pass when run alone. The failure stopped cargo before the other targets, so the app binary and `stagedctl` targets were run separately and pass. - New tests pass: the source test above, and two `pin_repo_impl` tests on an in-memory store. - The source test fails as intended when `pin_repo` is turned back into a plain `fn`, and when `is_sq_available` calls the lookup inline. The existing guard passes that second mutation. Not run: - The app itself. The freezes were not re-measured, and the note's perf verification steps could not be followed because the perf instrumentation they read (`STAGED_PERF`, `staged-perf.log`) is on 39e9ac12, which is not an ancestor of this branch. - `create_project_blocking` has no unit test. It needs a real `AppHandle` and creates the project directory under `~/.staged`. - Frontend checks. No frontend file changed. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 137 ++++++++++++++++-- apps/staged/src-tauri/src/util_commands.rs | 10 +- apps/staged/src-tauri/src/web_server.rs | 18 +-- .../src/web_server/blocking_guard_tests.rs | 49 +++++++ 4 files changed, 183 insertions(+), 31 deletions(-) diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d7f00dcbb..f3e3d5a03 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -493,9 +493,16 @@ fn list_projects( .map_err(|e| e.to_string()) } +/// 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 +515,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 +582,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 +614,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 +624,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 = @@ -928,21 +977,38 @@ fn update_repo_badge( }) } +/// 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}"); } } @@ -2599,8 +2665,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 +2893,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/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index e16b915b9..d3ec16b7d 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -61,9 +61,15 @@ pub fn open_url(app_handle: tauri::AppHandle, url: String) -> Result<(), String> /// /// 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. diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index c988ed5aa..00b3d3bdf 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1601,23 +1601,7 @@ 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)?; 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..7e18585bd 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 @@ -165,6 +165,55 @@ 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). +const OFFLOADED_COMMANDS: &[(&str, &str)] = &[ + ("util_commands.rs", "is_sq_available"), + ("lib.rs", "create_project"), + ("lib.rs", "pin_repo"), +]; + +/// What an [`OFFLOADED_COMMANDS`] body may call on the runtime: the store +/// handle lookup, and the mapping of the join error. +const OFFLOADED_COMMAND_INLINE: &[&str] = &["get_store", "map_err"]; + +/// 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. +#[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 inline = cut_offloaded(&body, &["spawn_blocking("]); + assert_ne!( + inline, body, + "`{command}` must run its body through `spawn_blocking`" + ); + let stray: Vec = calls(&inline) + .into_iter() + .map(|call| call.path) + .filter(|path| !OFFLOADED_COMMAND_INLINE.contains(&path.as_str())) + .collect(); + assert!( + stray.is_empty(), + "`{command}` runs these on a runtime worker; move them into its \ + `spawn_blocking` closure: {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`). From e0a9f2f0934ad598f6f0ce9d8ad65f796403d875 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 13:07:35 +1100 Subject: [PATCH 2/8] fix(staged): run the delete commands and hot store reads off the main thread Steps 3 and 4 of the recommended order in the synchronous-commands note, done as its Fix A and shipped together as the note asks. These 17 Tauri commands were plain `fn`s, so each ran on the main thread and froze the window for its own work and for any wait on the store lock: - deletes: `delete_note`, `delete_project_note`, `delete_session`, `delete_review`, `delete_pending_commit`, `delete_image` - reads: `list_projects`, `list_project_repos`, `get_all_repo_badges`, `list_branches_for_project`, `poll_session`, `get_session`, `get_session_messages`, `get_session_messages_since`, `get_active_sessions`, `list_project_notes`, `get_project_search_index` Each is now an `async fn` that only looks up its handles (the store, and for four of them the session registry) and hands its whole body to `tauri::async_runtime::spawn_blocking`, the pattern b3fd81e2 set. Three bodies were extracted for it: `delete_project_note_impl` and `delete_image_impl`, which the web arms now share instead of carrying copies, and `list_branches_for_project_impl`. The wrong pool - The source guard `offloaded_commands_run_only_the_handoff_on_the_runtime` now pins all 20 offloaded commands as `async fn` with nothing outside `spawn_blocking` but `get_store`, `Arc::clone` and the join-error mapping. - `dispatch` runs on the same runtime, and 13 of these commands' arms still made their store calls inline. They now go through `blocking_json` or `blocking_store_json`, with their behaviour unchanged (the `list_branches_for_project` arm still has no project check and no commit counts; the control-scope project filters still apply). The other four arms already did. - A second source test, `offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime`, holds the arms to the same rule. `create_project` is its one listed exception: b3fd81e2 left that arm's inline writes alone, and so does this commit. Ordering: deletes All eight frontend call sites were read. Six await the delete before anything that depends on it (BranchCard note, review, pending commit and image; ProjectSection project note; SessionLauncher session). The two draft-attachment removals (SessionChatPane, ImageAttachment) do not await it, but they drop the image from local state first and nothing reads after them. No read is issued behind an unawaited delete. Off the main thread, two interleavings are new: - Refetches triggered by a delete's first change event are now served while the delete is still running. `delete_note` removes the note, then scans for its media, then deletes or cancels the session. A refetch in between sees the note gone and the session still there, which the timeline does not show on its own. The session delete was not moved ahead of the scan, because the scan keeps media that the session's messages still reference. - The media cleanup checks references and then deletes, in separate store calls. Saves that run as synchronous commands (`create_note`, `update_note`) could not land inside that gap before; now they can, so a save that pastes an image into another note during the scan of a note being deleted can lose that image. Session threads and note transfers could already do this. Closing it needs a store change and is not part of this commit. Ordering: reads Synchronous commands answered in the order they were sent. On the pool two reads of the same thing can land in either order, so a call site with no latest-wins guard can apply an older response over a newer one. Every call site of the 11 reads was checked. The note's claim that projectsData.svelte.ts:1222-1231 guards the refetch path is only partly right: the generation there only discards responses from before a hard reload, and the in-flight queue beside it only serializes the branch refetch against itself. Guards added, through a small `LatestWins` helper that numbers requests when they are sent and refuses a response once a later-sent one has landed: - projectsData: the refresh, hydrate and branch-refetch paths shared no ordering for one project's branches, repos and the project list. A transfer's `replace` branch list is still always applied, since it alone drops the removed worktree paths. - ProjectSection: the project notes list had no guard. A list overtaken by a per-session row refetch is read again instead of applied. - BranchCard: the two project-note fetches behind its hashtag items. Already guarded, unchanged: repoBadges, ActionsSettingsPanel, the search index (single flight), the hashtag builders, `hydrateActiveSessions`, the PR, push and pull pollers, and paging older messages. Left as they are, for a follow-up: - SessionChatPane serializes its polls with `pollInFlight`, but `stopPolling` clears that flag, so a close and reopen inside one request's latency leaves two polls in flight, and a full load of a session that is already loaded can overlap a poll. An older response landing last can shrink the last row or put back an older status. The next tick repairs it while the session runs; if polling has just stopped it stays until the next open. - liveSessionHints has the same shape (`stopPolling` clears `hintPollInFlight`); it affects hint and title text only. - An older `get_active_sessions` snapshot can re-apply a stale background-hold flag until the next hydration. The web transport has always had these three, since it was never ordered. The note's references were rechecked at b3fd81e2. The code matches its description and only line numbers moved: `delete_note` is at note_commands.rs:142, `delete_project_note` :344, `list_project_notes` :286, `cleanup_unreferenced` note_media/mod.rs:357, `delete_session` session_commands.rs:3421, `get_active_sessions` :1206, `get_session` :1230, `get_session_messages` :1243, `get_session_messages_since` :1255, `poll_session` :1291, `delete_review` timeline.rs:1442, `delete_pending_commit` :1494, `delete_image` image_commands.rs:113, `list_projects` lib.rs:488, `list_project_repos` :724, `get_all_repo_badges` :899, `list_branches_for_project` branches.rs:1465, the session cascade store/sessions.rs:1137, `blocking_store_json` web_server.rs:811 and `blocking_json` :824. No test called any of the 17 command fns directly, so none needed a runtime. Verified, with the pinned 1.99.1 toolchain: - `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass on the final tree. - `cargo test --workspace --lib --bins --tests` at a machine load near 40: app lib 2097 passed, 1 failed; `stagedctl` and the other targets passed. The failure was the new arm test, which flagged the `is_sq_available` arm's `to_string` in its join-error mapping; that call is now allowed. After that fix and the new Rust tests, the `web_server`, `image_commands`, `note_commands` and `branches` test modules were rerun and pass. The full suite was not run a second time; no non-test Rust changed after it. - New Rust tests pass: two for `delete_image_impl`, one for `delete_project_note_impl`, two for `list_branches_for_project_impl`. - The command guard fails as intended when `delete_note` is turned back into a plain `fn` and when `list_projects` reads the store inline. The arm guard fails when the `delete_image` arm reads the store inline. - Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite (237 files, 3529 tests) pass. Three of the four new projectsData tests fail with the guard disabled; the fourth pins the `replace` exception. Not run or not covered: - The app itself. The freezes were not re-measured, and the note's perf verification steps could not be followed: the instrumentation they read is on 39e9ac12, which is not an ancestor of this branch. - The ProjectSection and BranchCard guards have no test of their own; only the helper and the projectsData paths do. - The delete bodies that need an `AppHandle` (`delete_note_impl`, `delete_review_impl`, `delete_pending_commit_impl`, `delete_session_impl`) were not changed and got no new tests. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/branches.rs | 53 ++++++- apps/staged/src-tauri/src/image_commands.rs | 45 +++++- apps/staged/src-tauri/src/lib.rs | 42 +++-- apps/staged/src-tauri/src/note_commands.rs | 45 ++++-- apps/staged/src-tauri/src/project_search.rs | 18 ++- apps/staged/src-tauri/src/session_commands.rs | 89 ++++++++--- apps/staged/src-tauri/src/timeline.rs | 52 +++++-- apps/staged/src-tauri/src/web_server.rs | 143 +++++++++--------- .../src/web_server/blocking_guard_tests.rs | 117 ++++++++++++-- .../lib/features/branches/BranchCard.svelte | 9 +- .../features/projects/ProjectSection.svelte | 30 +++- apps/staged/src/lib/shared/latestWins.test.ts | 47 ++++++ apps/staged/src/lib/shared/latestWins.ts | 41 +++++ .../lib/stores/projectsData.component.test.ts | 105 +++++++++++++ .../src/lib/stores/projectsData.svelte.ts | 67 ++++++-- 15 files changed, 731 insertions(+), 172 deletions(-) create mode 100644 apps/staged/src/lib/shared/latestWins.test.ts create mode 100644 apps/staged/src/lib/shared/latestWins.ts diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index d46553992..afa962194 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -1461,19 +1461,40 @@ pub fn get_branch( } } +/// 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 +1503,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()); @@ -3757,6 +3778,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/image_commands.rs b/apps/staged/src-tauri/src/image_commands.rs index df0b93cd2..90e22cd0a 100644 --- a/apps/staged/src-tauri/src/image_commands.rs +++ b/apps/staged/src-tauri/src/image_commands.rs @@ -109,19 +109,34 @@ pub fn get_image_path( } /// 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); @@ -321,6 +336,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/lib.rs b/apps/staged/src-tauri/src/lib.rs index f3e3d5a03..f1515f1ac 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -484,13 +484,20 @@ fn confirm_reset_store( // 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 @@ -720,15 +727,22 @@ fn create_project_blocking( 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")] @@ -895,13 +909,17 @@ 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")] diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 079529e59..c669647a6 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -144,6 +144,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 +156,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 +165,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 @@ -295,14 +298,21 @@ pub fn create_project_note( Ok(note) } +/// 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 @@ -350,7 +360,8 @@ pub fn set_project_note_read( /// /// 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 +370,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 +667,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/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/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 20f4937cb..353368394 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. @@ -1226,41 +1237,60 @@ pub fn list_child_sessions( .map_err(|e| e.to_string()) } +/// 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] @@ -1287,15 +1317,24 @@ pub fn get_session_acp_metadata_messages_since( /// 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 @@ -3417,14 +3456,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. 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/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 00b3d3bdf..e751b4274 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,10 +1270,7 @@ 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)?; @@ -1522,8 +1523,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 @@ -1921,17 +1921,18 @@ pub(crate) async fn dispatch( "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)?; @@ -3007,18 +3008,17 @@ pub(crate) async fn dispatch( 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)?; @@ -3062,17 +3062,11 @@ pub(crate) async fn dispatch( "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)?; @@ -3111,12 +3105,10 @@ pub(crate) async fn dispatch( 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" => { @@ -3272,13 +3264,7 @@ pub(crate) async fn dispatch( "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)?; @@ -3316,26 +3302,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)?; @@ -3560,14 +3553,19 @@ 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)?; @@ -4005,13 +4003,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 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 7e18585bd..40a53fe3d 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 @@ -172,11 +172,59 @@ 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"), ]; /// What an [`OFFLOADED_COMMANDS`] body may call on the runtime: the store -/// handle lookup, and the mapping of the join error. -const OFFLOADED_COMMAND_INLINE: &[&str] = &["get_store", "map_err"]; +/// 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", +]; /// 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 @@ -196,16 +244,12 @@ fn offloaded_commands_run_only_the_handoff_on_the_runtime() { "`{command}` must stay an `async fn`: a plain `fn` command runs on the main \ thread and freezes the window while it waits" ); - let inline = cut_offloaded(&body, &["spawn_blocking("]); - assert_ne!( - inline, body, + let (offloads, stray) = + left_on_runtime(&body, &["spawn_blocking("], OFFLOADED_COMMAND_INLINE); + assert!( + offloads, "`{command}` must run its body through `spawn_blocking`" ); - let stray: Vec = calls(&inline) - .into_iter() - .map(|call| call.path) - .filter(|path| !OFFLOADED_COMMAND_INLINE.contains(&path.as_str())) - .collect(); assert!( stray.is_empty(), "`{command}` runs these on a runtime worker; move them into its \ @@ -214,6 +258,59 @@ fn offloaded_commands_run_only_the_handoff_on_the_runtime() { } } +/// `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; + } + 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 { + assert!( + OFFLOADED_COMMANDS.iter().any(|(_, name)| name == command), + "`{command}` in ARMS_NOT_OFFLOADED is not an OFFLOADED_COMMANDS entry; remove it" + ); + } +} + +/// 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`). diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 8dc43b94e..b7a192492 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); }); }; diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 836e50b42..13cfd2d04 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,30 @@ 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. + */ + 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 +459,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 +476,7 @@ if (!superseded) latestNoteRefreshes.delete(sessionId); } if (superseded) return; + noteFetchOrder.admit('row', seq); if (updatedNote) { projectNotes = projectNotes.map((n) => (n.id === updatedNote.id ? updatedNote : n)); 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..4cfc9d041 --- /dev/null +++ b/apps/staged/src/lib/shared/latestWins.test.ts @@ -0,0 +1,47 @@ +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); + }); +}); diff --git a/apps/staged/src/lib/shared/latestWins.ts b/apps/staged/src/lib/shared/latestWins.ts new file mode 100644 index 000000000..66897f19e --- /dev/null +++ b/apps/staged/src/lib/shared/latestWins.ts @@ -0,0 +1,41 @@ +/** + * 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`. + */ +export class LatestWins { + private sent = 0; + private landed = new Map(); + + /** A ticket for a request about to be sent. */ + issue(): number { + return ++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; + } +} diff --git a/apps/staged/src/lib/stores/projectsData.component.test.ts b/apps/staged/src/lib/stores/projectsData.component.test.ts index e2ebe408b..43014d704 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(); diff --git a/apps/staged/src/lib/stores/projectsData.svelte.ts b/apps/staged/src/lib/stores/projectsData.svelte.ts index e9d6a71a0..8f6488d20 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, @@ -170,6 +171,14 @@ 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:`. + */ + private readOrder = new LatestWins(); private initialLoad: Promise | null = null; private revalidatePending = false; private revalidateQueued = false; @@ -427,6 +436,7 @@ class ProjectsDataStore { { 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,14 +444,20 @@ 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, { - replace: replaceBranches, - }); + if (this.readOrder.admit('projects', ticket)) { + this._projects = projectsResult.data; + } + const mergedBranches = this.applyProjectBranches( + 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); return true; } catch (e) { @@ -546,20 +562,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 +604,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 +685,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 +693,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 +708,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 +727,21 @@ class ProjectsDataStore { this._hydratedProjects = new Map(this._hydratedProjects).set(projectId, generation); } + /** + * Apply a fetched branch list, unless a newer load or a read sent after + * this one (`ticket`, see readOrder) has already landed. 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. + */ private applyProjectBranches( projectId: string, branches: Branch[], generation: number, + ticket: number, { replace = false }: { replace?: boolean } = {} ): Branch[] | null { if (generation !== this.loadGeneration || !this.hasProject(projectId)) return null; + if (!this.readOrder.admit(`branches:${projectId}`, ticket) && !replace) return null; const mergedBranches = replace ? branches @@ -718,8 +750,14 @@ class ProjectsDataStore { return mergedBranches; } - private applyProjectRepos(projectId: string, repos: ProjectRepo[], generation: number): void { + 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 +1258,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( From 92ee60e23ea132be18f5b71b5e6c6526a22020e6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 14:12:26 +1100 Subject: [PATCH 3/8] fix(staged): run the hot note, chat-read and queue writes off the main thread Step 6 of the recommended order in the synchronous-commands note, done as its Fix A. These five Tauri commands were plain `fn`s, so each ran on the main thread and froze the window for its own write and for any wait on the store lock: - `create_note`, `update_note`, `set_note_read` - `mark_session_chat_read`, `queue_session_message` Each is now an `async fn` that only looks up the store handle and hands its whole body to `tauri::async_runtime::spawn_blocking`, the pattern b3fd81e2 and b3998035 set. No body changed and none was extracted: the note commands already shared `create_standalone_note` and `update_note_impl` with the web arms, and the other three are one store call each. The wrong pool - `OFFLOADED_COMMANDS` gains the five, so `offloaded_commands_run_only_the_handoff_on_the_runtime` now pins 25 commands as `async fn` with nothing outside `spawn_blocking` but `get_store`, `Arc::clone` and the join-error mapping. - All five `dispatch` arms made their store call inline on a runtime worker. They now go through `blocking_json` or `blocking_store_json` with their behaviour unchanged, and `offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime` holds them to it. `ARMS_NOT_OFFLOADED` is unchanged. Ordering: the audit Synchronous commands ran one at a time in the order they arrived. On the pool two writes in flight land in either order. Every frontend call site of the five was read (seven sites, all through the `commands.ts` wrappers; nothing invokes these commands directly): - `update_note`: BranchCard.svelte:1159, the written-note editor's save. The Save button holds `saving`, but the editor's save before a transfer (`saveBeforeTransfer` in WriteNoteModal) does not, so Cmd+Enter during it sends a second save of the same note. Exposed: the older save could land last and stay. - `create_note`: BranchCard.svelte:1161 (editor, behind `saving`), ActionOutputModal.svelte:152 (awaited), and BranchCard.svelte:1884, a file drop, which creates one note per file under `Promise.all`. Exposed: the drop's notes could be created in any order, which is the order the timeline lists them in and the order same-titled ones get their ` (2)`, ` (3)` suffixes in. The store call is one lock hold, so two creates still cannot pick the same suffix. - `set_note_read`: noteReadActions.ts:47, reached from the viewer's mark-on-open and mark-on-revision (noteReadTracker, not awaited), its toggle (awaited, single flight), the editor's mark-on-open (BranchCard.svelte:1141, not awaited) and the timeline context menu (BranchCard.svelte:2287, not awaited). Exposed: "Mark as unread" and the read mark of opening the note straight after, and the viewer's toggle to unread against its read mark for a revision. The reversed order leaves the note in the wrong state, and the project unread flag kept beside it follows the responses. - `mark_session_chat_read`: NoteModal.svelte:567, sent when a chat opens and again when it closes, awaiting neither. - `queue_session_message`: SessionChatPane.svelte:1866. The queue path does not hold `sending`, so a second message can be sent while the first is still being queued. Exposed: the queue drains by a stamp taken when the command body starts, so the second message could be queued, and later sent to the agent, ahead of the first. The three pairs the note lists: - Successive `update_note` saves: exposed, as above. - `mark_session_chat_read` against `set_note_read`: both are sent, unawaited, when the viewer opens, but they commute. One writes `sessions.chat_read_at` and the other `notes.read_at`, and neither reads the column the other writes. - `queue_session_message` followed by `list_queued_session_messages`: the one call site awaits the write before it lists. Ordering: the fix The exposed pairs are serialised on the frontend. The backend cannot restore the order: an async command first runs on a runtime worker, after the order of arrival is gone, and HTTP requests never had one. - New `SendOrder` helper (`src/lib/shared/sendOrder.ts`): `run(key, send)` holds a request back until the one sent before it for the same key has settled, however it settled. With nothing in flight for the key it sends in the caller's own tick, so an uncontended call behaves as before. - The five wrappers in `commands.ts` send through one instance. Keys: a note's saves and read marks share `note:`; creates use `branch-notes:`; chat-read marks `chat-read:`; queued messages `queue:`. Different keys never wait on each other. - `deleteNote` joins its note's key. `delete_note` left the main thread in b3998035, when the writes before it were still synchronous and so finished first. With them on the pool, an unawaited "Mark as unread" could land after a delete sent behind it, fail on the missing note and toast an error for a note the user had just deleted. Because the order is kept in the wrappers, the web transport gets it too. Residual ordering risk - The order is per window. Two windows, or a window and a web client, were never ordered against each other and still are not. - Only the six wrappers above are ordered, and only within a key. A different command sent while one of these writes is in flight is not held back for it, where the main thread used to finish the write first. The cases found: - A message queued and then Stop (`cancel_session`, still synchronous). If the stop lands first the queue write is refused, since the row is no longer running; the composer reports "Failed to queue" and puts the draft back. Stop was not put behind the queue key, so that a slow queue write cannot delay it. - The viewer's read mark and its own row refetch (`get_note`, still synchronous). A refetch that read before the mark and answers after it shows the toggle as unread until the next refetch, which the mark's own change event triggers. - A read mark still in flight when its note is moved to another project: the moved note can arrive unread. - A write that never settles holds back the later writes for its key. As a synchronous command it held back every command. - Not new, and not changed: the chat pane can apply a queued-message list from an older poll over a newer one, as b3998035 recorded for its polls. The next 500 ms poll repairs it. The note's references were rechecked at b3998035. The code matches its description and only line numbers moved: `create_note` was at note_commands.rs:74, `update_note` :110, `set_note_read` :236, `mark_session_chat_read` session_commands.rs:1416 and `queue_session_message` :2851; the frontend sites the note gives as BranchCard.svelte:1126 and NoteModal.svelte:498 are at :1159 and :567. No test called any of the five command fns directly, so none needed a runtime. Verified, with the pinned 1.99.1 toolchain: - `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass. - `cargo test --workspace --lib --bins --tests` at a machine load near 40: app lib 2103 passed, 0 failed; `stagedctl` (106) and the other targets passed. That was the second run. The first was killed when the disk filled (os error 28) after 926 tests had passed; its failures were all in the disk-heavy `project_transfer` tests, which pass in the second run. The first clippy run also stopped on an environmental failure, in the vendored OpenSSL build script, before it reached this crate, and passed on a retry. - The command guard fails as intended when `set_note_read` is turned back into a plain `fn`, when `queue_session_message` reads the store inline, and when `update_note` drops its `spawn_blocking`. The arm guard fails when the `set_note_read` arm calls the store inline. Both pass again with the code restored. - Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite (238 files, 3544 tests) pass. New tests: six for `SendOrder` and nine for the wrappers, one per ordered pair plus failure and independence. Eight of them fail with the queue disabled. Not run or not covered: - The app itself. The freezes were not re-measured, and the note's perf verification steps could not be followed: the instrumentation they read is on 39e9ac12, which is not an ancestor of this branch. - No test drives the real call sites into the races. The wrapper tests pin the send order with a hand-settled transport; the components (WriteNoteModal, the chat composer, the file drop) are not exercised. - No new Rust unit tests. The five bodies are unchanged; only the source guards cover the handoff. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/note_commands.rs | 56 ++++++- apps/staged/src-tauri/src/session_commands.rs | 50 ++++-- apps/staged/src-tauri/src/web_server.rs | 35 ++-- .../src/web_server/blocking_guard_tests.rs | 8 + apps/staged/src/lib/commands.test.ts | 158 ++++++++++++++++++ apps/staged/src/lib/commands.ts | 92 ++++++++-- apps/staged/src/lib/shared/sendOrder.test.ts | 111 ++++++++++++ apps/staged/src/lib/shared/sendOrder.ts | 49 ++++++ 8 files changed, 507 insertions(+), 52 deletions(-) create mode 100644 apps/staged/src/lib/shared/sendOrder.test.ts create mode 100644 apps/staged/src/lib/shared/sendOrder.ts diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index c669647a6..36b6debad 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. @@ -234,15 +263,26 @@ pub fn get_branch_note_by_session( /// /// 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. diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 353368394..06affdb7f 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -1412,14 +1412,26 @@ pub fn count_assistant_messages_after( /// 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}"))? } // ============================================================================= @@ -2847,8 +2859,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, @@ -2856,14 +2876,18 @@ 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] diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index e751b4274..a36802fd0 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2991,18 +2991,22 @@ 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)?; @@ -3031,10 +3035,7 @@ pub(crate) async fn dispatch( 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)?; @@ -3616,10 +3617,8 @@ pub(crate) async fn dispatch( "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)?; @@ -3765,15 +3764,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)?; 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 40a53fe3d..c400b1c63 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 @@ -193,6 +193,14 @@ const OFFLOADED_COMMANDS: &[(&str, &str)] = &[ ("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"), ]; /// What an [`OFFLOADED_COMMANDS`] body may call on the runtime: the store diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 705bab6c6..d717c3b8a 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -1047,3 +1047,161 @@ 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 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'); + await flush(); + + expect(sent).toHaveLength(8); + }); +}); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index f8e5ada70..a6d404926 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,27 @@ import type { export type DiffScope = 'branch' | 'commit' | 'worktree'; +/** + * 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. + * + * Only these wrappers are ordered. A command sent through another wrapper + * while one of these is in flight is not held back for it. + */ +const writeOrder = new SendOrder(); + +/** 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[]; @@ -481,9 +503,15 @@ 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}. */ @@ -1464,9 +1492,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 +1545,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 +1753,11 @@ 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}). 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. */ export function createNote( branchId: string, @@ -1716,7 +1765,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 +1777,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 }) + ); } /** 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..f1853fbf8 --- /dev/null +++ b/apps/staged/src/lib/shared/sendOrder.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } 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']); + }); +}); diff --git a/apps/staged/src/lib/shared/sendOrder.ts b/apps/staged/src/lib/shared/sendOrder.ts new file mode 100644 index 000000000..faf46295f --- /dev/null +++ b/apps/staged/src/lib/shared/sendOrder.ts @@ -0,0 +1,49 @@ +/** + * 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, as a + * synchronous command that never returned held back every command. + */ +export class SendOrder { + private tails = new Map>(); + + /** Send through `send` once every earlier request for `key` has settled. */ + run(key: K, send: () => Promise): Promise { + const earlier = this.tails.get(key); + const result = earlier + ? earlier.then(send) + : // Inside an executor so a `send` that throws rejects like one that + // fails later, and still releases the key. + new Promise((resolve) => resolve(send())); + const tail = result.then(noop, noop); + this.tails.set(key, tail); + void tail.then(() => { + if (this.tails.get(key) === tail) this.tails.delete(key); + }); + return result; + } + + /** How many keys have a request in flight or waiting. */ + get pendingKeys(): number { + return this.tails.size; + } +} + +function noop(): void {} From 5b095c7fb6687e1b48eb8eb5c5ec1f4fbd5ad6ca Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:33:14 +1100 Subject: [PATCH 4/8] fix(staged): run the remaining blocking commands off the main thread Step 7 of the recommended order in the synchronous-commands note, which completes its Fix A. The request was to leave the in-process commands synchronous behind a source-scan test, and, so that test passes honestly, to first convert every other still-synchronous command, with the frontend ordering audit applied to each write. Fix B (step 5) is not touched. The list was re-derived from HEAD. Of 239 registered commands, 94 were plain `fn`s, which run on the main thread and freeze the window for their own work and for any wait on the store lock. 25 only touch state in memory (the note counted 24). 69 reach the store, a child process or the filesystem: 68 are converted here and one, `cancel_session`, is allowlisted. Converted Each is now an `async fn` that only looks up its handles and hands its whole body to `tauri::async_runtime::spawn_blocking`, the pattern of b3fd81e2, b3998035 and 013d1e4f. - tags (13): `get_tag_state` and the twelve tag and tag-group writes - repos and badges (11): `list_recent_repos`, `get_suggested_repos`, `clear_project_repo_reason`, `set_primary_project_repo`, `update_project_repo_branch_name`, `update_repo_badge`, `delete_repo_badge`, `unpin_repo`, `reorder_pinned_repos`, `set_repo_default_branch`, `detect_default_branch` - actions (8): `list_project_actions`, `update_project_action`, `delete_project_action`, `list_action_contexts`, `list_repo_actions`, `create_repo_action`, `delete_all_repo_actions`, `delete_action_context` - branches and PRs (5): `get_branch`, `create_branch`, `update_branch_pr`, `clear_branch_pr_status`, `cancel_deferred_push` - notes (8): `get_note`, `get_branch_note_by_session`, `list_child_notes`, `get_note_repo`, `create_project_note`, `get_project_note`, `get_project_note_by_session`, `set_project_note_read` - sessions (11): `list_child_sessions`, `get_session_acp_metadata_messages` and its `_since` form, `get_tool_image`, `get_tool_call_body`, `get_session_acp_initialization`, `count_assistant_messages_after`, `list_queued_session_messages`, `delete_queued_session_message`, `get_session_live_delivery`, `queue_branch_session` - images and files (6): `get_image_path`, `list_branch_images`, `get_image_data`, `read_image_file`, `read_text_file`, `resolve_path_aliases` - app (6): `confirm_reset_store`, `resume_interrupted_sessions`, `quit_app`, `list_control_grants`, `revoke_control_grant`, `open_url` Most bodies moved unchanged. The ones that did not: - `read_image_file` wraps a new `read_image_file_impl`, which its web arm and tests call. `resolve_path_aliases` wraps the `_impl` it already had and now returns a `Result` for the join error. - `confirm_reset_store`, `resume_interrupted_sessions`, `list_control_grants` and `revoke_control_grant` look up their managed state from the `AppHandle` inside the closure, since a `State` borrow cannot move into it. `resume_interrupted_sessions` still accepts a dismissal without a store. - `quit_app` runs `request_quit` on the pool. Its prompt flags are atomics, the dialog plugin hops to the main thread itself, and the cleanup runs at most once. The menu and Cmd+Q paths still call it on the main thread. - `Store::get_or_create_action_context` now looks up and inserts under one lock hold. With the action lists on the pool, the branch cards of a repo with no context row could each miss it, and every insert after the first failed on the unique index and left that card's action bar empty. New store test: eight threads, lined up behind a barrier before each of 400 fresh contexts, all get the same row. Left synchronous - The 25 in-process commands, listed by name with what each touches in the new `SYNCHRONOUS_COMMANDS`: the running-action registry and executor, the PR poll scheduler hints, the transfer and preflight registries, the pending control authorizations and their answer, the launch-resume offer, the store-status flag, the session hold and reconcile states, two computed paths, an environment variable, the window seed and the updater claim. - `cancel_session`, the one `SYNCHRONOUS_BLOCKING_ALLOWED` entry. For a registered turn Stop only fires the cancellation token. With no runner it reads the row and, if it is still live, writes `cancelled`, skips the pipeline dependents and kicks the queue: several store lock waits on the main thread. It stays there for its ordering. The registry and that write are keyed by session id, not by turn, and the main thread finishes a Stop before any command sent after it is dispatched. When a turn ends by itself as Stop is pressed and the next message is sent straight after, a Stop on the pool that lost the store lock to that resume would cancel the new turn, or write `cancelled` over a row the resume had claimed but not yet registered. Converting it needs a turn token in the registry and the write, or Stop and every turn-starting command for a session sent in order by the frontend. The source guards - New test `synchronous_commands_touch_only_in_process_state`. Every plain `fn` command must be in one of the two lists. A listed in-process command may not reach the store, a child process, the filesystem, the preferences file or the opener, in its signature, its body or the crate fns it calls inline (the existing walk, run for the main thread). An allowlisted one must still reach something, and a stale entry fails. The walk does not follow method calls, which is why the commands are listed by name: a new synchronous command fails until someone has read what it calls. - `OFFLOADED_COMMANDS` gains the 68, so `offloaded_commands_run_only_the_handoff_on_the_runtime` now pins 93. - 55 `dispatch` arms made their store or filesystem calls inline on a runtime worker. They now go through `blocking_json` with their behaviour unchanged. Seven arms were already offloaded. Six refuse in web mode and do no work (`quit_app`, `confirm_reset_store`, `resume_interrupted_sessions`, `list_control_grants`, `revoke_control_grant`, `open_url`): they are in the new `ARMS_WITH_NOTHING_TO_OFFLOAD`, which fails if one starts doing work. `ARMS_NOT_OFFLOADED` is unchanged, and the `cancel_session` arm is not touched. Ordering: the audit Synchronous commands ran one at a time in the order they arrived; on the pool two in flight land, and answer, in either order. Every frontend call site of the 68 was read. All go through the `commands.ts` wrappers. Exposed writes, now sent through `writeOrder` (013d1e4f's `SendOrder`): - `update_repo_badge` after itself and against `delete_repo_badge`. The settings panel saves as the name and hue are edited, without waiting for the save before, and each save carries both. Key: `repo-badge:\0`. - `reorder_pinned_repos` after itself: each call carries the whole order. Key: `pinned-repos`. - `set_project_note_read` after itself ("Mark as unread", unawaited, then the viewer's read mark) and against `delete_project_note`, which left the main thread in b3998035. Key: `project-note:`, the counterpart of `note:`. - The twelve tag writes. `tagActions` chains catalog edits and each project's assignments separately, and only the main thread ordered an assignment in flight against a move or delete of its tag. One key, `tags`, keeps the order they are sent in. Exposed reads, where a response sent earlier could land last and stay. Each now keeps the response of the request sent last (b3998035's `LatestWins`): - `list_repo_actions` and `list_action_contexts` in the repo settings panel. Picking repo A and then B could list A's actions under B, where Edit and Delete act on A's action ids. - `list_project_actions` in `ActionRunner`, which every action bar uses. An adopted detection result also outranks reads in flight. - `list_queued_session_messages` in the chat pane. The load, the polls and the refreshes now share one order, and a refresh is dropped when the pane has moved to another session. That covers the stale queued list b3998035 and 013d1e4f recorded as not new. - `list_control_grants` in the connected-apps panel, one load per change event. - `get_project_note_by_session`: the row refetch now yields to a list load sent after it. Not exposed: the action writes (awaited behind the edit dialog's `saving` flag, or idempotent deletes), `create_project_note`, `cancel_deferred_push`, `delete_queued_session_message`, `unpin_repo`, `revoke_control_grant`, `confirm_reset_store`, `resume_interrupted_sessions`, `quit_app`, `open_url`, and the reads whose call sites already guard their responses. No frontend caller: `create_branch`, `queue_branch_session`, `detect_default_branch`, `update_branch_pr`, `clear_branch_pr_status`, `update_project_repo_branch_name`, `set_primary_project_repo`, `set_repo_default_branch`, `clear_project_repo_reason`, `get_image_path`, `list_branch_images`, `get_session_acp_metadata_messages_since`, `get_session_acp_initialization`. Residual risk - The order is per window and per key. Two windows, a web client and stagedctl were never ordered against each other and still are not. - `cancel_session` can still freeze the window on its no-runner path, for as long as the store lock is held by someone else. - `get_store_status` stays synchronous and takes the mutex a store reset holds, so a window opened during a reset waits for it. The reset itself froze every window before. - `get_branch_note_by_session` when opening a session's note: two opens in quick succession can show the one clicked first. Not guarded. - The chat pane's load still reads ACP metadata beside the polls, the other half of the gap b3998035 recorded. The next poll repairs it. - A list of a repo's actions in flight while its context is deleted re-creates an empty context, since the lists get-or-create it. - Two saves of different badges with the same short name can both pass the duplicate check. One user edits one badge at a time. - A tag write that never settles holds back later tag writes. As a synchronous command it held back every command. - The new scan reads text. It does not follow method calls and knows the blocking calls by the markers listed in the test. Verified, with the pinned 1.99.1 toolchain: - `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass. - `cargo test --workspace --no-fail-fast --lib --bins --tests` at a machine load near 20: app lib 2105 passed, 0 failed; `stagedctl` (106) and `help_pins` (1) passed. That was the third run. The first was killed when the disk filled (os error 28). The second passed 2104 and failed `personal_overrides_cannot_mask_missing_native_binary`, which re-runs its own test binary: the shared cargo target directory was emptied in the run's last seconds and the spawn got "No such file or directory". It passes in the third run, on a rebuilt target. - The store test was strengthened after that run. `store::tests` (186), the four guard tests, fmt and clippy were rerun with it and pass. With the lookup and the insert back under separate lock holds it fails 20 runs of 20 on the unique index; with the fix it passes 30 of 30. Its first form, a single round, passed with the fix reverted. - `synchronous_commands_touch_only_in_process_state` fails as intended when `get_note` is turned back into a plain `fn`; when a listed in-process command (`get_blox_env`) takes the store, spawns a process or calls a crate fn that reaches the filesystem; when it is renamed or made `async`; and for a new unlisted plain `fn` command, whether it reads the store or reaches nothing. - The command guard fails when `get_note` drops its `spawn_blocking`. The arm guard fails when the `get_note` arm reads the store inline and when the `open_url` arm starts doing work. All four guard tests pass again with the code restored. - Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite (238 files, 3553 tests) pass. Nine new tests: six for the ordered wrappers, two for `ActionRunner` and one for the connected-apps panel. With `SendOrder` made to send at once the six fail, beside the seven older ordering tests; with `LatestWins` admitting every response the other three fail. Not run: - The app itself. The freezes were not re-measured, and the note's "done when" check (no `blocking-on-main-thread` or `main-thread-stall` line across launch, project create, note move and note delete) is still to do. The web transport and stagedctl were not exercised live. - No component test drives the repo settings panel's or the chat pane's new response guards; `LatestWins` itself and the runner and connected-apps guards are tested. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/app_lifecycle.rs | 13 +- apps/staged/src-tauri/src/branches.rs | 94 +- .../src-tauri/src/control_server/commands.rs | 51 +- .../src-tauri/src/control_server/consent.rs | 2 +- apps/staged/src-tauri/src/image_commands.rs | 60 +- .../src/image_commands/tool_results.rs | 21 +- .../staged/src-tauri/src/launch_resume/mod.rs | 70 +- apps/staged/src-tauri/src/lib.rs | 570 +++++++----- apps/staged/src-tauri/src/note_commands.rs | 98 ++- apps/staged/src-tauri/src/note_transfer.rs | 6 +- apps/staged/src-tauri/src/prs/commands.rs | 29 +- apps/staged/src-tauri/src/session_commands.rs | 144 ++- apps/staged/src-tauri/src/store/actions.rs | 17 +- apps/staged/src-tauri/src/store/tests.rs | 45 + apps/staged/src-tauri/src/tag_commands.rs | 171 ++-- apps/staged/src-tauri/src/util_commands.rs | 61 +- apps/staged/src-tauri/src/web_server.rs | 830 +++++++++++------- .../src/web_server/blocking_guard_tests.rs | 391 ++++++++- apps/staged/src/lib/commands.test.ts | 107 ++- apps/staged/src/lib/commands.ts | 96 +- .../features/actions/actionRunner.svelte.ts | 15 +- .../lib/features/actions/actionRunner.test.ts | 33 + .../features/projects/ProjectSection.svelte | 2 + .../features/sessions/SessionChatPane.svelte | 24 +- .../settings/ActionsSettingsPanel.svelte | 26 +- .../ConnectedAppsPanel.component.test.ts | 21 + .../settings/ConnectedAppsPanel.svelte | 11 +- .../lib/features/tags/tagActions.svelte.ts | 12 +- 28 files changed, 2112 insertions(+), 908 deletions(-) diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 5ac230f0a..d812f641d 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -439,9 +439,18 @@ 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 gate is safe off it: the +/// prompt flags are atomics, the alert hops to the main thread itself, and +/// the cleanup runs at most once however many quits race. The menu and +/// `Cmd+Q` paths still call [`request_quit`] on the main thread. #[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 diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index afa962194..4f03a3c1c 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -1440,25 +1440,29 @@ 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 @@ -1525,7 +1529,7 @@ fn list_branches_for_project_impl( /// 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, @@ -1533,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`]. 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 90e22cd0a..f2b43fb62 100644 --- a/apps/staged/src-tauri/src/image_commands.rs +++ b/apps/staged/src-tauri/src/image_commands.rs @@ -94,18 +94,22 @@ 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. @@ -159,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 @@ -228,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). 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 f1515f1ac..efa1475cc 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -448,36 +448,50 @@ 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}"))? } // ============================================================================= @@ -746,27 +760,35 @@ async fn list_project_repos( } #[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)] @@ -810,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")] @@ -841,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] @@ -923,76 +957,85 @@ async fn get_all_repo_badges( } #[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 @@ -1036,24 +1079,34 @@ pub(crate) fn pin_repo_impl( } #[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")] @@ -1082,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. @@ -1134,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. @@ -1555,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, @@ -1590,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}"))?; - store - .update_repo_action(&updated) - .map_err(|e| e.to_string()) + 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()) + }) + .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 @@ -1669,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, @@ -1681,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}"))? } // ============================================================================= diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 36b6debad..71c6bb7b7 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -250,13 +250,18 @@ 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. @@ -291,13 +296,17 @@ pub async 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 @@ -305,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}"))? } // ============================================================================= @@ -324,18 +337,22 @@ 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 @@ -363,37 +380,52 @@ pub async 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. 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/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 06affdb7f..b4152bc83 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -1228,13 +1228,18 @@ pub async 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 @@ -1294,25 +1299,35 @@ pub async fn get_session_messages_since( } #[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 @@ -1341,14 +1356,18 @@ pub async fn poll_session( /// 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( @@ -1372,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( @@ -1390,24 +1413,34 @@ 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 @@ -2891,23 +2924,33 @@ pub async fn queue_session_message( } #[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 @@ -2989,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 @@ -5960,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, @@ -5972,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/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index d3ec16b7d..f84ec489b 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -49,12 +49,16 @@ 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. @@ -78,27 +82,38 @@ pub async fn is_sq_available() -> Result { /// 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 a36802fd0..c745d5bbc 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1275,19 +1275,25 @@ pub(crate) async fn dispatch( "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)?; @@ -1321,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")?; @@ -1360,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")?; @@ -1400,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)?; @@ -1465,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)?; @@ -1476,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 } // ===================================================================== @@ -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 } // ===================================================================== @@ -1607,18 +1668,24 @@ pub(crate) async fn dispatch( 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)?; @@ -1642,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)?; @@ -1903,20 +1973,23 @@ 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)?; @@ -1941,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")?; @@ -2638,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)?; @@ -2672,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)?; @@ -2735,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 } // ===================================================================== @@ -3027,9 +3127,12 @@ pub(crate) async fn dispatch( "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)?; @@ -3040,25 +3143,31 @@ pub(crate) async fn dispatch( "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)?; @@ -3072,35 +3181,47 @@ pub(crate) async fn dispatch( "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)?; @@ -3115,8 +3236,11 @@ pub(crate) async fn dispatch( "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)?; @@ -3227,40 +3351,47 @@ 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)?; @@ -3280,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)?; @@ -3571,10 +3705,13 @@ pub(crate) async fn dispatch( "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)?; @@ -3609,10 +3746,13 @@ 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)?; @@ -3646,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. @@ -3782,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)?; @@ -3827,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)?; @@ -4065,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)?; @@ -4131,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)?; @@ -4279,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 } // ===================================================================== @@ -4339,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 c400b1c63..07b48f4bb 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,11 @@ //! `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. use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; @@ -87,7 +92,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})"), @@ -201,6 +206,95 @@ const OFFLOADED_COMMANDS: &[(&str, &str)] = &[ ("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 @@ -234,6 +328,17 @@ const ARMS_NOT_OFFLOADED: &[&str] = &[ "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 @@ -243,7 +348,7 @@ 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) + let (_, is_async, _, body) = command_fns(code) .into_iter() .find(|(name, ..)| name == command) .unwrap_or_else(|| panic!("{path} no longer defines the `{command}` command")); @@ -287,6 +392,14 @@ fn offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime() { ); 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`, \ @@ -298,14 +411,213 @@ fn offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime() { its blocking closure: {stray:?}" ); } - for command in ARMS_NOT_OFFLOADED { + for command in ARMS_NOT_OFFLOADED + .iter() + .chain(ARMS_WITH_NOTHING_TO_OFFLOAD) + { assert!( OFFLOADED_COMMANDS.iter().any(|(_, name)| name == command), - "`{command}` in ARMS_NOT_OFFLOADED is not an OFFLOADED_COMMANDS entry; remove it" + "`{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", "get_action_output_buffer"), + ("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. @@ -338,6 +650,7 @@ struct FnItem { is_async: bool, /// Takes `self`, so it is reachable only as a method call. is_method: bool, + params: String, body: String, } @@ -427,6 +740,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 { @@ -663,6 +1019,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(), }) }) @@ -670,15 +1027,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() } @@ -794,6 +1157,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 d717c3b8a..3e5101e68 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -1173,6 +1173,105 @@ describe('writes sent without awaiting keep their send order', () => { 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 @@ -1200,8 +1299,14 @@ describe('writes sent without awaiting keep their send order', () => { 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(8); + expect(sent).toHaveLength(14); }); }); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index a6d404926..c7bc1251b 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -89,6 +89,12 @@ export type DiffScope = 'branch' | 'commit' | 'worktree'; * 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. */ @@ -486,8 +492,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 }) + ); } /** @@ -514,12 +526,18 @@ export function setNoteRead(noteId: string, read: boolean): Promise ); } -/** 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 }) + ); } /** @@ -2655,18 +2673,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 }) + ); } // ============================================================================= @@ -2677,6 +2714,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, { @@ -2687,36 +2736,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'); } @@ -2732,7 +2781,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, @@ -2748,18 +2797,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'); } @@ -2769,7 +2818,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'); } @@ -2779,7 +2828,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'); } @@ -2797,9 +2846,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..ed9e954d0 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.admit('actions', this.loadOrder.issue()); 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/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 13cfd2d04..06033b945 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -476,6 +476,8 @@ 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) { 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/settings/ActionsSettingsPanel.svelte b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte index 5b955dab6..5a784b77c 100644 --- a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte @@ -33,6 +33,7 @@ import { navigation } from '../layout/navigation.svelte'; import { consumeRepoSettingsTarget } from './repoSettingsTarget'; import { viewport, watchViewport } from '../../shared/viewport.svelte'; + import { LatestWins } from '../../shared/latestWins'; type RepoAttachment = { projectId: string; @@ -254,11 +255,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,20 +291,32 @@ sidebarEl?.querySelector('.context-item.selected')?.scrollIntoView({ block: 'nearest' }); } + /** + * The context and action reads run concurrently on the backend, so two sent + * in a row can answer in either order. Without this, picking repo A and then + * B could leave A's actions listed under B, where Edit and Delete act on + * A's action ids. + */ + const loadOrder = new LatestWins<'contexts' | 'actions'>(); + async function loadActions() { + const ticket = loadOrder.issue(); if (!selectedContext) { + // Nothing to read, but a read still in flight must not fill the list. + loadOrder.admit('actions', ticket); actions = []; return; } loadingActions = true; try { - actions = await commands.listRepoActions( + const loaded = await commands.listRepoActions( selectedContext.githubRepo, selectedContext.subpath ?? undefined ); + if (loadOrder.admit('actions', ticket)) actions = loaded; } catch (e) { console.error('Failed to load actions:', e); - actions = []; + if (loadOrder.admit('actions', ticket)) actions = []; } finally { loadingActions = false; } 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..9599ffea4 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts @@ -148,6 +148,27 @@ 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('revokes behind a confirm and drops the row', async () => { commands.listControlGrants.mockResolvedValue([grant(), grant({ id: 'g-2', client: 'other' })]); await render(); diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte index b11009046..5504cdf23 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,11 +32,19 @@ let revoking = $state(false); let mounted = true; + /** + * Each `control-grants-changed` sends a load, and the backend reads them + * concurrently: the load sent for the latest change stays, whichever + * answers last. + */ + const loadOrder = new LatestWins<'grants'>(); + async function load() { + const ticket = loadOrder.issue(); loading = true; try { const list = await commands.listControlGrants(); - if (!mounted) return; + if (!mounted || !loadOrder.admit('grants', ticket)) return; grants = list; error = null; } catch (e) { 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'; From c9b2566cac5da585ae9d0d6f1502b4a6356784ee Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:21:59 +1100 Subject: [PATCH 5/8] fix(staged): close the ordering gaps review 39e6a9f3 found in Fix A Applies the findings of review 39e6a9f3 of d490643d, the last of the Fix A commits that moved the blocking Tauri commands off the main thread. Each finding was checked against the code first. All six are real. Five are fixed here, each with a test that fails without the fix. The sixth, the guard gap, is left in the code as asked, and only the docs that overstated the guards are corrected. Fix B is not touched. Findings, as verified - Multi-file drop order (BranchCard.svelte). Real, a regression from d490643d. `read_text_file` became async, each note was created as soon as its own file had been read, and `writeOrder` keeps only the order `createNote` is called in. A small file dropped after a large one was created first. - Quit gate (app_lifecycle.rs). Real, a regression from d490643d. `request_quit` checks the pending-prompt flag, snapshots the blockers and then sets the flag. With `quit_app` on the pool, two requests could both pass the check, and both raised an alert. The doc comment called that safe because the flags are atomics. - Reads over local writes. `LatestWins` only orders reads against reads, so a read sent before a write landed is admitted after the write's local update. - ActionsSettingsPanel.svelte: real. A deleted action came back and stayed, since the panel does not reload after a delete. - ProjectSection.svelte and `setBranchesByProject` in projectsData.svelte.ts: real, as a flicker. The deleted project note, or the old branch name or status, came back until the write's change event refetched. - noteReadActions.ts: real, but not new. "Mark as unread" flags the project only after it has found the note's project, which for a branch no view has loaded is a `get_branch` call. A read mark landing during that call found no flag to release, and the flag set afterwards stayed. The synchronous commands answered in the same order, so this predates the branch. Fixed anyway, as asked. - ConnectedAppsPanel.svelte. Real. The failure path did not check its ticket and `finally` cleared `loading` for any load. - SendOrder on the web transport. Real. `fetch` has no timeout, so one hung request held back every later write for its key until a reload. - Guards (blocking_guard_tests.rs). Real, and not a regression. Commands that were already `async` still call the store inline on a runtime worker: `ensure_review`, `find_review` and `add_comment` were read to confirm, with the `ensure_repo_badges`, `list_repos_for_home` and `list_all_repo_actions` arms. No guard test looks at them. Fixes - Drop order. `addTextFileNotes` reads every file first and then calls `createNote` in drop order. A file that cannot be read is now reported once all the reads are back, not as soon as its own read fails. - Quit gate. The whole check-then-act moved into `QuitState::decide`, which holds a new `gate` mutex and returns what to do: force, quit, ask, or nothing. `request_quit` acts on the result after the lock is released, so the lock is never held across the alert's hop to the main thread. Two racing requests are decided one after the other, as on the main thread: the first asks and an explicit second one forces. The blocker snapshot reads the store under the gate, so a Cmd+Q on the main thread can wait for a pooled request's read. Cmd+Q already makes that read on the main thread itself. The doc comments on `quit_app` and `request_quit` now say this. - Reads over local writes. A local write is recorded as a landing under a key of its own, and a read that was sent before it reads again. It is not dropped: the stale read may be the only one carrying some other change, and in the settings panel it may be the read for a repo picked since. `LatestWins` documents the pattern. - New `RepoActionList` (settings/repoActionList.svelte.ts) holds the panel's action list, its read order and its local writes. The panel writes through it at detect, create, edit, delete, delete all and delete repo. - ProjectSection counts its optimistic removal as a row landing, which the list load already answers by reading again. - `setBranchesByProject` marks each project whose branch list really changed. `applyProjectBranches` then refetches that project and returns the list in place, so `refreshProject` still invalidates those timelines. A call that changes no branch marks nothing, and a transfer's `replace` list is still always applied. - noteReadActions keeps a claim per note for an unread flag that is waiting on its project lookup. A later read mark, unread mark or delete of the note takes the claim, and a flag without its claim is not set. - ConnectedAppsPanel. The failure path checks the ticket like the success path. `loading` clears only when the newest load settles, through a new `LatestWins.isNewest`. - SendOrder. New optional `maxWaitMs`: a request waits at most that long, from its own call, for the ones ahead of it, and is then sent beside them. Nothing is cancelled or failed, so a command that is only slow still completes. `writeOrder` sets it to 30 s over HTTP only (`WEB_WRITE_ORDER_MAX_WAIT_MS`). Under Tauri it is unset, the wait has no bound and the code path is the old one, so no reordering comes back there. Over HTTP a write that outlasts the bound can be overtaken, as every pair could before 013d1e4f. - Docs only. The guard test module, `OFFLOADED_COMMANDS` and its test now state that an async command outside the list is not checked for inline store calls, and name the known ones. The `createNote` wrapper doc says it keeps the call order and no more. Not changed - The already-async commands that call the store inline, and Fix B. - ConnectedAppsPanel's revoke still filters its list with no ticket. An older load can put the row back until the grants-changed event reloads. That is the same shape as the read-over-write finding and heals itself. The review did not list it. - `flushPrStatusEvents` and the other event-driven patches to the branch map write without a mark, as before. - A failed drop read toasts later than it did (see above). Verified, with the pinned 1.99.1 toolchain - `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass. - `cargo test --workspace --no-fail-fast --lib --bins --tests`, one run, at a machine load between 20 and 40 and with 208 GB free before it: app lib 2107 passed, 0 failed, 2 ignored; `stagedctl` (106) and `help_pins` (1) passed. - New Rust tests: `racing_explicit_quits_raise_one_prompt` and `the_gate_decides_one_request_after_another`. With the gate lock removed the first fails with two `Ask` decisions. It passes again with the lock back. The four guard tests pass. - Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite (239 files, 3577 tests) pass. 24 tests are new. - Each new frontend test was run against the unfixed source and fails there: the drop-order test, both ConnectedAppsPanel tests, the ProjectSection test, the projectsData re-read test, two of the three noteReadActions tests, and three SendOrder tests plus the HTTP wrapper test. With the re-read removed from `RepoActionList`, two of its tests fail. With the bound also applied under Tauri, the Tauri wrapper test fails. All pass again with the fixes restored. Not run or not covered - The app itself. None of the races was reproduced live, and the web transport was not exercised against a real hung connection. - ActionsSettingsPanel has no component test. `RepoActionList` is tested by itself; the panel's use of it is covered by the type check only. - The quit test drives `QuitState::decide` with a stand-in blocker snapshot. `request_quit` needs an `AppHandle` and was not run. - No test drives `setBranchesByProject` from its real callers (workspaceLifecycle, ProjectHome); the store test calls it directly. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/app_lifecycle.rs | 234 +++++++++++++++--- .../src/web_server/blocking_guard_tests.rs | 20 ++ apps/staged/src/lib/commands.test.ts | 57 +++++ apps/staged/src/lib/commands.ts | 28 ++- .../branches/BranchCard.component.test.ts | 21 ++ .../lib/features/branches/BranchCard.svelte | 24 +- .../features/notes/noteReadActions.test.ts | 53 ++++ .../src/lib/features/notes/noteReadActions.ts | 46 +++- .../projects/ProjectSection.component.test.ts | 27 ++ .../features/projects/ProjectSection.svelte | 7 +- .../settings/ActionsSettingsPanel.svelte | 76 +++--- .../ConnectedAppsPanel.component.test.ts | 53 ++++ .../settings/ConnectedAppsPanel.svelte | 8 +- .../settings/repoActionList.svelte.ts | 77 ++++++ .../features/settings/repoActionList.test.ts | 148 +++++++++++ apps/staged/src/lib/shared/latestWins.test.ts | 24 ++ apps/staged/src/lib/shared/latestWins.ts | 18 ++ apps/staged/src/lib/shared/sendOrder.test.ts | 112 ++++++++- apps/staged/src/lib/shared/sendOrder.ts | 49 +++- .../lib/stores/projectsData.component.test.ts | 63 +++++ .../src/lib/stores/projectsData.svelte.ts | 32 ++- 21 files changed, 1073 insertions(+), 104 deletions(-) create mode 100644 apps/staged/src/lib/features/settings/repoActionList.svelte.ts create mode 100644 apps/staged/src/lib/features/settings/repoActionList.test.ts diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index d812f641d..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; + 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), } - - // 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; - } - - // 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. @@ -442,10 +496,15 @@ fn quit_confirmed(result: &MessageDialogResult) -> bool { /// /// 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 gate is safe off it: the -/// prompt flags are atomics, the alert hops to the main thread itself, and -/// the cleanup runs at most once however many quits race. The menu and -/// `Cmd+Q` paths still call [`request_quit`] on the main thread. +/// 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 async fn quit_app(app_handle: AppHandle) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || request_quit(&app_handle, QuitTrigger::Explicit)) @@ -811,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 { @@ -954,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/web_server/blocking_guard_tests.rs b/apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs index 07b48f4bb..8f232ff6e 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 @@ -21,6 +21,19 @@ //! 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}; @@ -173,6 +186,9 @@ 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"), @@ -343,6 +359,10 @@ const ARMS_WITH_NOTHING_TO_OFFLOAD: &[&str] = &[ /// 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(); diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 3e5101e68..98d7e3903 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -1309,4 +1309,61 @@ describe('writes sent without awaiting keep their send order', () => { 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('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 c7bc1251b..0de4b97e4 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -78,6 +78,15 @@ 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. + */ +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`, @@ -97,8 +106,14 @@ export type DiffScope = 'branch' | 'commit' | 'worktree'; * * 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(); +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 { @@ -1772,10 +1787,13 @@ 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}). 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. + * 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, 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..ecf0102e6 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,27 @@ 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']); + }); }); 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 b7a192492..66b04ca01 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -1872,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 06033b945..212a139c7 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -430,7 +430,8 @@ /** * 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. + * 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'>(); /** @@ -508,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.admit('row', noteFetchOrder.issue()); projectNotes = projectNotes.filter((n) => n.id !== noteId); hashtagVersion++; } catch (e) { diff --git a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte index 5a784b77c..f150bcd6f 100644 --- a/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ActionsSettingsPanel.svelte @@ -34,6 +34,7 @@ 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; @@ -64,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); @@ -292,34 +298,14 @@ } /** - * The context and action reads run concurrently on the backend, so two sent - * in a row can answer in either order. Without this, picking repo A and then - * B could leave A's actions listed under B, where Edit and Delete act on - * A's action ids. + * 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' | 'actions'>(); + const loadOrder = new LatestWins<'contexts'>(); - async function loadActions() { - const ticket = loadOrder.issue(); - if (!selectedContext) { - // Nothing to read, but a read still in flight must not fill the list. - loadOrder.admit('actions', ticket); - actions = []; - return; - } - loadingActions = true; - try { - const loaded = await commands.listRepoActions( - selectedContext.githubRepo, - selectedContext.subpath ?? undefined - ); - if (loadOrder.admit('actions', ticket)) actions = loaded; - } catch (e) { - console.error('Failed to load actions:', e); - if (loadOrder.admit('actions', ticket)) actions = []; - } finally { - loadingActions = false; - } + function loadActions(): Promise { + return actionList.load(); } $effect(() => { @@ -353,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(); @@ -402,7 +388,7 @@ values.icon ); if (selectedRepoKey === entryKey) { - actions = [...actions, newAction]; + actionList.write([...actions, newAction]); } } else { const actionId = actionToEdit.id; @@ -417,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 + ) ); } } @@ -438,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); @@ -456,7 +444,7 @@ try { await commands.deleteAllRepoActions(repoContextId); if (selectedRepoKey === entryKey) { - actions = []; + actionList.write([]); } showDeleteAllConfirm = false; window.dispatchEvent(new CustomEvent('project-actions-changed')); @@ -487,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 9599ffea4..91fa4bce5 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts @@ -169,6 +169,59 @@ describe('ConnectedAppsPanel', () => { 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(); diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte index 5504cdf23..daf8f8528 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte @@ -48,10 +48,14 @@ grants = list; error = null; } catch (e) { - if (!mounted) return; + // 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; error = e instanceof Error ? e.message : String(e); } 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. + if (mounted && loadOrder.isNewest(ticket)) loading = false; } } 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..561297a95 --- /dev/null +++ b/apps/staged/src/lib/features/settings/repoActionList.svelte.ts @@ -0,0 +1,77 @@ +/** + * 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.order.admit('read', ticket); + this.actions = []; + 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 { + this.loading = false; + } + } + + /** Replace the list with the result of a write that has landed. */ + write(actions: ProjectAction[]): void { + this.order.admit('written', this.order.issue()); + 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..96f600c00 --- /dev/null +++ b/apps/staged/src/lib/features/settings/repoActionList.test.ts @@ -0,0 +1,148 @@ +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. + reads[1].answer([action('build')]); + await inFlight; + expect(list.actions).toEqual([]); + }); +}); diff --git a/apps/staged/src/lib/shared/latestWins.test.ts b/apps/staged/src/lib/shared/latestWins.test.ts index 4cfc9d041..d0b5fad70 100644 --- a/apps/staged/src/lib/shared/latestWins.test.ts +++ b/apps/staged/src/lib/shared/latestWins.test.ts @@ -44,4 +44,28 @@ describe('LatestWins', () => { // `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.admit('written', order.issue()); + 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); + }); }); diff --git a/apps/staged/src/lib/shared/latestWins.ts b/apps/staged/src/lib/shared/latestWins.ts index 66897f19e..2eed9cc9f 100644 --- a/apps/staged/src/lib/shared/latestWins.ts +++ b/apps/staged/src/lib/shared/latestWins.ts @@ -13,6 +13,15 @@ * `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 (`admit(writeKey, issue())`), 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 { private sent = 0; @@ -38,4 +47,13 @@ export class LatestWins { overtaken(key: K, ticket: number): boolean { return (this.landed.get(key) ?? 0) > ticket; } + + /** + * Whether no ticket has been issued since `ticket`: the request it stands + * for is the newest one sent. For state that describes the requests still + * out, such as a spinner, which an older request settling must not clear. + */ + isNewest(ticket: number): boolean { + return ticket === this.sent; + } } diff --git a/apps/staged/src/lib/shared/sendOrder.test.ts b/apps/staged/src/lib/shared/sendOrder.test.ts index f1853fbf8..d60a907dc 100644 --- a/apps/staged/src/lib/shared/sendOrder.test.ts +++ b/apps/staged/src/lib/shared/sendOrder.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +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. */ @@ -109,3 +109,113 @@ describe('SendOrder', () => { 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('counts each request’s wait from its own call, so a hung chain still drains', async () => { + const order = new SendOrder({ maxWaitMs: 1000 }); + const log: string[] = []; + + void order.run('tags', request(log, 'first').send); + void order.run('tags', request(log, 'second').send); + await vi.advanceTimersByTimeAsync(600); + void order.run('tags', request(log, 'third').send); + + // `second` goes at 1000 ms and hangs too; `third` goes 1000 ms after its + // own call, not 1000 ms after `second` was sent. + await vi.advanceTimersByTimeAsync(400); + expect(log).toEqual(['first sent', 'second sent']); + await vi.advanceTimersByTimeAsync(600); + expect(log).toEqual(['first sent', 'second sent', 'third sent']); + }); + + 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 index faf46295f..76bf5e9a5 100644 --- a/apps/staged/src/lib/shared/sendOrder.ts +++ b/apps/staged/src/lib/shared/sendOrder.ts @@ -17,18 +17,36 @@ * 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, as a - * synchronous command that never returned held back every command. + * 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, from the call + * to `run`, for the ones ahead of it, and is then sent beside them. 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. Leave it 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 result = earlier - ? earlier.then(send) + ? this.settledOrOverdue(key, earlier).then(send) : // Inside an executor so a `send` that throws rejects like one that // fails later, and still releases the key. new Promise((resolve) => resolve(send())); @@ -44,6 +62,29 @@ export class SendOrder { get pendingKeys(): number { return this.tails.size; } + + /** + * `earlier` 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 the bound runs out, whichever is first. + */ + private settledOrOverdue(key: K, earlier: Promise): Promise { + const maxWaitMs = this.maxWaitMs; + if (maxWaitMs === undefined) return earlier; + return new Promise((resolve) => { + const timer = setTimeout(() => { + console.warn( + `[sendOrder] A write for "${String(key)}" has been waiting ${maxWaitMs} ms for the ` + + 'one sent before it; sending it without waiting any longer.' + ); + resolve(); + }, maxWaitMs); + void earlier.then(() => { + clearTimeout(timer); + 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 43014d704..cef8d6276 100644 --- a/apps/staged/src/lib/stores/projectsData.component.test.ts +++ b/apps/staged/src/lib/stores/projectsData.component.test.ts @@ -960,6 +960,69 @@ 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 }; + } + + 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])); + + listBranchesForProject.mockResolvedValue( + swr([branch({ branchName: 'renamed' }), branch({ id: 'b2', branchName: 'other' })]) + ); + invalidateProjectBranchTimelines.mockClear(); + stale.resolve(swr([branch({ branchName: 'before-rename' })])); + expect(await refresh).toBe(true); + + // The older read does not put the old name back. + expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual(['renamed']); + // The refresh still invalidates the timelines of the branches in place. + expect(invalidateProjectBranchTimelines).toHaveBeenCalledWith(['b1']); + // And what else that read carried is not lost: the list is read again. + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual([ + 'renamed', + 'other', + ]); + }); + }); + + 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 8f6488d20..761de4a76 100644 --- a/apps/staged/src/lib/stores/projectsData.svelte.ts +++ b/apps/staged/src/lib/stores/projectsData.svelte.ts @@ -86,6 +86,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. @@ -176,7 +183,9 @@ class ProjectsDataStore { * 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:`. + * `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; @@ -510,6 +519,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 applyProjectBranches). 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.admit(`branch-writes:${projectId}`, this.readOrder.issue()); + } + } this._branchesByProject = next; } @@ -732,6 +751,13 @@ class ProjectsDataStore { * this one (`ticket`, see readOrder) has already landed. 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. It is not just dropped, since it may be + * the only read carrying some other change: the list is read again, and + * the list in place is returned so the caller treats it as the result. */ private applyProjectBranches( projectId: string, @@ -742,6 +768,10 @@ class ProjectsDataStore { ): Branch[] | null { if (generation !== this.loadGeneration || !this.hasProject(projectId)) return null; if (!this.readOrder.admit(`branches:${projectId}`, ticket) && !replace) return null; + if (!replace && this.readOrder.overtaken(`branch-writes:${projectId}`, ticket)) { + void this.refetchProjectBranches(projectId); + return this._branchesByProject.get(projectId) ?? null; + } const mergedBranches = replace ? branches From c308f4f53718bb21b084334a712255fe9bf132b2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:40:51 +1100 Subject: [PATCH 6/8] fix(staged): keep order behind a hung web write and await set-aside refreshes Applies the findings of review 0247953f of 28aa072a, the commit that closed the ordering gaps in Fix A. Each finding was checked against the code first. All five are real. The two warnings are fixed, and the three cheap suggestions are taken. The items the review marks as information or as predating this work are left alone, and Fix B is not touched. No Rust changed. Findings, as verified - SendOrder wait bound (sendOrder.ts). Real. Each waiter's clock started at its own call, so every write queued behind one hung request ran out in the same instant and all were sent together. With the old source, a hung request followed by three writes queued in one tick sends all three at the bound. Web transport only: under Tauri there is no bound. - Set-aside refresh (projectsData.svelte.ts). Real. `applyProjectBranches` returned the list in place when a local write had landed under the read, so `refreshProject` resolved `true` with the old list still shown. The mark is per project, so a workspace status change on another branch was enough. The branch delete in ProjectHome awaits `refreshProject` and then clears `deletingBranches`, so the deleted card came back for one round trip. - RepoActionList `loading`. Real. `finally` cleared it for any load, so an older read settling hid the spinner while the newest was still out. - BranchCard drop. Real as a coverage gap: no test had a failed read among successful ones. - ConnectedAppsPanel revoke. Real. The revoke filtered its row with no mark, so a load sent before it could put the row back until the grants-changed load answered. Fixes - SendOrder. A waiter's clock now runs only while the request ahead of it is out: it starts at the waiter's own call, or when the request ahead is sent if that is later. Behind one hung request the first waiter gives up and is sent; the second then waits for that send with a full bound of its own, and so on. Writes that are not hung keep their order among themselves. A chain still drains, one bound for each request in it that hangs. A waiter behind a request that has not been sent holds no timer, and a timer is cleared when the request ahead settles, so none is left behind. With no bound the path is the old one: the same promise chain, no timer, no extra tick. - refreshProject. The branch apply is split. `offerProjectBranches` reports a list as applied, refused or set aside, and no longer starts a read itself. `applyProjectBranches` wraps it for the reads nothing awaits (hydration, the event refetch) and still reads again in the background. `refreshProject` reads the list again itself when its list is set aside, and resolves only once a list read after the local write has been offered. It applies repos and marks the project hydrated before that wait. If the re-read fails it resolves `false`. - LatestWins. New `wrote(key)` records a local write as a landing. It takes a place in the order but is not a request, so `isNewest` still answers for the last request sent. The four call sites that wrote `admit(key, issue())` for a local write now call it (RepoActionList, ProjectSection, ActionRunner, projectsData), with no change in what they admit. - RepoActionList. `loading` clears only for the newest load. A load with no repo selected clears it itself, since the read it overtakes no longer does. - ConnectedAppsPanel. A revoke is recorded under a `revoked` key, and a load sent before it reads again, whether it answered or failed. The re-read is the newest load, so Refresh stays disabled until it is back. - BranchCard. New test: four files dropped, reads answering out of order with one failure. Nothing is created or reported until every read is back, the other three are created in drop order, there is one toast, and only the failed file's "adding..." row is removed. The component is not changed. - ButtonStub (test stub) now reports `disabled` as `data-disabled`. It still does not apply it, so tests can click through as before. Behaviour to know about - Over HTTP, a write queued behind several requests that all hang now waits one bound for each of them, where it used to wait one bound from its own call. A write sent after the connection recovers waits behind what is left of such a chain. A write that is only slow past the bound can still be overtaken by the next one, as before. - `refreshProject` can now take one extra round trip, or more if local writes keep landing under its reads. Each round needs a new write that really changed a branch, so it ends when one read comes back with none. - A refresh whose re-read is refused, because a read sent later landed first, invalidates no timelines. That is what any refused refresh did. Before, a set-aside refresh invalidated the list in place. Left alone, as the review marks them - One slow or hung file read holds back every note of a drop, and the shared catch says "Could not read" when it was `createNote` that failed. The second predates this work. - ActionsSettingsPanel still has no component test. Verified - Frontend: `pnpm check` (0 errors), prettier on the whole tree, and the full vitest suite (239 files, 3593 tests) pass. 17 tests are new and one SendOrder test that pinned the old bound is replaced. The projectsData test that pinned the old `refreshProject` result is rewritten. - Each fix was removed and its tests rerun: - Old `sendOrder.ts`: three of the four new SendOrder tests and the new HTTP wrapper test fail. The fourth pins the wait from a request's own call when the one ahead is already out, which is unchanged. - Old `projectsData.svelte.ts`: the rewritten test and three new ones fail. The test for the background re-read of an event refetch passes on both, as intended. - RepoActionList with `loading` cleared for any load: two tests fail. Without the clear for an empty selection: one fails. - Old `ConnectedAppsPanel.svelte`: two of the three new tests fail. The third pins that a load sent after the revoke is applied as is. - `wrote` made to count as a request: the new LatestWins test fails. The panel and RepoActionList tests pass either way, because every load a write overtakes reads again and that re-read is the newest. `wrote` makes `isNewest` right without relying on that. - The new drop test passes on 28aa072a's BranchCard and fails on d490643d's, where notes were created as each read answered. - Rust: `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass with the pinned 1.99.1 toolchain. The Rust test suite was not run, since no Rust file changed. Not run or not covered - The app itself. None of the races was reproduced live, and the web transport was not exercised against a real hung connection. - No test drives the branch delete in ProjectHome. The store tests call `refreshProject` and `setBranchesByProject` directly. - The ProjectSection and ActionRunner moves to `wrote` are covered by their existing tests and the type check only. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src/lib/commands.test.ts | 16 ++ apps/staged/src/lib/commands.ts | 4 +- .../features/actions/actionRunner.svelte.ts | 2 +- .../branches/BranchCard.component.test.ts | 53 ++++++ .../features/projects/ProjectSection.svelte | 2 +- .../sessions/testStubs/ButtonStub.svelte | 8 +- .../ConnectedAppsPanel.component.test.ts | 90 +++++++++++ .../settings/ConnectedAppsPanel.svelte | 44 +++-- .../settings/repoActionList.svelte.ts | 10 +- .../features/settings/repoActionList.test.ts | 44 +++++ apps/staged/src/lib/shared/latestWins.test.ts | 23 ++- apps/staged/src/lib/shared/latestWins.ts | 28 +++- apps/staged/src/lib/shared/sendOrder.test.ts | 93 ++++++++++- apps/staged/src/lib/shared/sendOrder.ts | 94 +++++++---- .../lib/stores/projectsData.component.test.ts | 152 +++++++++++++++++- .../src/lib/stores/projectsData.svelte.ts | 80 ++++++--- 16 files changed, 653 insertions(+), 90 deletions(-) diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index 98d7e3903..96db37ac1 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -1353,6 +1353,22 @@ describe('writes sent without awaiting keep their send order', () => { 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'); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 0de4b97e4..9ee0a21cb 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -83,7 +83,9 @@ export type DiffScope = 'branch' | 'commit' | 'worktree'; * 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. + * 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; diff --git a/apps/staged/src/lib/features/actions/actionRunner.svelte.ts b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts index ed9e954d0..73f6a2693 100644 --- a/apps/staged/src/lib/features/actions/actionRunner.svelte.ts +++ b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts @@ -254,7 +254,7 @@ export class ActionRunner { */ setActions(actions: ProjectAction[]): void { // Newer than any read still in flight, which must not replace it. - this.loadOrder.admit('actions', this.loadOrder.issue()); + this.loadOrder.wrote('actions'); this.actions = actions; } 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 ecf0102e6..e6f038dbe 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.component.test.ts +++ b/apps/staged/src/lib/features/branches/BranchCard.component.test.ts @@ -287,6 +287,59 @@ describe('BranchCard file drops', () => { 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/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 212a139c7..8b3a5d8b4 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -512,7 +512,7 @@ // 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.admit('row', noteFetchOrder.issue()); + noteFetchOrder.wrote('row'); projectNotes = projectNotes.filter((n) => n.id !== noteId); hashtagVersion++; } catch (e) { 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/ConnectedAppsPanel.component.test.ts b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts index 91fa4bce5..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([ @@ -239,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 daf8f8528..02989eaa2 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte @@ -33,28 +33,46 @@ let mounted = true; /** - * Each `control-grants-changed` sends a load, and the backend reads them - * concurrently: the load sent for the latest change stays, whichever - * answers last. + * `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'>(); + const loadOrder = new LatestWins<'grants' | 'revoked'>(); - async function load() { + async function load(): Promise { const ticket = loadOrder.issue(); loading = true; try { - const list = await commands.listControlGrants(); - if (!mounted || !loadOrder.admit('grants', ticket)) return; - grants = list; - error = null; - } catch (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; - error = e instanceof Error ? e.message : String(e); + // 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 { // `loading` is about the newest load. An older one settling while that - // is still out leaves the Refresh button disabled. + // 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; } } @@ -75,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 index 561297a95..2e6d669da 100644 --- a/apps/staged/src/lib/features/settings/repoActionList.svelte.ts +++ b/apps/staged/src/lib/features/settings/repoActionList.svelte.ts @@ -43,8 +43,11 @@ export class RepoActionList { 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; @@ -65,13 +68,16 @@ export class RepoActionList { if (this.order.overtaken('written', ticket)) return await this.load(); this.actions = loaded; } finally { - this.loading = false; + // `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.admit('written', this.order.issue()); + 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 index 96f600c00..267312381 100644 --- a/apps/staged/src/lib/features/settings/repoActionList.test.ts +++ b/apps/staged/src/lib/features/settings/repoActionList.test.ts @@ -141,8 +141,52 @@ describe('RepoActionList', () => { 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/shared/latestWins.test.ts b/apps/staged/src/lib/shared/latestWins.test.ts index d0b5fad70..56edda67a 100644 --- a/apps/staged/src/lib/shared/latestWins.test.ts +++ b/apps/staged/src/lib/shared/latestWins.test.ts @@ -60,7 +60,7 @@ describe('LatestWins', () => { it('lets a local write mark the reads sent before it landed', () => { const order = new LatestWins<'list' | 'written'>(); const before = order.issue(); - order.admit('written', order.issue()); + order.wrote('written'); const after = order.issue(); expect(order.overtaken('written', before)).toBe(true); @@ -68,4 +68,25 @@ describe('LatestWins', () => { // 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 index 2eed9cc9f..03ee03b4e 100644 --- a/apps/staged/src/lib/shared/latestWins.ts +++ b/apps/staged/src/lib/shared/latestWins.ts @@ -18,18 +18,32 @@ * 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 (`admit(writeKey, issue())`), and have a read that finds itself + * 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 { - return ++this.sent; + 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); } /** @@ -49,11 +63,13 @@ export class LatestWins { } /** - * Whether no ticket has been issued since `ticket`: the request it stands - * for is the newest one sent. For state that describes the requests still - * out, such as a spinner, which an older request settling must not clear. + * 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.sent; + return ticket === this.newestRequest; } } diff --git a/apps/staged/src/lib/shared/sendOrder.test.ts b/apps/staged/src/lib/shared/sendOrder.test.ts index d60a907dc..2a15ce8e7 100644 --- a/apps/staged/src/lib/shared/sendOrder.test.ts +++ b/apps/staged/src/lib/shared/sendOrder.test.ts @@ -152,21 +152,102 @@ describe('SendOrder with a wait bound', () => { expect(log).toEqual(['hung sent', 'next sent', 'after sent']); }); - it('counts each request’s wait from its own call, so a hung chain still drains', async () => { + 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); - await vi.advanceTimersByTimeAsync(600); void order.run('tags', request(log, 'third').send); + const result = order.run('tags', last.send); - // `second` goes at 1000 ms and hangs too; `third` goes 1000 ms after its - // own call, not 1000 ms after `second` was sent. - await vi.advanceTimersByTimeAsync(400); + // 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(600); + 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 () => { diff --git a/apps/staged/src/lib/shared/sendOrder.ts b/apps/staged/src/lib/shared/sendOrder.ts index 76bf5e9a5..d9fe6c231 100644 --- a/apps/staged/src/lib/shared/sendOrder.ts +++ b/apps/staged/src/lib/shared/sendOrder.ts @@ -26,16 +26,25 @@ * 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, from the call - * to `run`, for the ones ahead of it, and is then sent beside them. 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. Leave it 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. + * `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 tails = new Map(); private readonly maxWaitMs: number | undefined; constructor({ maxWaitMs }: { maxWaitMs?: number } = {}) { @@ -45,15 +54,22 @@ export class SendOrder { /** 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(send) + ? 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(send())); - const tail = result.then(noop, noop); - this.tails.set(key, tail); - void tail.then(() => { - if (this.tails.get(key) === tail) this.tails.delete(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; } @@ -64,27 +80,49 @@ export class SendOrder { } /** - * `earlier` 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 the bound runs out, whichever is first. + * `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: Promise): Promise { + private settledOrOverdue(key: K, earlier: InFlight): Promise { const maxWaitMs = this.maxWaitMs; - if (maxWaitMs === undefined) return earlier; + if (maxWaitMs === undefined) return earlier.settled; return new Promise((resolve) => { - const timer = setTimeout(() => { - console.warn( - `[sendOrder] A write for "${String(key)}" has been waiting ${maxWaitMs} ms for the ` + - 'one sent before it; sending it without waiting any longer.' - ); - resolve(); - }, maxWaitMs); - void earlier.then(() => { - clearTimeout(timer); + 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 cef8d6276..15a7a85d8 100644 --- a/apps/staged/src/lib/stores/projectsData.component.test.ts +++ b/apps/staged/src/lib/stores/projectsData.component.test.ts @@ -969,6 +969,17 @@ describe('setBranchesByProject', () => { 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(); @@ -983,24 +994,151 @@ describe('setBranchesByProject', () => { 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('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' })]) ); - invalidateProjectBranchTimelines.mockClear(); stale.resolve(swr([branch({ branchName: 'before-rename' })])); - expect(await refresh).toBe(true); - // The older read does not put the old name back. - expect(store.branchesByProject.get('p1')!.map((b) => b.branchName)).toEqual(['renamed']); - // The refresh still invalidates the timelines of the branches in place. - expect(invalidateProjectBranchTimelines).toHaveBeenCalledWith(['b1']); - // And what else that read carried is not lost: the list is read again. 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 () => { diff --git a/apps/staged/src/lib/stores/projectsData.svelte.ts b/apps/staged/src/lib/stores/projectsData.svelte.ts index 761de4a76..865f3daef 100644 --- a/apps/staged/src/lib/stores/projectsData.svelte.ts +++ b/apps/staged/src/lib/stores/projectsData.svelte.ts @@ -64,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. @@ -439,6 +445,14 @@ 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, @@ -456,18 +470,22 @@ class ProjectsDataStore { if (this.readOrder.admit('projects', ticket)) { this._projects = projectsResult.data; } - const mergedBranches = this.applyProjectBranches( - projectId, - branchesResult.data, - generation, - ticket, - { replace: replaceBranches } - ); - if (mergedBranches) { - commands.invalidateProjectBranchTimelines(mergedBranches.map((b) => b.id)); - } + let offered = this.offerProjectBranches(projectId, branchesResult.data, generation, ticket, { + replace: replaceBranches, + }); 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); @@ -521,12 +539,12 @@ class ProjectsDataStore { 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 applyProjectBranches). Only lists 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.admit(`branch-writes:${projectId}`, this.readOrder.issue()); + this.readOrder.wrote(`branch-writes:${projectId}`); } } this._branchesByProject = next; @@ -747,30 +765,30 @@ class ProjectsDataStore { } /** - * Apply a fetched branch list, unless a newer load or a read sent after - * this one (`ticket`, see readOrder) has already landed. 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. + * Offer a fetched branch list to the store. Resolves to the list applied, + * or to null when it was refused: a newer load or a read sent after this + * one (`ticket`, see readOrder) has already landed. 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. It is not just dropped, since it may be - * the only read carrying some other change: the list is read again, and - * the list in place is returned so the caller treats it as the result. + * 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 applyProjectBranches( + 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; if (!this.readOrder.admit(`branches:${projectId}`, ticket) && !replace) return null; if (!replace && this.readOrder.overtaken(`branch-writes:${projectId}`, ticket)) { - void this.refetchProjectBranches(projectId); - return this._branchesByProject.get(projectId) ?? null; + return BRANCH_READ_SET_ASIDE; } const mergedBranches = replace @@ -780,6 +798,22 @@ class ProjectsDataStore { return mergedBranches; } + /** + * 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[], From f10f013589519d90e67b8e0f8fdde4755a46187d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:52:08 +1100 Subject: [PATCH 7/8] fix(staged): do not count a set-aside branch list as landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the one warning of review 6eff2107 of e6069e41, the commit that made `refreshProject` wait for a list read after a local write. The finding was checked against the code first and is real. Nothing else in the review is touched, and no Rust changed. The finding, as verified `offerProjectBranches` ran `admit` before it looked at `branch-writes`, so a list that was set aside was still recorded as a landing for `branches:`. An older read then found itself overtaken by a list the store never showed, and was refused. `refreshProject` takes a refusal to mean a later-sent list is in place, so it resolved `true` over the pre-mutation list, which is the symptom e6069e41 set out to remove. The review's sequence, reproduced in a test on e6069e41's source: - `refreshProject` sends its read after a branch delete. - The delete's change event sends a refetch, with a later ticket. - A local write lands: a workspace status change on another branch. - The event refetch answers first. It is admitted, set aside, and sends a read of its own in the background. - The refresh's read answers. It is refused, not set aside, the loop is skipped and the refresh resolves `true` with the deleted branch still listed. The branch delete in ProjectHome clears its "Deleting…" state on that result, so the card comes back until the background read answers. The fix The checks run in the order the review suggests. For a list that is not a `replace`: refused if a later-sent list has landed; else set aside if a local write has; and only then recorded with `admit` and applied. A list that is set aside leaves no mark, so a refusal now always means a later-sent list was really applied. In the sequence above the refresh's read is set aside too, and the refresh reads again. What was checked - No list is applied that was refused before. A read that used to be refused only because a set-aside list had landed was sent before that list, which was sent before the local write. It is therefore set aside itself, never applied. - The re-read loop is no more open than it was. A round still needs `setBranchesByProject` to change a branch list of the project after the round's ticket is issued, and nothing in the loop calls it. The refresh's first read can now enter the loop where it used to be refused, which is the point of the fix. - The background re-reads stay bounded. More reads can now be set aside, and each asks for a background refetch, but that refetch is single flight with one queued follow-up, as before. - The `replace` path is unchanged. It skips both checks, calls `admit` with the result ignored, and is always applied, as before. One edge differs: a `replace` list landing after a later-sent set-aside list now records its own ticket, where `admit` used to decline. Any read between the two is set aside by the same local write, so nothing new is applied. - `branches:` is read nowhere else. Behaviour to know about - A refresh in the review's sequence takes one more round trip, and its re-read can run beside the event refetch's background read. Whichever was sent later wins; the other is refused. Verified - New test, the review's sequence with hand-settled reads. On e6069e41's source it fails where the refresh resolves early. With the fix the refresh does not settle, reads again, resolves `true` with the deleted branch gone, and the background read answering last is refused with no further read. - Second new test, the same start with the background read landing before the refresh's own read. The refresh's read is refused, the refresh resolves `true` and nothing more is read. It passes with and without the fix: it pins that a true refusal still ends the refresh without another round. - `pnpm check` (0 errors, 0 warnings), the frontend prettier check (`src/**/*.{ts,svelte,css,html}`), and the full vitest suite (239 files, 3595 tests) pass, at a machine load between 25 and 48. Not run or not covered - The app itself. The race was not reproduced live. - No test drives the branch delete in ProjectHome. The store tests call `refreshProject` and `setBranchesByProject` directly and emit the change event by hand. - No new test for the `replace` edge above. The existing transfer tests pass unchanged. - The Rust checks and tests, since no Rust file changed. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../lib/stores/projectsData.component.test.ts | 114 ++++++++++++++++++ .../src/lib/stores/projectsData.svelte.ts | 22 ++-- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/apps/staged/src/lib/stores/projectsData.component.test.ts b/apps/staged/src/lib/stores/projectsData.component.test.ts index 15a7a85d8..cdacfc1d2 100644 --- a/apps/staged/src/lib/stores/projectsData.component.test.ts +++ b/apps/staged/src/lib/stores/projectsData.component.test.ts @@ -1061,6 +1061,120 @@ describe('setBranchesByProject', () => { 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(); diff --git a/apps/staged/src/lib/stores/projectsData.svelte.ts b/apps/staged/src/lib/stores/projectsData.svelte.ts index 865f3daef..d4e0d171b 100644 --- a/apps/staged/src/lib/stores/projectsData.svelte.ts +++ b/apps/staged/src/lib/stores/projectsData.svelte.ts @@ -766,10 +766,10 @@ class ProjectsDataStore { /** * Offer a fetched branch list to the store. Resolves to the list applied, - * or to null when it was refused: a newer load or a read sent after this - * one (`ticket`, see readOrder) has already landed. 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. + * 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 @@ -786,10 +786,18 @@ class ProjectsDataStore { { replace = false }: { replace?: boolean } = {} ): Branch[] | typeof BRANCH_READ_SET_ASIDE | null { if (generation !== this.loadGeneration || !this.hasProject(projectId)) return null; - if (!this.readOrder.admit(`branches:${projectId}`, ticket) && !replace) return null; - if (!replace && this.readOrder.overtaken(`branch-writes:${projectId}`, ticket)) { - return BRANCH_READ_SET_ASIDE; + 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 From a54376c56438c09019a08ed9b3667292e97c3cab Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 18:21:01 +1100 Subject: [PATCH 8/8] test(staged): drop get_action_output_buffer from the synchronous-command list Main made it an `async fn` in 1a3efbfc (#1067), so after rebasing onto main the source guard flagged the entry 5b095c7f added as stale. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.1 --- apps/staged/src-tauri/src/web_server/blocking_guard_tests.rs | 1 - 1 file changed, 1 deletion(-) 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 8f232ff6e..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 @@ -460,7 +460,6 @@ const SYNCHRONOUS_COMMANDS: &[(&str, &str)] = &[ ("actions/commands.rs", "stop_branch_action"), ("actions/commands.rs", "get_running_branch_actions"), ("actions/commands.rs", "get_all_running_actions"), - ("actions/commands.rs", "get_action_output_buffer"), ("actions/commands.rs", "clear_action_execution"), // An environment variable. ("branches.rs", "get_blox_env"),