{"record":{"id":"a7d6a81299005f93","repo":"farion1231/cc-switch","slug":"skill-directory-changed-during-install-please-ret","errorCode":null,"errorMessage":"Skill directory changed during install; please retry","messagePattern":"Skill directory changed during install; please retry","errorType":"exception","errorClass":"anyhow::Error","httpStatus":null,"severity":"warning","filePath":"src-tauri/src/services/skill.rs","lineNumber":909,"sourceCode":"            &skill.directory,\n        );\n\n        let readme_url =\n            Self::build_skill_doc_url(&skill.repo_owner, &skill.repo_name, &repo_branch, &doc_path);\n\n        // Re-check after the network download: another install/uninstall may have\n        // completed while the lock was intentionally released around `.await`.\n        let _state_guard = skill_state_write_guard();\n        if let Some(existing) = Self::reuse_existing_install(db, skill, &install_name, current_app)?\n        {\n            return Ok(existing);\n        }\n\n        if !dest.exists() {\n            let source = downloaded_source\n                .as_ref()\n                .map(|(_, source)| source)\n                .ok_or_else(|| anyhow!(\"Skill directory changed during install; please retry\"))?;\n            Self::preflight_install_destination(source, &install_name, current_app)?;\n            Self::copy_dir_recursive(source, &dest)?;\n        }\n\n        // 创建 InstalledSkill 记录\n        // 计算内容哈希\n        let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| {\n            log::warn!(\"Failed to compute content hash for {}: {e}\", install_name);\n            None\n        });\n\n        let installed_skill = InstalledSkill {\n            id: skill.key.clone(),\n            name: skill.name.clone(),\n            description: if skill.description.is_empty() {\n                None\n            } else {\n                Some(skill.description.clone())","sourceCodeStart":891,"sourceCodeEnd":927,"githubUrl":"https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/services/skill.rs#L891-L927","documentation":"Thrown by SkillService install after the skill-state write lock is deliberately released around the network download (.await) and then re-acquired. At re-check time the destination directory does not exist, but the in-memory downloaded_source slot is None, so there is no local copy to install from. It means another install/uninstall that ran during the download window consumed or invalidated the shared download state.","triggerScenarios":"Invoking install_skill_unified twice for the same skill in quick succession (double-click, duplicate window); an uninstall of the same skill completing while an install is still downloading; two install flows racing so one takes the reuse_existing_install path and leaves the other without a downloaded source.","commonSituations":"Install button not disabled while the request is in flight; frontend auto-retry firing while the first request is still running; slow network widening the await window; two instances of the app pointing at the same SSOT directory.","solutions":["Retry the install once — state is normally consistent on the second attempt","Disable the install button / debounce while an install request for that skill is in flight","Serialize installs client-side (queue per skill name) so only one install flow runs at a time","If it reproduces consistently, check whether a second app instance or another process is mutating the SSOT directory concurrently"],"exampleFix":"// before (frontend)\ndocument.getElementById('install')!.onclick = () => invoke('install_skill_unified', { ... });\n\n// after: single-flight per install name + one retry on the race signal\nconst inflight = new Set<string>();\nasync function install(payload: unknown, name: string) {\n  if (inflight.has(name)) return;\n  inflight.add(name);\n  try {\n    await invoke('install_skill_unified', payload);\n  } catch (e) {\n    if (String(e).includes('changed during install')) {\n      await invoke('install_skill_unified', payload); // one retry\n    } else throw e;\n  } finally {\n    inflight.delete(name);\n  }\n}","handlingStrategy":"retry","validationCode":"const inflightInstalls = new Set<string>();\nfunction canStartInstall(name: string): boolean {\n  return !inflightInstalls.has(name.toLowerCase());\n}","typeGuard":null,"tryCatchPattern":"try {\n  await invoke('install_skill_unified', payload);\n} catch (e) {\n  const msg = String(e);\n  if (msg.includes('changed during install')) {\n    await sleep(500);\n    return invoke('install_skill_unified', payload); // exactly one retry\n  }\n  throw e;\n}","preventionTips":["Disable the install button while its request is in flight","Single-flight per skill name: queue or ignore duplicate installs","Run only one app instance against a given SSOT directory"],"tags":["concurrency","race-condition","install","skill-manager","retry"],"backgroundTag":"concurrent-modification-race","analyzedSha":"06082e189d65e6d6dbadc35dacdac1ce6c79d89a","analyzedAt":"2026-08-20T14:29:00.113Z","contentChangedAt":"2026-08-20T14:29:00.113Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}