From f5fc22eae74ee1a5e141c00f7d159fac94dbfab0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 23 Jul 2026 07:24:37 +0200 Subject: [PATCH] fix: guard shared background-job channels against concurrent overwrite Bulk-import, import-directory, remove-git-from-all, create-repo, publish-existing, create-PR, create-issue, profile-push, and profile-pull all set state.pull_all_rx/create_repo_rx/profile_pull_rx unconditionally. Starting a second one of these before the first finished silently dropped the first job's progress/result (its sends now went to an orphaned receiver), and for the workspace-mutating jobs (remove-git-all racing an in-flight clone) could corrupt repos on disk. Each spawn site now checks the channel is free first and tells the user to wait instead. --- src/hub/modals_confirm_repo.rs | 8 ++++++++ src/hub/modals_spawn.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/hub/modals_confirm_repo.rs b/src/hub/modals_confirm_repo.rs index 4b2d532..308bcbc 100644 --- a/src/hub/modals_confirm_repo.rs +++ b/src/hub/modals_confirm_repo.rs @@ -101,6 +101,10 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option { state.status_msg = Some(format!("Path does not exist: {}", src.display())); return None; } + if state.pull_all_rx.is_some() { + state.status_msg = Some("A workspace job is already running — wait for it to finish.".into()); + return None; + } let workspace = state.workspace_root.clone(); let (tx, rx) = std::sync::mpsc::channel::(); state.pull_all_rx = Some(rx); @@ -186,6 +190,10 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option { // ── Remove .git from all workspace repos ────────────────────────────── Modal::Confirm { tag: ConfirmTag::RemoveGitAll, .. } => { + if state.pull_all_rx.is_some() { + state.status_msg = Some("A workspace job is already running — wait for it to finish.".into()); + return None; + } let workspace = state.workspace_root.clone(); let (tx, rx) = std::sync::mpsc::channel::(); state.pull_all_rx = Some(rx); diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index 81890ef..e30a871 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -20,6 +20,10 @@ pub fn spawn_create_repo( commands: Vec, owner: Option, ) { + if state.create_repo_rx.is_some() { + state.status_msg = Some("A create/publish/PR/issue job is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.create_repo_rx = Some(rx); state.modal = Modal::Info { @@ -74,6 +78,10 @@ pub fn spawn_create_repo( /// Like `spawn_create_repo`, but for a folder that already has git commits — /// only creates the remote (if missing) and pushes; never stages or commits. pub fn spawn_publish_existing(state: &mut HubState, path: std::path::PathBuf, display_name: String, owner: Option) { + if state.create_repo_rx.is_some() { + state.status_msg = Some("A create/publish/PR/issue job is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.create_repo_rx = Some(rx); state.modal = Modal::Info { @@ -121,6 +129,10 @@ pub fn spawn_publish_existing(state: &mut HubState, path: std::path::PathBuf, di /// creation, but the generic Progress/Done shape and the `Modal::Info` polling /// already wired up in `keys.rs` apply just as well here. pub fn spawn_create_pr(state: &mut HubState, repo: String, title: String, head: String, base: String) { + if state.create_repo_rx.is_some() { + state.status_msg = Some("A create/publish/PR/issue job is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.create_repo_rx = Some(rx); state.modal = Modal::Info { @@ -160,6 +172,10 @@ pub fn spawn_create_pr(state: &mut HubState, repo: String, title: String, head: /// Opens an issue via the Quick Actions panel. Same shared-channel pattern as /// `spawn_create_pr` above. pub fn spawn_create_issue(state: &mut HubState, repo: String, title: String) { + if state.create_repo_rx.is_some() { + state.status_msg = Some("A create/publish/PR/issue job is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.create_repo_rx = Some(rx); state.modal = Modal::Info { @@ -330,6 +346,10 @@ pub fn spawn_convention_preview(state: &mut HubState, repo: std::path::PathBuf, /// `Modal::Info` pattern; reuses `CreateRepoMsg` since a push needs no /// payload back, just success/failure. pub fn spawn_profile_push(state: &mut HubState, snap: crate::profile::SettingsSnapshot) { + if state.create_repo_rx.is_some() { + state.status_msg = Some("A create/publish/PR/issue job is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.create_repo_rx = Some(rx); let repo_name = crate::profile::SETTINGS_REPO_NAME.to_string(); @@ -364,6 +384,10 @@ pub fn spawn_profile_push(state: &mut HubState, snap: crate::profile::SettingsSn /// instead of `CreateRepoMsg`. pub fn spawn_profile_pull(state: &mut HubState) { use super::state::ProfilePullMsg; + if state.profile_pull_rx.is_some() { + state.status_msg = Some("A settings pull is already running — wait for it to finish.".into()); + return; + } let (tx, rx) = std::sync::mpsc::channel::(); state.profile_pull_rx = Some(rx); let repo_name = crate::profile::SETTINGS_REPO_NAME.to_string(); @@ -411,6 +435,10 @@ pub fn open_bulk_import_picker(state: &mut HubState, acc_idx: usize) { /// it auto-imports it immediately, since landing on this flow already means /// "I trust this account's data" and a second confirmation would be noise. pub fn spawn_bulk_import(state: &mut HubState, acc_idx: usize, affiliation: crate::providers::RepoAffiliation) { + if state.pull_all_rx.is_some() { + state.status_msg = Some("A workspace job is already running — wait for it to finish.".into()); + return; + } let Some(acc) = state.accounts.get(acc_idx).cloned() else { return; }; let (tx, rx) = std::sync::mpsc::channel::(); state.pull_all_rx = Some(rx);