From 6e0dc03c1e83ca1ee79ee63e0bf1fcfb1e0a165a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 15 Jan 2026 10:28:04 -0500 Subject: [PATCH] Cache dynamic JSON schemas with invalidation (#46824) Dynamic schemas (settings, settings/lsp/*, debug_tasks) are now cached after first generation and returned immediately via Task::ready() on subsequent requests. Cache is invalidated when `notify_schema_changed()` is called, which happens when: - Extensions are installed/uninstalled (affects settings schema) - DAP registry changes (affects debug_tasks schema) This eliminates repeated foreground thread blocking for the same dynamic schema. **Note:** This PR is based on #46823 and should be merged after it. Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- .../src/json_schema_store.rs | 135 +++++++++++------- 1 file changed, 85 insertions(+), 50 deletions(-) diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 4b75a9667ff4698c57c8676823898f142ee52136..6d80783a1f1e5e6d95cbfcee902019416d9e95e8 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -1,4 +1,3 @@ -//! # json_schema_store use std::sync::{Arc, LazyLock}; use anyhow::{Context as _, Result}; @@ -10,6 +9,8 @@ use project::{LspStore, lsp_store::LocalLspAdapterDelegate}; use settings::LSP_SETTINGS_SCHEMA_URL_PREFIX; use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields}; +const SCHEMA_URI_PREFIX: &str = "zed://schemas/"; + const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json"); const PACKAGE_JSON_SCHEMA: &str = include_str!("schemas/package.json"); @@ -41,6 +42,14 @@ static KEYMAP_SCHEMA: LazyLock = LazyLock::new(|| { static ACTION_SCHEMA_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::default())); +// Runtime cache for dynamic schemas that depend on runtime state: +// - "settings": depends on installed fonts, themes, languages, LSP adapters (extensions can add these) +// - "settings/lsp/*": depends on LSP adapter initialization options +// - "debug_tasks": depends on DAP adapters (extensions can add these) +// Cache is invalidated via notify_schema_changed() when extensions or DAP registry change. +static DYNAMIC_SCHEMA_CACHE: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::default())); + pub fn init(cx: &mut App) { cx.set_global(SchemaStore::default()); project::lsp_store::json_language_server_ext::register_schema_handler( @@ -55,7 +64,7 @@ pub fn init(cx: &mut App) { .detach(); if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) { - cx.subscribe(&extension_events, |_, evt, cx| { + cx.subscribe(&extension_events, move |_, evt, cx| { match evt { extension::Event::ExtensionInstalled(_) | extension::Event::ExtensionUninstalled(_) @@ -63,15 +72,15 @@ pub fn init(cx: &mut App) { extension::Event::ExtensionsInstalledChanged => {} } cx.update_global::(|schema_store, cx| { - schema_store.notify_schema_changed("zed://schemas/settings", cx); + schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}settings"), cx); }); }) .detach(); } - cx.observe_global::(|cx| { + cx.observe_global::(move |cx| { cx.update_global::(|schema_store, cx| { - schema_store.notify_schema_changed("zed://schemas/debug_tasks", cx); + schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}debug_tasks"), cx); }); }) .detach(); @@ -86,6 +95,8 @@ impl gpui::Global for SchemaStore {} impl SchemaStore { fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) { + DYNAMIC_SCHEMA_CACHE.write().remove(uri); + let uri = uri.to_string(); self.lsp_stores.retain(|lsp_store| { let Some(lsp_store) = lsp_store.upgrade() else { @@ -106,38 +117,65 @@ pub fn handle_schema_request( uri: String, cx: &mut AsyncApp, ) -> Task> { - let path = match uri.strip_prefix("zed://schemas/") { + let path = match uri.strip_prefix(SCHEMA_URI_PREFIX) { Some(path) => path, - None => return Task::ready(Err(anyhow::anyhow!("Invalid URI: {}", uri))), + None => return Task::ready(Err(anyhow::anyhow!("Invalid schema URI: {}", uri))), }; + if let Some(json) = resolve_static_schema(path) { + return Task::ready(Ok(json)); + } + + if let Some(cached) = DYNAMIC_SCHEMA_CACHE.read().get(&uri).cloned() { + return Task::ready(Ok(cached)); + } + + let path = path.to_string(); + let uri_clone = uri.clone(); + cx.spawn(async move |cx| { + let schema = resolve_dynamic_schema(lsp_store, &path, cx).await?; + let json = serde_json::to_string(&schema).context("Failed to serialize schema")?; + + DYNAMIC_SCHEMA_CACHE.write().insert(uri_clone, json.clone()); + + Ok(json) + }) +} + +fn resolve_static_schema(path: &str) -> Option { let (schema_name, rest) = path.split_once('/').unzip(); let schema_name = schema_name.unwrap_or(path); match schema_name { - "tsconfig" => return Task::ready(Ok(TSCONFIG_SCHEMA.to_string())), - "package_json" => return Task::ready(Ok(PACKAGE_JSON_SCHEMA.to_string())), - "tasks" => return Task::ready(Ok(TASKS_SCHEMA.clone())), - "snippets" => return Task::ready(Ok(SNIPPETS_SCHEMA.clone())), - "jsonc" => return Task::ready(Ok(JSONC_SCHEMA.clone())), - "keymap" => return Task::ready(Ok(KEYMAP_SCHEMA.clone())), + "tsconfig" => Some(TSCONFIG_SCHEMA.to_string()), + "package_json" => Some(PACKAGE_JSON_SCHEMA.to_string()), + "tasks" => Some(TASKS_SCHEMA.clone()), + "snippets" => Some(SNIPPETS_SCHEMA.clone()), + "jsonc" => Some(JSONC_SCHEMA.clone()), + "keymap" => Some(KEYMAP_SCHEMA.clone()), "zed_inspector_style" => { #[cfg(debug_assertions)] - return Task::ready(Ok(INSPECTOR_STYLE_SCHEMA.clone())); + { + Some(INSPECTOR_STYLE_SCHEMA.clone()) + } #[cfg(not(debug_assertions))] - return Task::ready(Ok(serde_json::to_string( - &schemars::json_schema!(true).to_value(), - ) - .expect("true schema should serialize"))); + { + Some( + serde_json::to_string(&schemars::json_schema!(true).to_value()) + .expect("true schema should serialize"), + ) + } } + "action" => { let normalized_action_name = match rest { Some(name) => name, - None => return Task::ready(Err(anyhow::anyhow!("No action name provided"))), + None => return None, }; let action_name = denormalize_action_name(normalized_action_name); + if let Some(cached) = ACTION_SCHEMA_CACHE.read().get(&action_name).cloned() { - return Task::ready(Ok(cached)); + return Some(cached); } let mut generator = settings::KeymapFile::action_schema_generator(); @@ -151,34 +189,31 @@ pub fn handle_schema_request( ACTION_SCHEMA_CACHE .write() .insert(action_name, json.clone()); - return Task::ready(Ok(json)); + Some(json) } - _ => {} - } - let schema_name = schema_name.to_string(); - let rest = rest.map(|s| s.to_string()); - cx.spawn(async move |cx| { - let schema = resolve_dynamic_schema(lsp_store, &schema_name, rest.as_deref(), cx).await?; - serde_json::to_string(&schema).context("Failed to serialize schema") - }) + _ => None, + } } async fn resolve_dynamic_schema( lsp_store: Entity, - schema_name: &str, - rest: Option<&str>, + path: &str, cx: &mut AsyncApp, ) -> Result { - let languages = lsp_store.read_with(cx, |store, _| store.languages.clone()); + let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone()); + let (schema_name, rest) = path.split_once('/').unzip(); + let schema_name = schema_name.unwrap_or(path); + let schema = match schema_name { "settings" if rest.is_some_and(|r| r.starts_with("lsp/")) => { let lsp_name = rest .and_then(|r| { r.strip_prefix( LSP_SETTINGS_SCHEMA_URL_PREFIX - .strip_prefix("zed://schemas/settings/") - .unwrap(), + .strip_prefix(SCHEMA_URI_PREFIX) + .and_then(|s| s.strip_prefix("settings/")) + .unwrap_or("lsp/"), ) }) .context("Invalid LSP schema path")?; @@ -266,7 +301,7 @@ async fn resolve_dynamic_schema( task::DebugTaskFile::generate_json_schema(&adapter_schemas) } _ => { - anyhow::bail!("Unrecognized builtin JSON schema: {schema_name}"); + anyhow::bail!("Unrecognized schema: {schema_name}"); } }; Ok(schema) @@ -299,25 +334,25 @@ pub fn all_schema_file_associations( schema_file_match(paths::settings_file()), paths::local_settings_file_relative_path() ], - "url": "zed://schemas/settings", + "url": format!("{SCHEMA_URI_PREFIX}settings"), }, { "fileMatch": [schema_file_match(paths::keymap_file())], - "url": "zed://schemas/keymap", + "url": format!("{SCHEMA_URI_PREFIX}keymap"), }, { "fileMatch": [ schema_file_match(paths::tasks_file()), paths::local_tasks_file_relative_path() ], - "url": "zed://schemas/tasks", + "url": format!("{SCHEMA_URI_PREFIX}tasks"), }, { "fileMatch": [ schema_file_match(paths::debug_scenarios_file()), paths::local_debug_file_relative_path() ], - "url": "zed://schemas/debug_tasks", + "url": format!("{SCHEMA_URI_PREFIX}debug_tasks"), }, { "fileMatch": [ @@ -327,19 +362,19 @@ pub fn all_schema_file_associations( .as_path() ) ], - "url": "zed://schemas/snippets", + "url": format!("{SCHEMA_URI_PREFIX}snippets"), }, { "fileMatch": ["tsconfig.json"], - "url": "zed://schemas/tsconfig" + "url": format!("{SCHEMA_URI_PREFIX}tsconfig") }, { "fileMatch": ["package.json"], - "url": "zed://schemas/package_json" + "url": format!("{SCHEMA_URI_PREFIX}package_json") }, { "fileMatch": &jsonc_globs, - "url": "zed://schemas/jsonc" + "url": format!("{SCHEMA_URI_PREFIX}jsonc") }, ]); @@ -352,21 +387,21 @@ pub fn all_schema_file_associations( "fileMatch": [ "zed-inspector-style.json" ], - "url": "zed://schemas/zed_inspector_style" + "url": format!("{SCHEMA_URI_PREFIX}zed_inspector_style") })); } - file_associations.as_array_mut().unwrap().extend( - // ?PERF: use all_action_schemas() and don't include action schemas with no arguments - cx.all_action_names().into_iter().map(|&name| { + file_associations + .as_array_mut() + .unwrap() + .extend(cx.all_action_names().into_iter().map(|&name| { let normalized_name = normalize_action_name(name); let file_name = normalized_action_name_to_file_name(normalized_name.clone()); serde_json::json!({ "fileMatch": [file_name], - "url": format!("zed://schemas/action/{normalized_name}") + "url": format!("{}action/{normalized_name}", SCHEMA_URI_PREFIX) }) - }), - ); + })); file_associations }