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 <ben@zed.dev>
This commit is contained in:
Richard FeldmanandBen Kunkle authored and GitHub committed 2026-01-15 10:28:04 -05:00
1 parent 4af9c5427c
commit ce165dd3cb
1 file changed
+85 -50
@@ -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<String> = LazyLock::new(|| {
static ACTION_SCHEMA_CACHE: LazyLock<RwLock<HashMap<String, String>>> =
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<RwLock<HashMap<String, String>>> =
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::<SchemaStore, _>(|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::<dap::DapRegistry>(|cx| {
cx.observe_global::<dap::DapRegistry>(move |cx| {
cx.update_global::<SchemaStore, _>(|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<Result<String>> {
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<String> {
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<LspStore>,
schema_name: &str,
rest: Option<&str>,
path: &str,
cx: &mut AsyncApp,
) -> Result<serde_json::Value> {
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
}