From fe440b2d26e0aba19c5a46c4791fe7c1516e5449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:34:10 +0300 Subject: [PATCH 01/67] perf(state): isolate remote tag discovery from local repository loads Run optional remote tag lookups on a lazy shared worker pool so an offline remote cannot delay local tags and reference metadata. Keep remote discovery tracked for coalescing without treating it as foreground readiness work. Cover blocked network workers and load bookkeeping with regression tests; validated with the state test suite. --- crates/gitcomet-state/src/model/loads.rs | 33 ++++- .../src/store/effects/references.rs | 123 +++++++++++++++--- crates/gitcomet-state/src/store/executor.rs | 6 + 3 files changed, 139 insertions(+), 23 deletions(-) diff --git a/crates/gitcomet-state/src/model/loads.rs b/crates/gitcomet-state/src/model/loads.rs index b2cb01b5d..6c1f06892 100644 --- a/crates/gitcomet-state/src/model/loads.rs +++ b/crates/gitcomet-state/src/model/loads.rs @@ -170,13 +170,19 @@ impl RepoLoadsInFlight { self.line_stats.active.is_some() } - /// Linked-checkout scans do not keep the active tab's spinner running. + /// Optional remote tag discovery and linked-checkout scans do not keep the + /// active tab's spinner running after its local contents are available. pub fn any_foreground_in_flight(&self) -> bool { - self.in_flight != 0 || self.log_in_flight() || self.line_stats_in_flight() + (self.in_flight & !LoadKind::RemoteTags.mask()) != 0 + || self.log_in_flight() + || self.line_stats_in_flight() } pub fn any_in_flight(&self) -> bool { - self.any_foreground_in_flight() || self.worktree_dirty_in_flight() + self.in_flight != 0 + || self.log_in_flight() + || self.line_stats_in_flight() + || self.worktree_dirty_in_flight() } pub fn clear(&mut self) { @@ -385,6 +391,27 @@ impl LineStatsLoads { mod tests { use super::*; + #[test] + fn remote_tags_stay_tracked_without_delaying_local_readiness() { + let mut loads = RepoLoadsInFlight::default(); + assert!(loads.request(LoadKind::RemoteTags)); + assert!(loads.any_in_flight()); + assert!(!loads.any_foreground_in_flight()); + + assert!(loads.request(LoadKind::WorktreeStatus)); + assert!(loads.any_foreground_in_flight()); + assert!(!loads.finish(LoadKind::WorktreeStatus)); + assert!(!loads.any_foreground_in_flight()); + + // Background classification must preserve coalescing and completion. + assert!(!loads.request(LoadKind::RemoteTags)); + assert!(loads.finish(LoadKind::RemoteTags)); + assert!(loads.any_in_flight()); + assert!(!loads.any_foreground_in_flight()); + assert!(!loads.finish(LoadKind::RemoteTags)); + assert!(!loads.any_in_flight()); + } + #[test] fn promoted_log_bookkeeping_matches_the_prepared_effect_request() { let mut loads = RepoLoadsInFlight::default(); diff --git a/crates/gitcomet-state/src/store/effects/references.rs b/crates/gitcomet-state/src/store/effects/references.rs index 130c9a041..0156d6e1f 100644 --- a/crates/gitcomet-state/src/store/effects/references.rs +++ b/crates/gitcomet-state/src/store/effects/references.rs @@ -1,14 +1,15 @@ use crate::model::AppState; use crate::msg::{InternalMsg, Msg, ReferenceEffect}; use crate::store::repository_runtime::RepositoryRuntimes; -use crate::store::{executor::TaskExecutor, worker_channel::StoreWorkerSender}; +use crate::store::{ + executor::{StoreExecutorPool, TaskExecutor}, + worker_channel::StoreWorkerSender, +}; use gitcomet_core::services::{CancellationToken, GitRepository}; use std::sync::{Arc, RwLock}; use super::load_tokens::repo_load_context; -use super::util::{ - missing_repo_error, send_or_log, spawn_detached_with_repo_or_else, spawn_with_repo_or_else, -}; +use super::util::{missing_repo_error, send_or_log, spawn_detached_with_repo_or_else}; pub(super) fn schedule( repo_load_executor: &TaskExecutor, @@ -23,20 +24,27 @@ pub(super) fn schedule( else { return; }; + // A remote can remain offline for minutes. Keep its optional tag lookup + // away from local tags/ref metadata, including loads from other windows. + // Create the shared pool only when remote metadata is actually requested. + let remote_metadata_executor = matches!(work, ReferenceEffect::LoadRemoteTags { .. }) + .then(|| TaskExecutor::shared_for_store(StoreExecutorPool::RemoteMetadata, 2)); let (executor, task_name) = match work { - ReferenceEffect::LoadBranches { .. } => (repo_load_executor, Some("load-branches")), - ReferenceEffect::LoadRemotes { .. } => (repo_load_executor, Some("load-remotes")), - ReferenceEffect::LoadRemoteBranches { .. } => { - (repo_load_executor, Some("load-remote-branches")) - } - ReferenceEffect::LoadHeadBranch { .. } => (repo_load_executor, Some("load-head-branch")), + ReferenceEffect::LoadBranches { .. } => (repo_load_executor, "load-branches"), + ReferenceEffect::LoadRemotes { .. } => (repo_load_executor, "load-remotes"), + ReferenceEffect::LoadRemoteBranches { .. } => (repo_load_executor, "load-remote-branches"), + ReferenceEffect::LoadHeadBranch { .. } => (repo_load_executor, "load-head-branch"), ReferenceEffect::LoadUpstreamDivergence { .. } => { - (repo_load_executor, Some("load-upstream-divergence")) + (repo_load_executor, "load-upstream-divergence") } - ReferenceEffect::LoadTags { .. } | ReferenceEffect::LoadRemoteTags { .. } => { - (metadata_executor, None) - } - ReferenceEffect::LoadRefMetadata { .. } => (metadata_executor, Some("load-ref-metadata")), + ReferenceEffect::LoadTags { .. } => (metadata_executor, "load-tags"), + ReferenceEffect::LoadRemoteTags { .. } => ( + remote_metadata_executor + .as_ref() + .expect("remote metadata executor"), + "load-remote-tags", + ), + ReferenceEffect::LoadRefMetadata { .. } => (metadata_executor, "load-ref-metadata"), }; let run = move |repo: Arc, msg_tx: StoreWorkerSender| { send_or_log( @@ -50,11 +58,7 @@ pub(super) fn schedule( Msg::Internal(work.failed(missing_repo_error(repo_id))), ); }; - if let Some(task_name) = task_name { - spawn_detached_with_repo_or_else(executor, task_name, repos, repo_id, msg_tx, run, missing); - } else { - spawn_with_repo_or_else(executor, repos, repo_id, msg_tx, run, missing); - } + spawn_detached_with_repo_or_else(executor, task_name, repos, repo_id, msg_tx, run, missing); } fn run( @@ -99,3 +103,82 @@ fn run( }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{RepoId, RepoState}; + use gitcomet_core::domain::RepoSpec; + use std::{sync::mpsc, time::Duration}; + + #[test] + fn blocked_remote_discovery_does_not_hold_up_local_tags() { + let remote = TaskExecutor::shared_for_store(StoreExecutorPool::RemoteMetadata, 2); + let (started_tx, started_rx) = mpsc::channel(); + let mut releases = Vec::new(); + for _ in 0..2 { + let (release_tx, release_rx) = mpsc::channel::<()>(); + releases.push(release_tx); + let started_tx = started_tx.clone(); + remote.spawn(move || { + let _ = started_tx.send(()); + let _ = release_rx.recv(); + }); + } + for _ in 0..2 { + started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("remote worker started"); + } + + let repo_id = RepoId(1); + let mut state = AppState::test_default(); + let spec = RepoSpec { + workdir: "remote-metadata-test".into(), + }; + state + .repos + .push(RepoState::new_opening(repo_id, spec.clone())); + let mut repos = RepositoryRuntimes::default(); + repos.attach_repository_for_test( + repo_id, + Arc::new(gitcomet_core::test_support::UnconfiguredRepository::new( + spec.workdir, + )), + ); + repos.bind_test_state(&state); + let state = Arc::new(RwLock::new(Arc::new(state))); + let (tx, rx) = mpsc::channel(); + let sender = StoreWorkerSender::for_test_msg_sender(tx); + let local = TaskExecutor::new(1); + for work in [ + ReferenceEffect::LoadRemoteTags { repo_id }, + ReferenceEffect::LoadTags { repo_id }, + ] { + schedule(&local, &local, &state, &mut repos, sender.clone(), work); + } + + let first = rx.recv_timeout(Duration::from_secs(5)); + // Always release the blocked pool before assertions (including failure). + drop(releases); + let first = first.expect("local tags must finish while network workers are blocked"); + fn result(message: Msg) -> InternalMsg { + let Msg::Internal(mut message) = message else { + panic!("expected internal result") + }; + while let InternalMsg::RepoLoadFinished { message: inner, .. } = message { + message = *inner; + } + message + } + assert!(matches!(result(first), InternalMsg::TagsLoaded { .. })); + assert!(matches!( + result( + rx.recv_timeout(Duration::from_secs(5)) + .expect("remote tags complete after release") + ), + InternalMsg::RemoteTagsLoaded { .. }, + )); + local.join(); + } +} diff --git a/crates/gitcomet-state/src/store/executor.rs b/crates/gitcomet-state/src/store/executor.rs index 408c14986..1ac6b474e 100644 --- a/crates/gitcomet-state/src/store/executor.rs +++ b/crates/gitcomet-state/src/store/executor.rs @@ -54,6 +54,8 @@ pub(super) fn metadata_worker_threads() -> usize { pub(super) enum StoreExecutorPool { Primary, Metadata, + /// Optional network discovery must not queue ahead of local metadata. + RemoteMetadata, Signatures, SessionPersist, /// `git maintenance`, which can run for hours; one thread of its own. @@ -214,6 +216,7 @@ impl TaskExecutor { static PRIMARY: OnceLock> = OnceLock::new(); static SIGNATURES: OnceLock> = OnceLock::new(); static METADATA: OnceLock> = OnceLock::new(); + static REMOTE_METADATA: OnceLock> = OnceLock::new(); static SESSION_PERSIST: OnceLock> = OnceLock::new(); static MAINTENANCE: OnceLock> = OnceLock::new(); @@ -221,6 +224,9 @@ impl TaskExecutor { StoreExecutorPool::Primary => (&PRIMARY, "gitcomet-store-primary"), StoreExecutorPool::Signatures => (&SIGNATURES, "gitcomet-store-signatures"), StoreExecutorPool::Metadata => (&METADATA, "gitcomet-store-metadata"), + StoreExecutorPool::RemoteMetadata => { + (&REMOTE_METADATA, "gitcomet-store-remote-metadata") + } StoreExecutorPool::SessionPersist => { (&SESSION_PERSIST, "gitcomet-store-session-persist") } From aaaa1bf195f14a2c510c1f78f86fbea44c7c3096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:34:27 +0300 Subject: [PATCH 02/67] perf(state): refresh history on activation only when metadata changes Compare bounded Git metadata snapshots on the monitor thread while retaining a working-tree refresh for missed filesystem events. Validate repository lifetime, load epoch and displayed revisions before applying the selective result, falling back to a full refresh when metadata or state cannot be trusted. Reuse discovered watch inputs, deduplicate control-file reads and avoid recursively rescanning linked-checkout administration directories. Cover stale results, configuration and topology changes, scan budgets and unchanged activation with regression tests. --- crates/gitcomet-state/src/store/mod.rs | 42 +- .../gitcomet-state/src/store/repo_monitor.rs | 23 +- .../src/store/repo_monitor/activation.rs | 861 ++++++++++++++++++ .../src/store/repo_monitor/monitor.rs | 82 +- .../src/store/repo_monitor/policy.rs | 6 +- .../tests/selective/efficiency.rs | 115 +++ .../src/store/tests/repo_monitor.rs | 8 +- .../src/store/tests/send_failures.rs | 3 + .../src/store/worker_channel.rs | 28 + 9 files changed, 1146 insertions(+), 22 deletions(-) create mode 100644 crates/gitcomet-state/src/store/repo_monitor/activation.rs diff --git a/crates/gitcomet-state/src/store/mod.rs b/crates/gitcomet-state/src/store/mod.rs index 1ab7028f5..4a3dd320f 100644 --- a/crates/gitcomet-state/src/store/mod.rs +++ b/crates/gitcomet-state/src/store/mod.rs @@ -165,6 +165,7 @@ fn is_control_command(command: &StoreWorkerCommand) -> bool { StoreWorkerCommand::Msg(msg) | StoreWorkerCommand::Traced(msg, _) => is_control_msg(msg), StoreWorkerCommand::Shutdown => true, StoreWorkerCommand::Repository { .. } => true, + StoreWorkerCommand::ActivationRefresh(_) => false, #[cfg(any(test, feature = "test-support"))] StoreWorkerCommand::InsertRepoForTest { .. } => true, #[cfg(any(test, feature = "test-support"))] @@ -176,6 +177,7 @@ fn is_control_command(command: &StoreWorkerCommand) -> bool { fn can_control_command_overtake(command: &StoreWorkerCommand) -> bool { matches!(command.msg(), Some(Msg::Internal(_))) + || matches!(command, StoreWorkerCommand::ActivationRefresh(_)) } fn first_control_command_before_order_barrier( @@ -755,6 +757,17 @@ impl AppStore { let (mut msg, stamp) = match command { StoreWorkerCommand::Msg(msg) => (*msg, None), StoreWorkerCommand::Traced(msg, stamp) => (*msg, Some(stamp)), + StoreWorkerCommand::ActivationRefresh(refresh) => { + let state = thread_state.read().unwrap_or_else(|e| e.into_inner()); + let repo_id = refresh.repo_id; + let Some(repo) = state.repos.iter().find(|repo| repo.id == repo_id) else { + continue; + }; + let Some(change) = refresh.into_change(repo) else { + continue; + }; + (Msg::RepoExternallyChanged { repo_id, change }, None) + } StoreWorkerCommand::Shutdown => break, StoreWorkerCommand::Repository { repo_id, @@ -874,7 +887,7 @@ impl AppStore { } Msg::RepoActivated { repo_id } => { repo_load_trace::trace!( - "repo_activated_full_refresh repo_id={:?} monitor_running={}", + "repo_activated repo_id={:?} monitor_running={}", repo_id, repo_monitors.is_running(*repo_id) ); @@ -1027,20 +1040,21 @@ impl AppStore { }); } Msg::RepoActivated { repo_id } => { - // Do a FULL refresh on activation (window focus). The filesystem monitor is - // best-effort and cannot be the sole refresh trigger: in sandboxed/Flatpak - // runs an external editor's or terminal's writes to the bind-mounted repo do - // not propagate inotify events into the sandbox, so the monitor — even when - // its thread is "running" — sees neither worktree edits NOR git-state changes - // (commits, checkouts, fetches). Refreshing only the working-changes lanes - // here would leave the log/branches/HEAD/divergence stale forever in exactly - // that case. A full refresh keeps every view correct regardless of whether, - // or how reliably, the watcher is delivering events; activation is throttled - // upstream (REPO_ACTIVATION_THROTTLE), so this does not run on every alt-tab. - worker_ctx.repo_monitors.revalidate(repo_id); - let change = RepoExternalChange::all(); + // Native notifications can be lost (including across sandbox mounts). + // Always check working changes, but compare Git metadata before making + // every focus event reload the same history and reference lists. + let context = worker_ctx.thread_state.read().unwrap_or_else(|e| e.into_inner()) + .repos.iter().find(|repo| repo.id == repo_id) + .map(repo_monitor::ActivationContext::for_repo); + let refresh_message = if context.is_some_and(|context| worker_ctx.repo_monitors.refresh_on_activation(repo_id, context)) { + // The monitor will report the checked change. Retain + // the cheap periodic maintenance request below. + Msg::RepoActivated { repo_id } + } else { + Msg::RepoExternallyChanged { repo_id, change: RepoExternalChange::all() } + }; let (message, inputs) = PreparedReduction::for_worker( - Msg::RepoExternallyChanged { repo_id, change }, worker_ctx.thread_state, &repos, + refresh_message, worker_ctx.thread_state, &repos, ).into_parts(); worker_ctx.reduce_and_handle( &mut repos, diff --git a/crates/gitcomet-state/src/store/repo_monitor.rs b/crates/gitcomet-state/src/store/repo_monitor.rs index f7760064d..0379bef61 100644 --- a/crates/gitcomet-state/src/store/repo_monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor.rs @@ -21,6 +21,8 @@ use super::repo_load_trace; use super::send_diagnostics::{SendFailureKind, panic_payload_to_string, send_or_log}; use super::worker_channel::StoreWorkerSender; +mod activation; +pub(super) use activation::{ActivationContext, CheckedActivation}; mod ignore_rules; mod monitor; mod native_watcher; @@ -45,7 +47,9 @@ use test_sync::{DrainRequest, NativeCheckpoint, NativeObservations, NativeTestSt enum MonitorMsg { Event(notify::Result), + #[cfg(test)] Revalidate, + RefreshOnActivation(ActivationContext), Stop, #[cfg(test)] Barrier(mpsc::Sender<()>), @@ -369,10 +373,21 @@ impl RepoMonitorManager { self.stop_all(); } - pub(super) fn revalidate(&self, repo_id: RepoId) { - if let Some(handle) = self.handles.get(&repo_id) { - let _ = handle.msg_tx.send(MonitorMsg::Revalidate); - } + /// The metadata walk belongs on the monitor thread, never the store + /// worker: repositories on slow filesystems must not block UI commands. + pub(super) fn refresh_on_activation( + &self, + repo_id: RepoId, + context: ActivationContext, + ) -> bool { + self.handles.get(&repo_id).is_some_and(|handle| { + handle.monitor_enabled.load(Ordering::Relaxed) + && !handle.join.is_finished() + && handle + .msg_tx + .send(MonitorMsg::RefreshOnActivation(context)) + .is_ok() + }) } pub(super) fn stop_all(&mut self) { diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs new file mode 100644 index 000000000..c5230caaa --- /dev/null +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -0,0 +1,861 @@ +//! Focus refreshes must tolerate missed watcher events without reloading all +//! history and references when only working files could have changed. +use super::*; +use std::collections::BTreeMap; + +const MAX_METADATA_ENTRIES: usize = 16_384; +const MAX_CONTROL_FILE_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct ActivationRefresh { + previous: Option, + context: Option, +} + +/// A failed refresh can retain its old Ready values. Diagnostics and the load +/// epoch distinguish that state from a successful refresh of identical files. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::store) struct ActivationContext { + lifetime: u64, + epoch: crate::model::RepoLoadEpoch, + diagnostics_seq: u64, + revisions: [u64; 10], + upstream: crate::model::Loadable>, + sequencer: crate::model::Loadable, + merge_message: crate::model::Loadable>, + rebase: crate::model::Loadable, + ready: bool, +} + +impl ActivationContext { + #[cfg(test)] + pub(super) fn ready_for_test() -> Self { + Self { + lifetime: 0, + epoch: Default::default(), + diagnostics_seq: 0, + revisions: [0; 10], + upstream: crate::model::Loadable::Ready(None), + sequencer: crate::model::Loadable::Ready(Default::default()), + merge_message: crate::model::Loadable::Ready(None), + rebase: crate::model::Loadable::Ready(false), + ready: true, + } + } + pub(in crate::store) fn for_repo(repo: &crate::model::RepoState) -> Self { + use crate::model::Loadable; + Self { + lifetime: repo.lifetime(), + epoch: repo.load_epoch, + diagnostics_seq: repo.feedback.diagnostics_seq, + // A watched change can load B, then an unseen reset can restore + // disk A. Disk A matching the last activation is insufficient: + // the currently displayed state must still match that baseline. + revisions: [ + repo.head_branch_rev, + repo.log_rev, + repo.branches_rev, + repo.remote_branches_rev, + repo.tags_rev, + repo.remotes_rev, + repo.stashes_rev, + repo.reflog_rev, + repo.worktrees_rev, + repo.ops_rev, + ], + upstream: repo.upstream_divergence.clone(), + sequencer: repo.sequencer_state.clone(), + merge_message: repo.merge_commit_message.clone(), + rebase: repo.rebase_in_progress.clone(), + ready: matches!(repo.head_branch, Loadable::Ready(_)) + && matches!(repo.log, Loadable::Ready(_)) + && matches!(repo.branches, Loadable::Ready(_)) + && matches!(repo.remote_branches, Loadable::Ready(_)) + && matches!(repo.tags, Loadable::Ready(_)) + && matches!(repo.upstream_divergence, Loadable::Ready(_)) + && matches!(repo.sequencer_state, Loadable::Ready(_)) + && matches!(repo.merge_commit_message, Loadable::Ready(_)), + } + } +} + +pub(in crate::store) struct CheckedActivation { + pub repo_id: RepoId, + pub context: ActivationContext, + pub change: RepoExternalChange, +} + +impl CheckedActivation { + /// Loads can finish while the monitor is inspecting the filesystem. Only + /// the store can decide whether its current view still matches the input. + pub(in crate::store) fn into_change( + self, + repo: &crate::model::RepoState, + ) -> Option { + if repo.lifetime() != self.context.lifetime { + return None; + } + Some(if self.context == ActivationContext::for_repo(repo) { + self.change + } else { + self.change.union(RepoExternalChange::all()) + }) + } +} + +impl ActivationRefresh { + pub(super) fn prepare( + workdir: &Path, + inputs: &WatchInputs, + ) -> Result { + if !inputs.backend_discovered + || inputs.info.discovery_incomplete + || inputs.info.git_dirs.is_empty() + { + return Err(SnapshotError::Unavailable); + } + MetadataSnapshot::capture(workdir, inputs) + } + + /// Exact control bytes also cover edits that preserve their timestamps. + /// Let the monitor rediscover includes, submodules and linked checkouts in + /// its existing revalidation pass, before accepting a new baseline. + pub(super) fn needs_discovery( + &self, + current: Result<&MetadataSnapshot, &SnapshotError>, + ) -> bool { + match (self.previous.as_ref(), current) { + (_, Err(SnapshotError::DiscoveryRequired)) => true, + (_, Err(SnapshotError::Unavailable)) => false, + (Some(previous), Ok(current)) => previous.discovery != current.discovery, + // The monitor's metadata stamps alone cannot establish an exact + // baseline after startup or an unreadable/oversized probe. A + // timestamp-preserving config edit may have changed its includes. + (None, Ok(_)) => true, + } + } + + pub(super) fn complete( + &mut self, + current: Option, + context: ActivationContext, + ) -> RepoExternalChange { + let unchanged = context.ready + && self.context.as_ref() == Some(&context) + && current.as_ref().is_some_and(|current| { + self.previous + .as_ref() + .is_some_and(|previous| current.entries == previous.entries) + }); + self.context = Some(context); + self.previous = current; + if unchanged { + RepoExternalChange { + git_state: false, + tags: false, + // Index validation belongs to the monitor: an index rewrite + // can change submodule/ignore coverage without changing refs. + // Working files still refresh when native events are missed. + ..RepoExternalChange::all() + } + } else { + RepoExternalChange::all() + } + } + + #[cfg(test)] + pub(super) fn check( + &mut self, + workdir: &Path, + backend: &dyn GitBackend, + context: ActivationContext, + ) -> RepoExternalChange { + let current = WatchInputs::load(workdir, backend) + .ok() + .and_then(|inputs| Self::prepare(workdir, &inputs).ok()); + self.complete(current, context) + } + + #[cfg(test)] + pub(super) fn change( + &mut self, + workdir: &Path, + backend: &dyn GitBackend, + ) -> RepoExternalChange { + self.check(workdir, backend, ActivationContext::ready_for_test()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileStamp { + len: u64, + modified: std::time::SystemTime, + created: Option, + directory: bool, + contents: Option>, + #[cfg(unix)] + inode: u64, + #[cfg(unix)] + ctime: (i64, i64), +} + +#[derive(Clone, Copy, Debug, Default)] +struct Coverage { + recursive: bool, + recursive_contents: bool, + contents: bool, +} + +#[derive(Debug, Default)] +pub(super) struct MetadataSnapshot { + entries: BTreeMap>, + discovery: BTreeMap>, + coverage: BTreeMap, + git_dirs: Vec, + inputs: Vec, + bytes: u64, +} + +#[derive(Debug)] +pub(super) enum SnapshotError { + DiscoveryRequired, + Unavailable, +} + +impl From for SnapshotError { + fn from(_: std::io::Error) -> Self { + Self::Unavailable + } +} + +impl MetadataSnapshot { + fn capture(workdir: &Path, inputs: &WatchInputs) -> Result { + let mut snapshot = Self { + git_dirs: inputs.info.git_dirs.clone(), + inputs: inputs.inputs.clone(), + ..Self::default() + }; + for path in &inputs.inputs { + snapshot.add_discovery(path)?; + } + snapshot.add_discovery(&workdir.join(".gitmodules"))?; + for root in &inputs.info.git_dirs { + snapshot.add_discovery(root)?; + for name in ["HEAD", "config", "config.worktree", "commondir", "gitdir"] { + snapshot.add_discovery(&root.join(name))?; + } + // Deliberately omit objects and private caches: reading their + // contents is not necessary to detect a changed Git view. Ref + // targets, replacements and shallow boundaries are included. + for name in [ + "ORIG_HEAD", + "FETCH_HEAD", + "MERGE_HEAD", + "MERGE_MSG", + "MERGE_MODE", + "AUTO_MERGE", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "REBASE_HEAD", + "BISECT_LOG", + "BISECT_START", + "BISECT_NAMES", + "BISECT_EXPECTED_REV", + "packed-refs", + "shallow", + "info/grafts", + "info/attributes", + ] { + snapshot.add(&root.join(name), false, true)?; + } + for name in [ + "refs", + "reftable", + "rebase-apply", + "rebase-merge", + "sequencer", + ] { + snapshot.add(&root.join(name), true, true)?; + } + // The application reads HEAD's reflog. Branch/tag reflogs are not + // inputs to the displayed history or references. + snapshot.add(&root.join("logs/HEAD"), false, false)?; + // Discovery already returns each linked administrative root. + // Inspect the topology here, then scan each root exactly once. + let worktrees = root.join("worktrees"); + snapshot.add_discovery(&worktrees)?; + if snapshot.entries[&worktrees] + .as_ref() + .is_some_and(|stamp| stamp.directory) + { + for entry in fs::read_dir(&worktrees)? { + let entry = entry?; + if entry.file_name().as_encoded_bytes().ends_with(b".lock") { + continue; + } + let path = entry.path(); + snapshot.add_discovery(&path)?; + if !inputs.info.git_dirs.contains(&path) { + return Err(SnapshotError::DiscoveryRequired); + } + } + } + } + Ok(snapshot) + } + + fn add_discovery(&mut self, path: &Path) -> std::io::Result<()> { + self.add(path, false, true)?; + self.discovery + .insert(path.to_path_buf(), self.entries[path].clone()); + Ok(()) + } + + pub(super) fn covers(&self, inputs: &WatchInputs) -> bool { + inputs.backend_discovered + && !inputs.info.discovery_incomplete + && self.git_dirs == inputs.info.git_dirs + && self.inputs == inputs.inputs + } + + pub(super) fn discovery_still_matches(&self, current: &Self) -> bool { + self.discovery + .iter() + .all(|(path, stamp)| current.discovery.get(path) == Some(stamp)) + } + + fn add(&mut self, path: &Path, recursive: bool, read_contents: bool) -> std::io::Result<()> { + let previous = self.coverage.get(path).copied(); + if let Some(previous) = previous + && (!recursive || previous.recursive) + && (!recursive || !read_contents || previous.recursive_contents) + && (!read_contents || previous.contents) + { + return Ok(()); + } + if previous.is_none() && self.entries.len() >= MAX_METADATA_ENTRIES { + return Err(std::io::Error::other("activation metadata limit exceeded")); + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Windows can report PATH_NOT_FOUND for a child of a regular + // file, too. That is an unreadable layout, not a stable absent + // optional input. Validate the nearest existing ancestor. + for ancestor in path.ancestors().skip(1) { + match fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.is_dir() => break, + Ok(_) => { + return Err(std::io::Error::other( + "activation input has a non-directory ancestor", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + self.entries.insert(path.to_path_buf(), None); + self.coverage.insert( + path.to_path_buf(), + Coverage { + recursive, + recursive_contents: recursive && read_contents, + contents: read_contents, + }, + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + // Fall back to the full refresh for unusual layouts instead of + // mistaking an unchanged link for an unchanged target. + if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { + return Err(std::io::Error::other( + "unsupported activation metadata input", + )); + } + let directory = metadata.is_dir(); + let contents = if previous.is_some_and(|coverage| coverage.contents) { + self.entries + .get(path) + .and_then(|stamp| stamp.as_ref()) + .and_then(|stamp| stamp.contents.clone()) + } else if read_contents && !directory { + if metadata.len().saturating_add(self.bytes) > MAX_CONTROL_FILE_BYTES { + return Err(std::io::Error::other( + "activation control file limit exceeded", + )); + } + // HEAD, refs and configuration can be rewritten in place while + // preserving timestamps. Their bytes are the actual input. + use std::io::Read; + let mut contents = Vec::new(); + fs::File::open(path)? + .take(MAX_CONTROL_FILE_BYTES - self.bytes + 1) + .read_to_end(&mut contents)?; + self.bytes += contents.len() as u64; + if self.bytes > MAX_CONTROL_FILE_BYTES { + return Err(std::io::Error::other( + "activation control file limit exceeded", + )); + } + Some(contents.into()) + } else { + None + }; + self.entries.insert( + path.to_path_buf(), + Some(FileStamp { + len: if directory { 0 } else { metadata.len() }, + // Directory identity is enough: recursive inputs enumerate every + // child; a Git root's mtime changes with unrelated lock files. + modified: if directory { + std::time::SystemTime::UNIX_EPOCH + } else { + metadata.modified()? + }, + created: metadata.created().ok(), + directory, + contents, + #[cfg(unix)] + inode: std::os::unix::fs::MetadataExt::ino(&metadata), + #[cfg(unix)] + ctime: if directory { + (0, 0) + } else { + ( + std::os::unix::fs::MetadataExt::ctime(&metadata), + std::os::unix::fs::MetadataExt::ctime_nsec(&metadata), + ) + }, + }), + ); + self.coverage.insert( + path.to_path_buf(), + Coverage { + recursive: recursive || previous.is_some_and(|coverage| coverage.recursive), + recursive_contents: (recursive && read_contents) + || previous.is_some_and(|coverage| coverage.recursive_contents), + contents: read_contents || previous.is_some_and(|coverage| coverage.contents), + }, + ); + if recursive && directory { + for entry in fs::read_dir(path)? { + let entry = entry?; + if !entry.file_name().as_encoded_bytes().ends_with(b".lock") { + self.add(&entry.path(), true, read_contents)?; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gitcomet_core::services::{GitRepository, RepositoryWatchInfo}; + + struct Backend(Option); + impl GitBackend for Backend { + fn open(&self, _: &Path) -> gitcomet_core::services::Result> { + unreachable!("metadata comparison must not open a repository") + } + fn repository_watch_info( + &self, + _: &Path, + ) -> gitcomet_core::services::Result> { + Ok(self.0.clone()) + } + } + + fn fixture() -> (tempfile::TempDir, Backend) { + let dir = tempfile::tempdir().unwrap(); + let git_dir = dir.path().join(".git"); + fs::create_dir_all(git_dir.join("refs/heads")).unwrap(); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + fs::write(git_dir.join("refs/heads/main"), "1111\n").unwrap(); + let backend = Backend(Some(RepositoryWatchInfo { + git_dirs: vec![git_dir], + ..Default::default() + })); + (dir, backend) + } + + fn assert_working_changes_only(change: RepoExternalChange) { + assert!(!change.git_state && !change.tags); + assert!(change.worktree && change.index); + assert!(change.large_file_support && change.text_attributes); + } + + #[test] + fn unchanged_focus_and_worktree_edits_do_not_reload_history() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write( + dir.path().join("changed.txt"), + "external edit without a watcher event", + ) + .unwrap(); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + } + + #[test] + fn missed_git_metadata_changes_restore_full_refresh() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + for name in [ + "HEAD", + "refs/heads/main", + "refs/heads/nested/topic", + "refs/tags/v1", + "packed-refs", + "reftable/tables.list", + "shallow", + "config", + "MERGE_HEAD", + "rebase-merge/head-name", + "sequencer/todo", + "logs/HEAD", + ] { + let path = dir.path().join(".git").join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("new value for {name}\n")).unwrap(); + let change = refresh.change(dir.path(), &backend); + assert!(change.git_state && change.tags, "missed change: {name}"); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::remove_file(&path).unwrap(); + assert!( + refresh.change(dir.path(), &backend).git_state, + "missed deletion: {name}" + ); + } + } + + #[test] + fn common_directory_and_external_config_inputs_are_checked() { + let (dir, mut backend) = fixture(); + let external = tempfile::tempdir().unwrap(); + let common = external.path().join("common"); + fs::create_dir_all(common.join("refs/heads")).unwrap(); + let config = external.path().join("included-config"); + let info = backend.0.as_mut().unwrap(); + info.git_dirs.push(common.clone()); + info.ignore_inputs.push(config.clone()); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write(common.join("refs/heads/main"), "2222\n").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + fs::write(&config, "[branch.main]\n remote = origin\n").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + } + + #[test] + fn index_only_changes_keep_history_and_tags_but_still_refresh_status() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + fs::write(dir.path().join(".git/index"), "new staged contents").unwrap(); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::remove_file(dir.path().join(".git/index")).unwrap(); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + } + + #[test] + fn duplicate_control_inputs_are_read_and_budgeted_once() { + let (dir, mut backend) = fixture(); + let config = dir.path().join(".git/config"); + fs::write(&config, vec![b'#'; 3 * 1024 * 1024]).unwrap(); + backend + .0 + .as_mut() + .unwrap() + .ignore_inputs + .push(config.clone()); + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + let snapshot = MetadataSnapshot::capture(dir.path(), &inputs).unwrap(); + // The same config is an explicit discovered input and a Git control. + // Counting both would exceed the 4 MiB budget and disable narrowing. + assert!(snapshot.bytes < 3 * 1024 * 1024 + 1024); + assert_eq!( + snapshot.entries[&config] + .as_ref() + .unwrap() + .contents + .as_ref() + .unwrap() + .len(), + 3 * 1024 * 1024 + ); + } + + #[test] + fn unavailable_metadata_snapshot_does_not_request_watcher_rebuild() { + let (dir, backend) = fixture(); + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + fs::write( + dir.path().join(".git/config"), + vec![b'#'; MAX_CONTROL_FILE_BYTES as usize + 1], + ) + .unwrap(); + let probe = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(matches!(probe, Err(SnapshotError::Unavailable))); + assert!(!refresh.needs_discovery(probe.as_ref())); + assert!( + refresh + .complete(probe.ok(), ActivationContext::ready_for_test()) + .git_state + ); + fs::remove_file(dir.path().join(".git/config")).unwrap(); + let recovered = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(recovered.is_ok()); + assert!( + refresh.needs_discovery(recovered.as_ref()), + "recovering a readable snapshot must rediscover exact control inputs" + ); + } + + #[test] + fn duplicate_visits_upgrade_recursive_and_content_coverage() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("child"); + fs::write(&file, "bytes").unwrap(); + let mut snapshot = MetadataSnapshot::default(); + snapshot.add(dir.path(), true, false).unwrap(); + snapshot.add(dir.path(), false, true).unwrap(); + snapshot.add(dir.path(), true, true).unwrap(); + assert_eq!( + snapshot.entries[&file] + .as_ref() + .unwrap() + .contents + .as_deref(), + Some(b"bytes".as_slice()) + ); + assert_eq!(snapshot.bytes, 5); + snapshot.add(dir.path(), true, true).unwrap(); + snapshot.add(&file, false, true).unwrap(); + assert_eq!(snapshot.bytes, 5); + } + + #[test] + fn linked_admin_roots_are_scanned_once_and_new_roots_require_discovery() { + let (dir, mut backend) = fixture(); + let linked = dir.path().join(".git/worktrees/linked"); + fs::create_dir_all(linked.join("logs/refs/heads")).unwrap(); + fs::write(linked.join("HEAD"), "ref: refs/heads/linked\n").unwrap(); + fs::write(linked.join("index"), "staged files").unwrap(); + fs::write( + linked.join("logs/refs/heads/linked"), + "unused branch reflog", + ) + .unwrap(); + backend.0.as_mut().unwrap().git_dirs.push(linked.clone()); + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + let snapshot = MetadataSnapshot::capture(dir.path(), &inputs).unwrap(); + assert!(!snapshot.entries.contains_key(&linked.join("index"))); + assert!( + !snapshot + .entries + .contains_key(&linked.join("logs/refs/heads/linked")) + ); + assert_eq!(snapshot.bytes, 49); // root HEAD/ref plus linked HEAD, once each + let mut refresh = ActivationRefresh::default(); + refresh.complete(Some(snapshot), ActivationContext::ready_for_test()); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write(linked.join("HEAD"), "ref: refs/heads/other\n").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + fs::write(linked.join("gitdir"), "moved-checkout/.git\n").unwrap(); + let moved = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(refresh.needs_discovery(moved.as_ref())); + assert!(refresh.change(dir.path(), &backend).git_state); + fs::create_dir_all(dir.path().join(".git/worktrees/new")).unwrap(); + let current = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(refresh.needs_discovery(current.as_ref())); + } + + #[test] + fn only_the_displayed_head_reflog_invalidates_history() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + let logs = dir.path().join(".git/logs"); + fs::create_dir_all(logs.join("refs/heads")).unwrap(); + fs::write(logs.join("refs/heads/main"), "unused reflog").unwrap(); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write(logs.join("HEAD"), "displayed reflog").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + } + + #[test] + fn incomplete_or_unsupported_discovery_never_skips_full_refresh() { + let (dir, mut backend) = fixture(); + backend.0.as_mut().unwrap().discovery_incomplete = true; + for backend in [backend, Backend(None)] { + let mut refresh = ActivationRefresh::default(); + for _ in 0..2 { + assert!(refresh.change(dir.path(), &backend).git_state); + } + } + } + + #[test] + fn metadata_errors_discard_the_baseline_and_recover_conservatively() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + // A regular file where info is expected makes deeper inputs + // unreadable and must not be cached as an unchanged error. + fs::write(dir.path().join(".git/info"), "invalid info directory").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert!(refresh.change(dir.path(), &backend).git_state); + fs::remove_file(dir.path().join(".git/info")).unwrap(); + fs::create_dir(dir.path().join(".git/info")).unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + } + + #[test] + fn failed_or_cancelled_refresh_is_retried_with_identical_metadata() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + let mut context = ActivationContext::ready_for_test(); + assert!( + refresh + .check(dir.path(), &backend, context.clone()) + .git_state + ); + assert_working_changes_only(refresh.check(dir.path(), &backend, context.clone())); + // Even a failed load that retains Ready data increments diagnostics. + context.diagnostics_seq += 1; + assert!( + refresh + .check(dir.path(), &backend, context.clone()) + .git_state + ); + assert_working_changes_only(refresh.check(dir.path(), &backend, context.clone())); + context.epoch = context.epoch.next(); + assert!( + refresh + .check(dir.path(), &backend, context.clone()) + .git_state + ); + context.ready = false; + for _ in 0..2 { + assert!( + refresh + .check(dir.path(), &backend, context.clone()) + .git_state + ); + } + } + + #[test] + fn displayed_state_changes_invalidate_an_old_matching_disk_snapshot() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + let mut context = ActivationContext::ready_for_test(); + refresh.check(dir.path(), &backend, context.clone()); + assert_working_changes_only(refresh.check(dir.path(), &backend, context.clone())); + // The watcher loaded B after activation at A; an unseen reset put disk + // back at A. An unchanged disk snapshot must not keep displaying B. + context.revisions[1] += 1; + assert!( + refresh + .check(dir.path(), &backend, context.clone()) + .git_state + ); + assert_working_changes_only(refresh.check(dir.path(), &backend, context)); + } + + #[test] + fn ref_content_changes_are_detected_with_preserved_size_and_mtime() { + let (dir, backend) = fixture(); + let mut refresh = ActivationRefresh::default(); + refresh.change(dir.path(), &backend); + let path = dir.path().join(".git/refs/heads/main"); + let modified = fs::metadata(&path).unwrap().modified().unwrap(); + fs::write(&path, "2222\n").unwrap(); + fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(modified)) + .unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + } + + #[test] + fn delayed_activation_reply_revalidates_current_state_and_repository_lifetime() { + let mut repo = crate::model::RepoState::new_opening( + RepoId(1), + gitcomet_core::domain::RepoSpec { + workdir: PathBuf::from("repo"), + }, + ); + let context = ActivationContext::for_repo(&repo); + let checked = || CheckedActivation { + repo_id: repo.id, + context: context.clone(), + change: RepoExternalChange { + git_state: false, + tags: false, + ..RepoExternalChange::all() + }, + }; + assert!(!checked().into_change(&repo).unwrap().git_state); + // The UI accepted a different history result while metadata was read. + let pending = checked(); + repo.log_rev += 1; + assert!(pending.into_change(&repo).unwrap().git_state); + let reopened = crate::model::RepoState::new_opening(repo.id, repo.spec.clone()); + let pending = CheckedActivation { + repo_id: repo.id, + context, + change: RepoExternalChange::all(), + }; + assert!(pending.into_change(&reopened).is_none()); + } + + #[test] + fn equal_successful_refreshes_preserve_the_production_activation_context() { + use crate::model::Loadable; + let mut repo = crate::model::RepoState::new_opening( + RepoId(1), + gitcomet_core::domain::RepoSpec { + workdir: PathBuf::from("repo"), + }, + ); + let page = Arc::new(gitcomet_core::domain::LogPage { + commits: Vec::new(), + next_cursor: None, + }); + let apply = |repo: &mut crate::model::RepoState| { + repo.set_head_branch(Loadable::Ready("main".into())); + repo.set_log(Loadable::Ready(page.clone())); + repo.set_branches(Loadable::Ready(Vec::new())); + repo.set_remote_branches(Loadable::Ready(Vec::new())); + repo.set_tags(Loadable::Ready(Vec::new())); + repo.set_remotes(Loadable::Ready(Vec::new())); + repo.set_stashes(Loadable::Ready(Vec::new())); + repo.set_reflog(Loadable::Ready(Vec::new())); + repo.set_worktrees(Loadable::Ready(Vec::new())); + repo.set_upstream_divergence(Loadable::Ready(None)); + repo.set_rebase_in_progress(Loadable::Ready(false)); + repo.set_sequencer_state(Loadable::Ready(Default::default())); + repo.set_merge_commit_message(Loadable::Ready(None)); + }; + apply(&mut repo); + let before = ActivationContext::for_repo(&repo); + assert!(before.ready); + apply(&mut repo); + assert_eq!(before, ActivationContext::for_repo(&repo)); + } +} diff --git a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs index 9c6a97e29..3dbc335fa 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs @@ -686,13 +686,24 @@ pub(super) fn repo_monitor_thread( let mut index_dirty = false; let mut rebuild = None; let mut idle_at = Instant::now() + config.idle_tick; + let mut activation_refresh = super::activation::ActivationRefresh::default(); let leased = Arc::clone(&config.leased); let worktree_owner = config.worktree_owner.clone(); - let flush = |change| { + let flush = |change, activation_context: Option| { change_generation.fetch_add(1, Ordering::SeqCst); let active = active_repo_id.load(Ordering::SeqCst); if active == repo_id.0 || leased.load(Ordering::Relaxed) { trace_repo_monitor_flush("flush", repo_id, &change, active); + if let Some(context) = activation_context + && worktree_owner.is_none() + { + msg_tx.send_activation_refresh_or_log(CheckedActivation { + repo_id, + context, + change, + }); + return; + } msg_tx.send_repo_monitor_or_log( match &worktree_owner { Some((lifetime, path)) => Msg::WorktreeExternallyChanged { @@ -718,6 +729,7 @@ pub(super) fn repo_monitor_thread( .min(idle_at.saturating_duration_since(now)); let mut due_change = None; let mut revalidate = false; + let mut refresh_on_activation = None; match monitor_rx.recv_timeout(timeout) { Ok(MonitorMsg::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => break, #[cfg(test)] @@ -740,7 +752,12 @@ pub(super) fn repo_monitor_thread( }; let _ = reply.send(result); } + #[cfg(test)] Ok(MonitorMsg::Revalidate) => revalidate = true, + Ok(MonitorMsg::RefreshOnActivation(context)) => { + revalidate = true; + refresh_on_activation = Some(context); + } Ok(MonitorMsg::Event(result)) => { if !monitor_enabled.load(Ordering::Relaxed) { break; @@ -839,6 +856,17 @@ pub(super) fn repo_monitor_thread( #[cfg(not(test))] let drain_work = false; if due_change.is_some() || revalidate || idle || drain_work { + // Reuse the monitor's discovered inputs. Exact control contents + // complement its cheap metadata stamps when timestamps were kept. + let activation_probe = refresh_on_activation + .as_ref() + .map(|_| super::activation::ActivationRefresh::prepare(&workdir, &state.inputs)); + if let Some(probe) = &activation_probe + && activation_refresh.needs_discovery(probe.as_ref()) + { + policy_dirty = true; + } + let mut activation_snapshot = activation_probe.and_then(Result::ok); if state.inputs.stamps.changed() { policy_dirty = true; } @@ -904,6 +932,7 @@ pub(super) fn repo_monitor_thread( } WatchSetup::Cancelled => break, } + loaded = true; note_watch_outcome(&msg_tx, repo_id, &mut degraded, °raded_flag, outcome); debouncer.take(); due_change = Some(RepoExternalChange { @@ -913,8 +942,57 @@ pub(super) fn repo_monitor_thread( } policy_dirty = false; index_dirty = false; + if let Some(context) = &refresh_on_activation { + if loaded + && !activation_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.covers(&state.inputs)) + { + let current = + super::activation::ActivationRefresh::prepare(&workdir, &state.inputs).ok(); + // Newly discovered paths need a snapshot too. Do not cache + // a configuration that changed during that discovery as + // though its new include/topology inputs were inspected. + activation_snapshot = match (activation_snapshot, current) { + (Some(previous), Some(current)) + if !previous.discovery_still_matches(¤t) => + { + None + } + (_, current) => current, + }; + } + if state.rules.failed { + activation_snapshot = None; + } + let change = activation_refresh.complete(activation_snapshot, context.clone()); + if !monitor_enabled.load(Ordering::Relaxed) { + break; + } + repo_load_trace::trace!( + "repo_activated_refresh repo_id={:?} git_state={} tags={}", + repo_id, + change.git_state, + change.tags, + ); + // Other checkouts can have working-file edits without any + // changed Git metadata. Keep their background status fallback + // when the current repository's history refresh is skipped. + if !change.git_state + && worktree_owner.is_none() + && active_repo_id.load(Ordering::SeqCst) == repo_id.0 + { + msg_tx.send_repo_monitor_or_log( + Msg::LoadWorktreeDirty { repo_id }, + "repo activation worktree refresh", + ); + } + due_change = Some( + due_change.map_or(change.clone(), |pending| merge_change(pending, change)), + ); + } if let Some(change) = due_change { - flush(change); + flush(change, refresh_on_activation); } } #[cfg(test)] diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index 5c19af78f..0c0020f8e 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -275,6 +275,7 @@ impl Stamps { #[derive(Default)] pub(super) struct WatchInputs { pub info: RepositoryWatchInfo, + pub backend_discovered: bool, pub inputs: Vec, pub stamps: Stamps, pub indexes: Stamps, @@ -297,7 +298,9 @@ impl WatchInputs { stamps.add(root.join("config")); indexes.add(root.join("index")); } - let info = backend.repository_watch_info(workdir)?.unwrap_or_else(|| { + let discovered = backend.repository_watch_info(workdir)?; + let backend_discovered = discovered.is_some(); + let info = discovered.unwrap_or_else(|| { let mut info = RepositoryWatchInfo::default(); if let Some(root) = root_git { info.cache_dirs.extend( @@ -314,6 +317,7 @@ impl WatchInputs { }); let mut inputs = Self { info, + backend_discovered, stamps, indexes, index_file, diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs index eee2ad51c..16bcc415b 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs @@ -354,6 +354,121 @@ fn external_excludes_edit_applies_on_revalidate() { ); } +#[test] +fn activation_checks_missed_changes_without_reloading_unchanged_git_metadata() { + let (_temp, root) = repository(); + struct CountingBackend(Arc); + impl GitBackend for CountingBackend { + fn open( + &self, + root: &Path, + ) -> gitcomet_core::services::Result> + { + gitcomet_git_gix::GixBackend.open(root) + } + fn repository_watch_info( + &self, + root: &Path, + ) -> gitcomet_core::services::Result> + { + self.0.fetch_add(1, Ordering::Relaxed); + gitcomet_git_gix::GixBackend.repository_watch_info(root) + } + fn worktree_ignore_matcher( + &self, + root: &Path, + ) -> gitcomet_core::services::Result>> { + gitcomet_git_gix::GixBackend.worktree_ignore_matcher(root) + } + } + let discoveries = Arc::new(AtomicU64::new(0)); + // Hold native callbacks away from the monitor to exercise the same + // fallback needed when an external editor's events do not cross a mount. + let (native_tx, _native_rx) = mpsc::channel(); + let monitor = RunningMonitor::start_with_callback( + &root, + Arc::new(CountingBackend(discoveries.clone())), + MonitorConfig::default(), + Some(native_tx), + ); + let activate = || { + monitor + .tx + .send(MonitorMsg::RefreshOnActivation( + ActivationContext::ready_for_test(), + )) + .unwrap(); + let mut worktree_scan = false; + loop { + match monitor.rx.recv_timeout(Duration::from_secs(10)).unwrap() { + Msg::LoadWorktreeDirty { repo_id: RepoId(1) } => worktree_scan = true, + Msg::RepoExternallyChanged { + repo_id: RepoId(1), + change, + } => { + return (change, worktree_scan); + } + other => panic!("unexpected activation result: {other:?}"), + } + } + }; + assert!(activate().0.git_state, "first focus establishes a baseline"); + assert_eq!( + discoveries.load(Ordering::Relaxed), + 2, + "the first readable activation baseline needs one exact-input discovery" + ); + let (unchanged, worktree_scan) = activate(); + assert!(!unchanged.git_state && !unchanged.tags); + assert!(unchanged.worktree && unchanged.index); + assert!( + worktree_scan, + "other checkouts still need status revalidation" + ); + fs::write(root.join("source.txt"), "unseen working edit").unwrap(); + assert!(!activate().0.git_state); + run_git(&root, &["add", "source.txt"]); + let (index_change, _) = activate(); + assert!(index_change.index && !index_change.git_state && !index_change.tags); + assert_eq!( + discoveries.load(Ordering::Relaxed), + 3, + "index validation needs one discovery, without an activation duplicate" + ); + run_git(&root, &["branch", "unseen-branch"]); + assert!( + activate().0.git_state, + "missed ref writes must refresh history" + ); + assert!(!activate().0.git_state); + assert_eq!( + discoveries.load(Ordering::Relaxed), + 3, + "ref changes do not change discovery inputs" + ); + run_git(&root, &["tag", "unseen-tag"]); + assert!( + activate().0.tags, + "missed tag writes must refresh decorations" + ); + fs::write( + root.join(".git/info/attributes"), + vec![b'#'; 4 * 1024 * 1024 + 1], + ) + .unwrap(); + for _ in 0..2 { + assert!( + activate().0.git_state, + "oversized metadata needs a full data refresh" + ); + } + assert_eq!( + discoveries.load(Ordering::Relaxed), + 3, + "an unavailable metadata probe must not rebuild healthy watcher coverage" + ); +} + #[test] fn external_excludes_edit_applies_on_existing_idle_tick() { let (_temp, root) = repository(); diff --git a/crates/gitcomet-state/src/store/tests/repo_monitor.rs b/crates/gitcomet-state/src/store/tests/repo_monitor.rs index 159000643..27e3ab7f0 100644 --- a/crates/gitcomet-state/src/store/tests/repo_monitor.rs +++ b/crates/gitcomet-state/src/store/tests/repo_monitor.rs @@ -957,7 +957,9 @@ fn repo_monitor_manager_never_reuses_a_dead_monitor() { monitors.insert_blocked_monitor_for_test(RepoId(7), release_rx, exited_tx); // The thread ends while the monitor stays enabled, as after a panic. - monitors.revalidate(RepoId(7)); + let state = active_ready_repo_state(RepoId(7), PathBuf::from("unused-monitor-workdir")); + let context = monitor_impl::ActivationContext::for_repo(&state.repos[0]); + assert!(monitors.refresh_on_activation(RepoId(7), context.clone())); release_tx .send(()) .expect("test monitor release signal should send"); @@ -977,6 +979,10 @@ fn repo_monitor_manager_never_reuses_a_dead_monitor() { ); std::thread::yield_now(); } + assert!( + !monitors.refresh_on_activation(RepoId(7), context), + "a dead monitor must leave activation to the full-refresh fallback" + ); } #[test] diff --git a/crates/gitcomet-state/src/store/tests/send_failures.rs b/crates/gitcomet-state/src/store/tests/send_failures.rs index bbe9c17f1..79fa96623 100644 --- a/crates/gitcomet-state/src/store/tests/send_failures.rs +++ b/crates/gitcomet-state/src/store/tests/send_failures.rs @@ -630,6 +630,9 @@ fn concurrent_last_app_store_drops_shutdown_worker_once() { super::worker_channel::StoreWorkerCommand::Repository { .. } => { panic!("expected shutdown command, got repository read") } + super::worker_channel::StoreWorkerCommand::ActivationRefresh(_) => { + panic!("expected shutdown command, got checked repository activation") + } super::worker_channel::StoreWorkerCommand::Msg(_) | super::worker_channel::StoreWorkerCommand::Traced(..) => { panic!("expected shutdown command, got message command") diff --git a/crates/gitcomet-state/src/store/worker_channel.rs b/crates/gitcomet-state/src/store/worker_channel.rs index a5bde739c..dfc55d45e 100644 --- a/crates/gitcomet-state/src/store/worker_channel.rs +++ b/crates/gitcomet-state/src/store/worker_channel.rs @@ -37,6 +37,7 @@ pub(super) enum StoreWorkerCommand { /// A message sent while operation tracing is on, carrying the sender's /// operation and enqueue time. Handled exactly like [`Self::Msg`]. Traced(Box, op_trace::Stamp), + ActivationRefresh(Box), Shutdown, Repository { repo_id: RepoId, @@ -240,6 +241,33 @@ impl StoreWorkerSender { self.send_or_log(msg, SendFailureKind::RepoMonitorMessage, context, true); } + pub(super) fn send_activation_refresh_or_log( + &self, + refresh: super::repo_monitor::CheckedActivation, + ) { + if !self.is_alive() { + return; + } + match &self.inner { + StoreWorkerSenderInner::Command(tx) => { + send_diagnostics::send_or_log( + tx, + StoreWorkerCommand::ActivationRefresh(Box::new(refresh)), + SendFailureKind::RepoMonitorMessage, + "checked repository activation", + ); + } + #[cfg(test)] + StoreWorkerSenderInner::MsgForTest(_) => self.send_repo_monitor_or_log( + Msg::RepoExternallyChanged { + repo_id: refresh.repo_id, + change: refresh.change, + }, + "checked repository activation test", + ), + } + } + fn wrap_effect_message(&self, msg: Msg) -> Msg { #[cfg(test)] if matches!(&self.inner, StoreWorkerSenderInner::MsgForTest(_)) { From 63c791ce0f3a2d7f164a50b2f2775ab1877f3c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:34:54 +0300 Subject: [PATCH 03/67] fix(ui): keep usable repository tabs quiet during background refresh Show tab progress for opening, explicit reloads, history changes and user operations, while retaining a quiet tab when focus or filesystem checks refresh already-ready primary data. Add spinner classification and window activation lifecycle regressions; validated with 53 window lifecycle tests. --- .../src/view/panels/repo_tabs_bar.rs | 57 ++++++++++++++++++- .../src/view/tests/window_lifecycle.rs | 30 ++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs index 0b2239273..43f79e808 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs @@ -350,8 +350,16 @@ impl RepoTabsBarView { } fn is_repo_busy(repo: &RepoState) -> bool { + // Focus and filesystem refreshes retain the usable primary view. + // Their checks must not make the whole tab appear to load again. + // Opening, explicit Reload and history-filter changes replace these + // values with Loading; pagination has its own explicit pending flag. + let primary_view_ready = matches!(repo.head_branch, Loadable::Ready(_)) + && matches!(repo.status, Loadable::Ready(_)) + && matches!(repo.log, Loadable::Ready(_)); matches!(repo.open, Loadable::Loading) - || repo.loads_in_flight.any_foreground_in_flight() + || (repo.loads_in_flight.any_foreground_in_flight() + && (!primary_view_ready || repo.log_loading_more)) || repo.local_actions_in_flight > 0 || repo.pull_in_flight > 0 || repo.push_in_flight > 0 @@ -1444,6 +1452,53 @@ mod tests { } } + #[test] + fn remote_tag_discovery_does_not_keep_the_repository_tab_busy() { + use gitcomet_state::model::{LoadKind, Loadable}; + let mut repo = repo_state("/tmp/remote-tag-loading"); + repo.open = Loadable::Ready(()); + repo.loads_in_flight.request(LoadKind::RemoteTags); + assert!(!RepoTabsBarView::is_repo_busy(&repo)); + + // Local data and user operations still own the tab's progress mark. + let mut local_load = repo.clone(); + local_load.loads_in_flight.request(LoadKind::Tags); + assert!(RepoTabsBarView::is_repo_busy(&local_load)); + repo.local_actions_in_flight = 1; + assert!(RepoTabsBarView::is_repo_busy(&repo)); + } + + #[test] + fn retained_primary_data_keeps_background_refreshes_out_of_the_tab_spinner() { + use gitcomet_state::model::{LoadKind, Loadable}; + let mut repo = repo_state("/tmp/focus-refresh"); + repo.open = Loadable::Ready(()); + repo.head_branch = Loadable::Ready("main".into()); + repo.status = Loadable::Ready(std::sync::Arc::new(gitcomet_core::domain::RepoStatus { + staged: Default::default(), + unstaged: Default::default(), + })); + repo.log = Loadable::Ready(std::sync::Arc::new(gitcomet_core::domain::LogPage { + commits: Vec::new(), + next_cursor: None, + })); + repo.loads_in_flight.request(LoadKind::WorktreeStatus); + repo.loads_in_flight.request(LoadKind::Tags); + assert!(!RepoTabsBarView::is_repo_busy(&repo)); + + let mut reload = repo.clone(); + reload.status = Loadable::Loading; + assert!(RepoTabsBarView::is_repo_busy(&reload)); + let mut history_filter = repo.clone(); + history_filter.log = Loadable::Loading; + assert!(RepoTabsBarView::is_repo_busy(&history_filter)); + let mut pagination = repo.clone(); + pagination.log_loading_more = true; + assert!(RepoTabsBarView::is_repo_busy(&pagination)); + repo.local_actions_in_flight = 1; + assert!(RepoTabsBarView::is_repo_busy(&repo)); + } + #[test] fn repo_tab_tooltip_defaults_to_repo_path() { let repo = repo_state("/tmp/repo"); diff --git a/crates/gitcomet-ui-gpui/src/view/tests/window_lifecycle.rs b/crates/gitcomet-ui-gpui/src/view/tests/window_lifecycle.rs index 264cda1be..1f86a9fd4 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests/window_lifecycle.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests/window_lifecycle.rs @@ -1495,6 +1495,36 @@ fn refreshes_shorter_than_the_spinner_delay_leave_the_chrome_alone(cx: &mut gpui publish(&idle, cx); assert!(!spinner(cx)); assert_eq!((tabs_notified.get(), actions_notified.get()), (2, 0)); + + // A focus refresh can take seconds while the usable view remains Ready. + // It must stay quiet even after the spinner delay has elapsed. + let mut cached = (*idle).clone(); + cached.repos[0].head_branch = Loadable::Ready("main".into()); + cached.repos[0].status = Loadable::Ready(Arc::new(RepoStatus { + staged: Default::default(), + unstaged: Default::default(), + })); + cached.repos[0].log = Loadable::Ready(Arc::new(gitcomet_core::domain::LogPage { + commits: Vec::new(), + next_cursor: None, + })); + let cached = Arc::new(cached); + publish(&cached, cx); + test_support::redraw(cx); + let before = tabs_notified.get(); + let mut refreshing = (*cached).clone(); + refreshing.repos[0] + .loads_in_flight + .request(LoadKind::WorktreeStatus); + refreshing.repos[0].loads_in_flight.request(LoadKind::Tags); + publish(&Arc::new(refreshing), cx); + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + assert!(!spinner(cx)); + assert_eq!(tabs_notified.get(), before); + publish(&cached, cx); + assert!(!spinner(cx)); + assert_eq!(tabs_notified.get(), before); } #[gpui::test] From 5777319158b9eea24676b1dc65ef6b4ac51f2860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:34:54 +0300 Subject: [PATCH 04/67] perf(git): reuse filesystem and stat caches during status scans Honor core.fscache with the platform default instead of disabling the per-walk filesystem cache. Retain refreshed stats for small indexes even below byte thresholds so clean filters are not restarted on every refresh, while preserving the large-index allocation guard. Pass cancellation through supplemental gitlink status commands. Cover filter reuse, filesystem-cache parity, index guards and cancellation; backend suite passed with 390 tests. Paired local warm scans improved about 29% on GitComet and 73% on a 20,000-file fixture. --- crates/gitcomet-git-gix/src/repo/status.rs | 48 ++- .../src/repo/status/refresh_tests.rs | 295 ++++++++++++++++++ 2 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs index 5ae019332..b9cf03d18 100644 --- a/crates/gitcomet-git-gix/src/repo/status.rs +++ b/crates/gitcomet-git-gix/src/repo/status.rs @@ -3,7 +3,7 @@ use super::{ conflict_stages::conflict_kind_from_stage_mask, git_ops::head_upstream_divergence, repo_file_stamp, }; -use crate::util::{git_workdir_cmd_for, path_buf_from_git_bytes, run_git_raw_output}; +use crate::util::{git_workdir_cmd_for, path_buf_from_git_bytes, run_git_background_output}; use gitcomet_core::domain::{ FileConflictKind, FileStatus, FileStatusKind, RepoStatus, UpstreamDivergence, }; @@ -20,6 +20,10 @@ mod worker_limit; #[path = "status/cancellation_tests.rs"] mod cancellation_tests; +#[cfg(test)] +#[path = "status/refresh_tests.rs"] +mod refresh_tests; + use cancellation::StatusCancellation; impl GixRepo { @@ -151,6 +155,7 @@ impl GixRepo { staged, unstaged, direct.has_conflicted_unstaged, + cancellation, ) } @@ -189,6 +194,7 @@ impl GixRepo { &repo, &mut Vec::new(), &mut unstaged, + cancellation, )?; cancellation.check_cancelled()?; } @@ -231,6 +237,7 @@ impl GixRepo { &repo, &mut staged, &mut Vec::new(), + cancellation, )?; cancellation.check_cancelled()?; } @@ -333,6 +340,7 @@ fn finalize_status( mut staged: Vec, mut unstaged: Vec, has_conflicted_unstaged: bool, + cancellation: &CancellationToken, ) -> Result { // Some platforms may omit certain unmerged shapes (notably stage-1-only both-deleted // conflicts) from gix status output. Supplement conflict entries from the index's unmerged @@ -349,8 +357,15 @@ fn finalize_status( // or gitlinks. This avoids a full `git status` subprocess on every refresh for the common // case. if may_have_gitlinks { - supplement_gitlink_status_from_porcelain(workdir, repo, &mut staged, &mut unstaged)?; + supplement_gitlink_status_from_porcelain( + workdir, + repo, + &mut staged, + &mut unstaged, + cancellation, + )?; } + cancellation.check_cancelled()?; sort_and_dedup_status_entries(&mut staged); sort_and_dedup_status_entries(&mut unstaged); @@ -670,10 +685,12 @@ pub(super) struct StatRefreshedIndex { index: gix::index::File, } -/// Re-hashing less than this per walk is cheaper than holding a second copy of -/// the index. +/// Avoid a second large index for a few inexpensive content checks. const STAT_REFRESH_MIN_BYTES: u64 = 256 * 1024; const STAT_REFRESH_MIN_ENTRIES: usize = 64; +/// Small indexes are cheap to retain, even when a few small files took costly +/// external clean filters to verify. Byte count alone does not capture that cost. +const STAT_REFRESH_SMALL_INDEX_ENTRIES: usize = 128; fn collect_index_worktree_status_direct( repo: &gix::Repository, @@ -753,7 +770,10 @@ fn apply_stat_refresh( IndexWorktreeApplyChange::SetSizeToZero { .. } => 0, }) .sum(); - if rehashed_bytes < STAT_REFRESH_MIN_BYTES && changes.len() < STAT_REFRESH_MIN_ENTRIES { + if base.entries().len() > STAT_REFRESH_SMALL_INDEX_ENTRIES + && rehashed_bytes < STAT_REFRESH_MIN_BYTES + && changes.len() < STAT_REFRESH_MIN_ENTRIES + { return None; } StatRefreshedIndex { @@ -847,6 +867,14 @@ impl gix::status::plumbing::index_as_worktree::traits::SubmoduleStatus for NoopS } } +fn filesystem_cache_enabled(repo: &gix::Repository) -> bool { + // Match gix's normal status path. Repository opens use its lenient config + // policy, so an absent or invalid value gets the platform default. + repo.config_snapshot() + .boolean(gix::config::tree::Core::FS_CACHE) + .unwrap_or(cfg!(windows)) +} + fn collect_index_worktree_status_direct_with_submodule( repo: &gix::Repository, index: &gix::index::File, @@ -896,6 +924,7 @@ where let fs_caps = repo .filesystem_options() .map_err(|e| Error::new(ErrorKind::Backend(format!("gix status fs options: {e}"))))?; + let fscache = filesystem_cache_enabled(repo); let accelerate_lookup = fs_caps.ignore_case.then(|| index.prepare_icase_backing()); let resource_cache = gix::diff::resource_cache( repo, @@ -937,11 +966,11 @@ where }, gix::status::plumbing::index_as_worktree_with_renames::Options { sorting: None, - fscache: false, + fscache, tracked_file_modifications: gix::status::plumbing::index_as_worktree::Options { fs: fs_caps, thread_limit: worker_limit::for_index(repo, index, cancellation)?, - fscache: false, + fscache, stat: repo.stat_options().map_err(|e| { Error::new(ErrorKind::Backend(format!("gix status stat options: {e}"))) })?, @@ -1340,7 +1369,9 @@ fn supplement_gitlink_status_from_porcelain( repo: &gix::Repository, staged: &mut Vec, unstaged: &mut Vec, + cancellation: &CancellationToken, ) -> Result<()> { + cancellation.check_cancelled()?; let mut command = git_workdir_cmd_for(workdir); command .arg("--literal-pathspecs") @@ -1355,7 +1386,8 @@ fn supplement_gitlink_status_from_porcelain( } command.arg("--").args(paths); } - let output = match run_git_raw_output(command, "git status --porcelain=v2") { + let output = match run_git_background_output(command, "git status --porcelain=v2", cancellation) + { Ok(output) => output, // Gitlink supplementation is best-effort parity glue on top of the primary // gix status result. If the subprocess itself times out, keep the base status. diff --git a/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs b/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs new file mode 100644 index 000000000..903f04fbe --- /dev/null +++ b/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs @@ -0,0 +1,295 @@ +use super::tests::{git_success, init_test_repo, open_repo, write_file}; +use gitcomet_core::domain::{FileStatus, FileStatusKind}; +use gitcomet_core::services::CancellationToken; +use std::path::Path; + +fn shell_path(path: &Path) -> String { + format!( + "'{}'", + path.to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''") + ) +} + +#[test] +fn small_clean_filtered_files_keep_refreshed_stats_without_restarting_filters() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file(workdir, ".gitattributes", "*.txt filter=count\n"); + let counter = workdir.join(".git/filter-count"); + git_success( + workdir, + &[ + "config", + "filter.count.clean", + &format!("printf x >> {}; cat", shell_path(&counter)), + ], + ); + let timestamp = filetime::FileTime::now().unix_seconds() - 30; + for number in 0..10 { + let name = format!("{number}.txt"); + write_file(workdir, &name, &"unchanged text\n".repeat(80)); + filetime::set_file_mtime( + workdir.join(name), + filetime::FileTime::from_unix_time(timestamp, 0), + ) + .unwrap(); + } + git_success(workdir, &["add", "."]); + git_success(workdir, &["commit", "-qm", "seed"]); + let index_path = workdir.join(".git/index"); + let index_before = std::fs::read(&index_path).unwrap(); + let index_mtime = std::fs::metadata(&index_path).unwrap().modified().unwrap(); + std::fs::write(&counter, []).unwrap(); + for number in 0..10 { + filetime::set_file_mtime( + workdir.join(format!("{number}.txt")), + filetime::FileTime::from_unix_time(timestamp + 1, 0), + ) + .unwrap(); + } + + let repo = open_repo(workdir); + let cancellation = CancellationToken::new(); + assert!(repo.status_impl().unwrap().unstaged.is_empty()); + let count = std::fs::read(&counter).unwrap(); + assert_eq!(count.len(), 10, "cold scan verifies each touched file"); + for _ in 0..2 { + assert!(repo.status_impl().unwrap().unstaged.is_empty()); + assert!( + repo.worktree_status_cancellable_impl(&cancellation) + .unwrap() + .is_empty() + ); + } + assert_eq!(std::fs::read(&counter).unwrap(), count); + assert_eq!(std::fs::read(&index_path).unwrap(), index_before); + assert_eq!( + std::fs::metadata(&index_path).unwrap().modified().unwrap(), + index_mtime, + "retained status stats must not write the index" + ); + + write_file(workdir, "0.txt", &"different text\n".repeat(80)); + let status = repo.status_impl().unwrap(); + assert_eq!(status.unstaged.len(), 1); + assert_eq!(status.unstaged[0].path, Path::new("0.txt")); + assert!(std::fs::read(&counter).unwrap().len() > count.len()); + git_success(workdir, &["add", "0.txt"]); + let status = repo.status_impl().unwrap(); + assert!(status.unstaged.is_empty()); + assert_eq!(status.staged.len(), 1); + assert_eq!(status.staged[0].path, Path::new("0.txt")); +} + +#[test] +fn one_small_plain_touch_does_not_clone_a_large_index() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + git_success(workdir, &["config", "core.trustctime", "false"]); + let timestamp = filetime::FileTime::now().unix_seconds() - 30; + for number in 0..=super::STAT_REFRESH_SMALL_INDEX_ENTRIES { + let name = format!("{number:03}.txt"); + write_file(workdir, &name, "unchanged\n"); + filetime::set_file_mtime( + workdir.join(name), + filetime::FileTime::from_unix_time(timestamp, 0), + ) + .unwrap(); + } + git_success(workdir, &["add", "."]); + git_success(workdir, &["commit", "-qm", "seed"]); + filetime::set_file_mtime( + workdir.join("000.txt"), + filetime::FileTime::from_unix_time(timestamp + 1, 0), + ) + .unwrap(); + let repo = open_repo(workdir); + assert!(repo.status_impl().unwrap().unstaged.is_empty()); + assert!(repo.stat_refreshed_index.lock().unwrap().is_none()); +} + +#[test] +fn filesystem_cache_configuration_preserves_status_and_is_fresh_per_scan() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file(workdir, "file.txt", "base\n"); + write_file(workdir, "nested/deleted.txt", "base\n"); + git_success(workdir, &["add", "."]); + git_success(workdir, &["commit", "-qm", "seed"]); + let repo = open_repo(workdir); + for enabled in [false, true] { + git_success( + workdir, + &[ + "config", + "core.fscache", + if enabled { "true" } else { "false" }, + ], + ); + assert_eq!( + super::filesystem_cache_enabled(&repo.status_repo()), + enabled + ); + assert!(repo.status_impl().unwrap().unstaged.is_empty()); + } + write_file(workdir, "file.txt", "edit\n"); + std::fs::remove_file(workdir.join("nested/deleted.txt")).unwrap(); + write_file(workdir, "new/file.txt", "new\n"); + let expected = [ + ("file.txt", FileStatusKind::Modified), + ("nested/deleted.txt", FileStatusKind::Deleted), + ("new/file.txt", FileStatusKind::Untracked), + ] + .map(|(path, kind)| FileStatus { + path: path.into(), + kind, + conflict: None, + }); + for enabled in [false, true] { + git_success( + workdir, + &[ + "config", + "core.fscache", + if enabled { "true" } else { "false" }, + ], + ); + assert_eq!(repo.status_impl().unwrap().unstaged.as_ref(), &expected); + } + write_file(workdir, "file.txt", "base\n"); + write_file(workdir, "nested/deleted.txt", "base\n"); + std::fs::remove_file(workdir.join("new/file.txt")).unwrap(); + assert!(repo.status_impl().unwrap().unstaged.is_empty()); + // The application uses gix's lenient opens: invalid values fall back just + // like absent ones, to the upstream Windows default. + git_success(workdir, &["config", "core.fscache", "invalid"]); + assert_eq!( + super::filesystem_cache_enabled(&repo.status_repo()), + cfg!(windows) + ); +} + +#[test] +#[cfg_attr( + windows, + ignore = "existing Windows taskkill runner loses MSYS fork/exec parentage" +)] +fn cancellation_interrupts_the_gitlink_subprocess_in_every_status_lane() { + cancels_gitlink_subprocess(false, false); +} + +#[test] +fn pre_cancelled_gitlink_status_never_starts_the_subprocess() { + cancels_gitlink_subprocess(false, true); +} + +#[cfg(windows)] +#[test] +#[ignore = "existing Windows taskkill runner cannot reach orphaned MSYS exec descendants"] +fn cancellation_interrupts_a_gitlink_hook_with_an_orphaned_exec_descendant() { + cancels_gitlink_subprocess(true, false); +} + +fn cancels_gitlink_subprocess(orphan_with_exec: bool, pre_cancelled: bool) { + use gitcomet_core::error::ErrorKind; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file(workdir, "file.txt", "base\n"); + git_success(workdir, &["add", "."]); + git_success(workdir, &["commit", "-qm", "seed"]); + let head = crate::refs::head_oid(&gix::open(workdir).unwrap()) + .unwrap() + .unwrap(); + git_success( + workdir, + &[ + "update-index", + "--add", + "--cacheinfo", + "160000", + &head.to_string(), + "module", + ], + ); + let marker = workdir.join(".git/fsmonitor-started"); + let hook = workdir.join(".git/slow-fsmonitor.sh"); + let sleep = if orphan_with_exec { + "exec sleep 15" + } else { + // Avoid intentionally replacing the shell. Windows MSYS can still + // lose native parentage during fork; the ignored tests retain both + // forms of that existing process-runner limitation. + "sleep 15\nprintf finished" + }; + std::fs::write( + &hook, + format!("printf started > {}\n{sleep}\n", shell_path(&marker)), + ) + .unwrap(); + // Git runs command-valued hooks via an outer `sh -c` too. Prevent its + // final-command exec optimization in the ordinary hook case. + let outer_shell_tail = if orphan_with_exec { "" } else { "; :" }; + git_success( + workdir, + &[ + "config", + "core.fsmonitor", + &format!("sh {}{outer_shell_tail}", shell_path(&hook)), + ], + ); + + for lane in ["combined", "worktree", "staged"] { + let repo = open_repo(workdir); + let cancellation = CancellationToken::new(); + if pre_cancelled { + cancellation.cancel(); + } + std::thread::scope(|scope| { + let (tx, rx) = mpsc::channel(); + let token = cancellation.clone(); + let worker = scope.spawn(move || { + let result = match lane { + "combined" => repo.status_cancellable_impl(&token).map(|_| ()), + "worktree" => repo.worktree_status_cancellable_impl(&token).map(|_| ()), + _ => repo.staged_status_cancellable_impl(&token).map(|_| ()), + }; + tx.send(result).unwrap(); + }); + if !pre_cancelled { + let deadline = Instant::now() + Duration::from_secs(5); + while !marker.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + } + let started = marker.exists(); + cancellation.cancel(); + // Bounded hook sleep makes even a regression terminate; receive + // before joining proves cancellation, not natural completion. + let result = rx.recv_timeout(Duration::from_secs(5)); + worker.join().unwrap(); + assert_eq!(started, !pre_cancelled, "{lane} subprocess start"); + let error = result + .expect("cancelled Git must stop promptly") + .unwrap_err(); + assert!( + matches!(error.kind(), ErrorKind::Cancelled), + "{lane}: {error:?}" + ); + }); + if pre_cancelled { + assert!(!marker.exists(), "{lane} must not spawn after cancellation"); + } else { + std::fs::remove_file(&marker).unwrap(); + } + } +} From 3f20b071bd9e461c46bab98e966ebc2bbf4cb439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:35:37 +0300 Subject: [PATCH 05/67] feat(profiling): trace startup stages and measure interactive readiness Add opt-in JSONL startup spans through launch, fonts, renderer selection and first usable content. Distinguish ready home and repository milestones so launch benchmarks do not stop at an empty window or wait for optional remote discovery. Provide a PowerShell capture helper with isolated-profile support and graceful closure, a repository-load probe, and documented reporter instructions. Validate startup probes, launch-harness tests, trace output and real capture runs. --- crates/gitcomet-app/src/launch.rs | 48 +++- crates/gitcomet-app/src/lib.rs | 12 +- crates/gitcomet-base/Cargo.toml | 4 +- crates/gitcomet-base/src/lib.rs | 1 + crates/gitcomet-base/src/startup_trace.rs | 139 +++++++++ crates/gitcomet-core/src/lib.rs | 4 +- crates/gitcomet-git-gix/Cargo.toml | 4 + .../examples/repository-load-probe.rs | 176 ++++++++++++ crates/gitcomet-ui-gpui/src/app/launch.rs | 28 +- crates/gitcomet-ui-gpui/src/startup_probe.rs | 180 +++++++++++- .../src/view/gitcomet_view.rs | 6 +- .../src/view/gitcomet_view_render.rs | 16 +- .../src/view/runtime_probe.rs | 16 +- .../gitcomet-ui-gpui/src/windows_renderer.rs | 9 + .../gitcomet-ui-kit/src/font_preferences.rs | 2 + crates/gitcomet/src/bin/perf_app_launch.rs | 263 +++++++++++------- scripts/profiling/README.md | 99 +++++++ scripts/profiling/capture-startup.ps1 | 165 +++++++++++ scripts/profiling/compare-perf-runs.sh | 4 + 19 files changed, 1038 insertions(+), 138 deletions(-) create mode 100644 crates/gitcomet-base/src/startup_trace.rs create mode 100644 crates/gitcomet-git-gix/examples/repository-load-probe.rs create mode 100644 scripts/profiling/capture-startup.ps1 diff --git a/crates/gitcomet-app/src/launch.rs b/crates/gitcomet-app/src/launch.rs index 82f02b745..91924ce57 100644 --- a/crates/gitcomet-app/src/launch.rs +++ b/crates/gitcomet-app/src/launch.rs @@ -146,7 +146,10 @@ pub(crate) fn run_mode( #[cfg(not(feature = "ui-gpui-runtime"))] let () = extensions; - install_configured_git_executable_preference(&mode); + { + let _stage = gitcomet_core::startup_trace::span("git_executable_preference"); + install_configured_git_executable_preference(&mode); + } #[cfg(feature = "ui-gpui-runtime")] let backend = || build_backend(&repository_options, backend_override.clone()); #[cfg(not(feature = "ui-gpui-runtime"))] @@ -261,7 +264,11 @@ fn run_browser( } let path = browser_instance::normalize_browser_path(path); - let browser_open_target = gitcomet_state::session::load() + let session = { + let _stage = gitcomet_core::startup_trace::span("browser_session_load"); + gitcomet_state::session::load() + }; + let browser_open_target = session .browser_open_target .as_deref() .and_then(gitcomet_ui_gpui::BrowserOpenTarget::from_key) @@ -270,18 +277,21 @@ fn run_browser( path: path.clone(), target: browser_open_target, }; - let mut browser_instance = - match browser_instance::start_or_forward(initial_browser_request.clone()) { - Ok(browser_instance::StartResult::Forwarded) => return exit_code::SUCCESS, - Ok(browser_instance::StartResult::Primary(primary)) => Some(primary), - Err(err) => { - eprintln!( - "Could not initialize the {name} browser-instance broker; \ + let instance_result = { + let _stage = gitcomet_core::startup_trace::span("browser_instance_broker"); + browser_instance::start_or_forward(initial_browser_request.clone()) + }; + let mut browser_instance = match instance_result { + Ok(browser_instance::StartResult::Forwarded) => return exit_code::SUCCESS, + Ok(browser_instance::StartResult::Primary(primary)) => Some(primary), + Err(err) => { + eprintln!( + "Could not initialize the {name} browser-instance broker; \ launching this process independently: {err}" - ); - None - } - }; + ); + None + } + }; let browser_requests = browser_instance .as_mut() .and_then(browser_instance::PrimaryBrowserInstance::take_requests); @@ -295,17 +305,25 @@ fn run_browser( let _ = std::thread::Builder::new() .name("git-probe".into()) .spawn(move || { + let _stage = gitcomet_core::startup_trace::span("git_runtime_probe_background"); let _ = probe.run(); }); } - let startup_crash_report = crashlog::take_startup_report(); + let startup_crash_report = { + let _stage = gitcomet_core::startup_trace::span("startup_crash_report"); + crashlog::take_startup_report() + }; if let Some(report) = startup_crash_report.as_ref() { // Keep the recovery path visible even if WSLg cannot create // a window for the in-app notification. print_startup_crash_report_hint(report); } - if let Err(err) = crashlog::begin_session() { + let crash_session_result = { + let _stage = gitcomet_core::startup_trace::span("crashlog_begin_session"); + crashlog::begin_session() + }; + if let Err(err) = crash_session_result { eprintln!("Failed to create {name} crash-recovery marker before UI launch: {err}"); } let startup_report = startup_crash_report.map(|report| gitcomet_ui_gpui::StartupCrashReport { diff --git a/crates/gitcomet-app/src/lib.rs b/crates/gitcomet-app/src/lib.rs index c6d7650bc..05477beea 100644 --- a/crates/gitcomet-app/src/lib.rs +++ b/crates/gitcomet-app/src/lib.rs @@ -142,6 +142,7 @@ impl AppLaunch { /// Runs with `args` (including the program name) and returns the exit code. pub fn run_with_args(self, args: Vec) -> i32 { + gitcomet_core::startup_trace::mark("app_entry"); let Self { identity, about, @@ -157,9 +158,16 @@ impl AppLaunch { } #[cfg(feature = "ui-gpui-runtime")] - crashlog::install(); + { + let _stage = gitcomet_core::startup_trace::span("crashlog_install"); + crashlog::install(); + } - dispatch(cli::parse_cli(args, &about), on_prepare, move |mode| { + let parsed = { + let _stage = gitcomet_core::startup_trace::span("parse_cli"); + cli::parse_cli(args, &about) + }; + dispatch(parsed, on_prepare, move |mode| { launch::run_mode(mode, extensions, repository_options, backend) }) } diff --git a/crates/gitcomet-base/Cargo.toml b/crates/gitcomet-base/Cargo.toml index 3ae424413..90f7f454b 100644 --- a/crates/gitcomet-base/Cargo.toml +++ b/crates/gitcomet-base/Cargo.toml @@ -16,8 +16,6 @@ benchmarks = [] [dependencies] serde = { workspace = true } +serde_json = { workspace = true } sysinfo = { workspace = true } tempfile = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/crates/gitcomet-base/src/lib.rs b/crates/gitcomet-base/src/lib.rs index e4538c12e..f1ce32c09 100644 --- a/crates/gitcomet-base/src/lib.rs +++ b/crates/gitcomet-base/src/lib.rs @@ -10,6 +10,7 @@ pub mod identity; mod loadable; pub mod op_trace; pub mod platform; +pub mod startup_trace; pub use loadable::Loadable; diff --git a/crates/gitcomet-base/src/startup_trace.rs b/crates/gitcomet-base/src/startup_trace.rs new file mode 100644 index 000000000..371d6fbb7 --- /dev/null +++ b/crates/gitcomet-base/src/startup_trace.rs @@ -0,0 +1,139 @@ +//! Opt-in startup stage timings, available before the native UI exists. +//! +//! Set `GITCOMET_STARTUP_TRACE` to a local file path. Each line is JSON; begin +//! records survive a stalled stage and end records include its duration. The +//! file is appended, with a process id on every record. No repository paths, +//! environment values or command arguments are recorded. + +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; + +static TRACE: OnceLock> = OnceLock::new(); + +struct Trace { + origin: Instant, + file: Mutex, +} + +fn trace() -> Option<&'static Trace> { + TRACE + .get_or_init(|| { + let path = std::env::var_os("GITCOMET_STARTUP_TRACE").filter(|p| !p.is_empty())?; + let origin = Instant::now(); + match OpenOptions::new().create(true).append(true).open(path) { + Ok(file) => Some(Trace { + origin, + file: Mutex::new(file), + }), + Err(error) => { + crate::write_stderr_line(format_args!("Cannot open startup trace: {error}")); + None + } + } + }) + .as_ref() +} + +pub fn is_enabled() -> bool { + trace().is_some() +} + +/// A timestamp relative to the earliest startup trace call in this process. +pub fn mark(name: &'static str) { + if let Some(trace) = trace() { + trace.write("mark", name, None); + } +} + +/// Times a synchronous stage. Keep the guard alive only for that stage, not +/// for the whole event loop. Disabled tracing does not read the clock. +#[must_use = "the startup stage ends when its guard drops"] +pub fn span(name: &'static str) -> Span { + Span(trace().map(|trace| { + trace.write("begin", name, None); + (name, Instant::now()) + })) +} + +pub struct Span(Option<(&'static str, Instant)>); + +impl Drop for Span { + fn drop(&mut self) { + if let Some((name, started)) = self.0 + && let Some(trace) = trace() + { + trace.write("end", name, Some(started.elapsed().as_secs_f64() * 1_000.0)); + } + } +} + +impl Trace { + fn write(&self, kind: &str, name: &str, duration_ms: Option) { + let thread = std::thread::current(); + let record = record( + kind, + name, + self.origin.elapsed().as_secs_f64() * 1_000.0, + duration_ms, + thread.name(), + ); + if let Ok(mut file) = self.file.lock() { + let _ = writeln!(file, "{record}"); + } + } +} + +fn record( + kind: &str, + name: &str, + elapsed_ms: f64, + duration_ms: Option, + thread: Option<&str>, +) -> serde_json::Value { + serde_json::json!({ + "kind": kind, + "name": name, + "elapsed_ms": elapsed_ms, + "duration_ms": duration_ms, + "pid": std::process::id(), + "thread": thread, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_records_are_json_with_distinct_elapsed_and_stage_duration() { + let value = record("end", "native_application", 125.0, Some(95.0), Some("main")); + let parsed: serde_json::Value = serde_json::from_str(&value.to_string()).unwrap(); + assert_eq!(parsed["elapsed_ms"], 125.0); + assert_eq!(parsed["duration_ms"], 95.0); + assert_eq!(parsed["pid"], std::process::id()); + assert_eq!(parsed["name"], "native_application"); + } + + #[test] + fn trace_writes_each_record_as_a_complete_line_without_waiting_for_shutdown() { + let temporary = tempfile::NamedTempFile::new().unwrap(); + let trace = Trace { + origin: Instant::now(), + file: Mutex::new(temporary.reopen().unwrap()), + }; + trace.write("begin", "font\"catalog", None); + trace.write("end", "font\"catalog", Some(2.0)); + let contents = std::fs::read_to_string(temporary.path()).unwrap(); + let rows: Vec = contents + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["kind"], "begin"); + assert_eq!(rows[0]["name"], "font\"catalog"); + assert!(rows[0]["duration_ms"].is_null()); + assert_eq!(rows[1]["duration_ms"], 2.0); + } +} diff --git a/crates/gitcomet-core/src/lib.rs b/crates/gitcomet-core/src/lib.rs index aa7753c98..0aca08cef 100644 --- a/crates/gitcomet-core/src/lib.rs +++ b/crates/gitcomet-core/src/lib.rs @@ -1,5 +1,7 @@ // Shared foundations, kept at their established paths. -pub use gitcomet_base::{environment, fs_utils, history_perf, identity, op_trace, platform}; +pub use gitcomet_base::{ + environment, fs_utils, history_perf, identity, op_trace, platform, startup_trace, +}; pub mod annex; pub mod auth; diff --git a/crates/gitcomet-git-gix/Cargo.toml b/crates/gitcomet-git-gix/Cargo.toml index a8fd73ba2..669ef3199 100644 --- a/crates/gitcomet-git-gix/Cargo.toml +++ b/crates/gitcomet-git-gix/Cargo.toml @@ -26,6 +26,10 @@ required-features = ["benchmarks", "test-support"] name = "interaction-probe" required-features = ["benchmarks"] +[[example]] +name = "repository-load-probe" +required-features = ["benchmarks"] + # Cargo builds this fixture for integration tests. Application packages depend # only on this package's library, so it is not part of application distributions. [[bin]] diff --git a/crates/gitcomet-git-gix/examples/repository-load-probe.rs b/crates/gitcomet-git-gix/examples/repository-load-probe.rs new file mode 100644 index 000000000..1032e6b64 --- /dev/null +++ b/crates/gitcomet-git-gix/examples/repository-load-probe.rs @@ -0,0 +1,176 @@ +//! Read-only backend stage timings for startup and unchanged refreshes. +//! Usage: repository-load-probe REPO [--refreshes N] [--all-branches] [--remote-tags] +//! +//! Build with --release --features benchmarks. Stages run sequentially to +//! isolate their cost; their sum is NOT the application's concurrent launch +//! time. Each JSON line is emitted immediately so a stalled stage is visible. +//! Remote queries are opt-in. Command capture is thread-local, so commands a +//! stage delegates to child threads (remote tag queries) are included in its +//! elapsed time but not its command breakdown. +use gitcomet_core::domain::HistoryMode; +use gitcomet_core::services::{ + CancellationToken, GitBackend, HistoryReadRequest, HistoryReadResult, +}; +use gitcomet_git_gix::GixBackend; +use serde_json::{Value, json}; +use std::{io::Write as _, path::PathBuf, time::Instant}; + +fn emit(value: Value) { + println!("{value}"); + std::io::stdout().flush().expect("flush stage timing"); +} + +fn measure( + iteration: usize, + stage: &str, + run: impl FnOnce() -> gitcomet_core::services::Result, + describe: impl FnOnce(&T) -> Value, +) -> Option { + emit(json!({"event": "start", "iteration": iteration, "stage": stage})); + let start = Instant::now(); + let (result, commands) = gitcomet_git_gix::command_trace::capture(run); + let elapsed = start.elapsed(); + let commands: Vec<_> = commands + .iter() + .map(|command| { + json!({ + "label": command.label, + "milliseconds": command.elapsed.as_secs_f64() * 1000.0, + "stages": command.stages.iter().map(|(stage, duration)| { + json!({"stage": stage, "milliseconds": duration.as_secs_f64() * 1000.0}) + }).collect::>() + }) + }) + .collect(); + let (witness, error) = match &result { + Ok(value) => (describe(value), None), + Err(error) => (Value::Null, Some(error.to_string())), + }; + emit(json!({ + "event": "finish", "iteration": iteration, "stage": stage, + "milliseconds": elapsed.as_secs_f64() * 1000.0, + "witness": witness, "error": error, "commands": commands, + })); + result.ok() +} + +fn main() { + let mut args = std::env::args_os().skip(1); + let root = PathBuf::from( + args.next() + .expect("repository-load-probe REPO [--refreshes N] [--all-branches] [--remote-tags]"), + ); + let mut refreshes = 2usize; + let mut mode = HistoryMode::default(); + let mut remote_tags = false; + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--refreshes") => { + refreshes = args + .next() + .and_then(|value| value.to_str().and_then(|value| value.parse().ok())) + .filter(|count| *count <= 1000) + .expect("--refreshes requires a count from 0 to 1000"); + } + Some("--all-branches") => mode = HistoryMode::AllBranches, + Some("--remote-tags") => remote_tags = true, + _ => panic!("unknown option: {arg:?}"), + } + } + emit(json!({ + "event": "configuration", "debug_assertions": cfg!(debug_assertions), + "refreshes": refreshes, "history_mode": format!("{mode:?}"), + "remote_tags": remote_tags, "execution": "sequential_backend_stages", + })); + let Some(repo) = measure(0, "open", || GixBackend.open(&root), |_| json!("opened")) else { + std::process::exit(1); + }; + let cancellation = CancellationToken::new(); + let mut history = HistoryReadRequest::Page { + limit: 200, + cursor: None, + snapshot: None, + }; + for iteration in 0..=refreshes { + macro_rules! count { + ($stage:literal, $call:expr) => { + measure(iteration, $stage, || $call, |items| json!({"count": items.len()})); + }; + } + measure( + iteration, + "head", + || repo.current_branch(&cancellation), + |_| json!("read"), + ); + measure( + iteration, + "upstream-divergence", + || repo.upstream_divergence(&cancellation), + |value| { + json!( + value + .as_ref() + .map(|value| json!({"ahead": value.ahead, "behind": value.behind})) + ) + }, + ); + let status = measure( + iteration, + "status", + || repo.status(&cancellation), + |status| json!({"staged": status.staged.len(), "unstaged": status.unstaged.len()}), + ); + if let Some(status) = status { + measure( + iteration, + "line-stats", + || repo.uncommitted_line_stats_for_status(&status, &cancellation), + |_| json!("read"), + ); + } + if let Some(HistoryReadResult::Page { page, snapshot }) = measure( + iteration, + "history", + || repo.read_history(mode, None, &history, &cancellation, &mut |_| {}), + |result| match result { + HistoryReadResult::Page { page, .. } => json!({"commits": page.commits.len()}), + HistoryReadResult::Unchanged => json!("unchanged"), + HistoryReadResult::Invalidated => json!("invalidated"), + }, + ) { + history = HistoryReadRequest::Refresh { + previous: page, + snapshot, + }; + } + count!("branches", repo.list_branches(&cancellation)); + count!("remotes", repo.list_remotes(&cancellation)); + count!("remote-branches", repo.list_remote_branches(&cancellation)); + measure( + iteration, + "sequencer", + || repo.sequencer_state(&cancellation), + |_| json!("read"), + ); + measure( + iteration, + "merge-message", + || repo.merge_commit_message(&cancellation), + |message| json!({"present": message.is_some()}), + ); + count!("stashes", repo.stash_list(&cancellation)); + count!("worktrees", repo.list_worktrees(&cancellation)); + count!("submodules", repo.list_submodules(&cancellation)); + measure( + iteration, + "large-file-support", + || repo.large_file_support(&cancellation), + |support| json!({"lfs": support.lfs.in_use(), "annex": support.annex.in_use()}), + ); + count!("tags", repo.list_tags(&cancellation)); + if remote_tags { + count!("remote-tags", repo.list_remote_tags(&cancellation)); + } + } +} diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index 4249f5bd6..fa483859b 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -208,7 +208,10 @@ impl UiLaunch { } pub fn run(self) -> Result { - install_live_kit_policy(); + { + let _stage = gitcomet_core::startup_trace::span("ui_runtime_policy"); + install_live_kit_policy(); + } let Self { backend, extensions, @@ -219,7 +222,10 @@ impl UiLaunch { } = self; let mut launch = normal_launch_config(initial_request.path, startup_crash_report); launch.browser_open_target = initial_request.target; - ensure_graphics_device_available("main GPUI window launch")?; + { + let _stage = gitcomet_core::startup_trace::span("graphics_device_precheck"); + ensure_graphics_device_available("main GPUI window launch")?; + } run_with_panic_guard("main GPUI window launch", move || { run_windowed_app( backend, @@ -459,15 +465,19 @@ pub(super) fn run_windowed_app( let quit_when_all_windows_closed = should_quit_when_all_windows_closed(&launch); // Without this, `gpui` keeps its null client and every request — the // update check, and images a markdown preview points at — fails silently. + let platform_stage = gitcomet_core::startup_trace::span("native_application"); #[cfg(target_os = "windows")] let (application, renderer_session) = crate::windows_renderer::application( launch.view_config.view_mode == GitCometViewMode::Normal, ); #[cfg(not(target_os = "windows"))] let application = gpui_platform::application(); + drop(platform_stage); + let http_stage = gitcomet_core::startup_trace::span("http_client"); let application = application .with_assets(GitCometAssets::with_extensions(extensions.assets())) .with_http_client(crate::http::client()); + drop(http_stage); #[cfg(target_os = "macos")] let open_urls_rx = if launch.view_config.view_mode == GitCometViewMode::Normal { @@ -492,7 +502,9 @@ pub(super) fn run_windowed_app( }); } + gitcomet_core::startup_trace::mark("event_loop_start"); application.run(move |cx: &mut App| { + gitcomet_core::startup_trace::mark("event_loop_callback"); cx.set_global(clean_shutdown_tracker); #[cfg(target_os = "windows")] cx.set_global(renderer_session); @@ -516,8 +528,11 @@ pub(super) fn run_windowed_app( async {} }) .detach(); - if let Err(err) = crate::bundled_fonts::register(cx) { - eprintln!("Failed to register bundled fonts: {err:#}"); + { + let _stage = gitcomet_core::startup_trace::span("register_bundled_fonts"); + if let Err(err) = crate::bundled_fonts::register(cx) { + eprintln!("Failed to register bundled fonts: {err:#}"); + } } if quit_when_all_windows_closed { cx.on_window_closed(|cx, _| { @@ -557,6 +572,7 @@ pub(super) fn run_windowed_app( let launch_is_normal = launch.view_config.view_mode == GitCometViewMode::Normal; let resume = move |cx: &mut App| { + let _stage = gitcomet_core::startup_trace::span("open_initial_windows"); open_initial_gitcomet_windows(cx, Arc::clone(&backend), &launch); crate::view::scenario_driver::start_if_requested(cx); cx.activate(true); @@ -739,6 +755,7 @@ pub(super) fn open_gitcomet_window( let ui_scale_percent = ui_scale.percent; let window = crate::ui_probe::time_section("open main window", || { + let _stage = gitcomet_core::startup_trace::span("open_window"); cx.open_window( with_main_window_background(WindowOptions { window_bounds: Some(window_bounds), @@ -759,15 +776,18 @@ pub(super) fn open_gitcomet_window( ..Default::default() }), move |window, cx| { + let _stage = gitcomet_core::startup_trace::span("window_view_init"); ui_scale::apply_to_window(window, ui_scale_percent); window.on_window_should_close(cx, |window, cx| { close_window_or_warn(window, cx); false }); + let store_stage = gitcomet_core::startup_trace::span("app_store_init"); #[cfg(test)] let (store, events) = AppStore::new_test(Arc::clone(&backend)); #[cfg(not(test))] let (store, events) = AppStore::new(Arc::clone(&backend)); + drop(store_stage); cx.new(|cx| { GitCometView::new_with_config(store, events, view_config.clone(), window, cx) }) diff --git a/crates/gitcomet-ui-gpui/src/startup_probe.rs b/crates/gitcomet-ui-gpui/src/startup_probe.rs index 54e726d65..1849deffc 100644 --- a/crates/gitcomet-ui-gpui/src/startup_probe.rs +++ b/crates/gitcomet-ui-gpui/src/startup_probe.rs @@ -10,9 +10,13 @@ const ENABLED_ENV: &str = "GITCOMET_PERF_STARTUP_PROBE"; const DISABLE_AUTO_RESTORE_ENV: &str = "GITCOMET_PERF_STARTUP_DISABLE_AUTO_RESTORE"; const AUTO_EXIT_ENV: &str = "GITCOMET_PERF_STARTUP_AUTO_EXIT"; const EXPECT_READY_REPOS_ENV: &str = "GITCOMET_PERF_STARTUP_EXPECT_READY_REPOS"; +const WAIT_FOR_ACTIVE_REPO_ENV: &str = "GITCOMET_PERF_STARTUP_WAIT_FOR_ACTIVE_REPO"; +const WAIT_FOR_HOME_ENV: &str = "GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY"; static FIRST_PAINT_EMITTED: AtomicBool = AtomicBool::new(false); static FIRST_INTERACTIVE_EMITTED: AtomicBool = AtomicBool::new(false); +static ACTIVE_REPO_READY_EMITTED: AtomicBool = AtomicBool::new(false); +static HOME_READY_EMITTED: AtomicBool = AtomicBool::new(false); static LAST_REPO_PROGRESS_SNAPSHOT: AtomicU64 = AtomicU64::new(u64::MAX); static CONFIG: OnceLock = OnceLock::new(); @@ -23,6 +27,8 @@ struct StartupProbeConfig { disable_auto_restore: bool, auto_exit_after_interactive: bool, expected_ready_repos: Option, + wait_for_active_repo: bool, + wait_for_home: bool, } #[derive(Clone, Copy, Debug)] @@ -30,6 +36,8 @@ enum StartupProbeEvent { FirstPaint, FirstInteractive, ReposLoaded, + ActiveRepoReady, + HomeReady, } impl StartupProbeEvent { @@ -38,6 +46,8 @@ impl StartupProbeEvent { Self::FirstPaint => "first_paint", Self::FirstInteractive => "first_interactive", Self::ReposLoaded => "repos_loaded", + Self::ActiveRepoReady => "active_repo_ready", + Self::HomeReady => "home_ready", } } } @@ -54,9 +64,31 @@ pub(crate) fn disable_auto_restore() -> bool { pub(crate) fn should_exit_after_first_interactive() -> bool { let config = config(); + let snapshot = LAST_REPO_PROGRESS_SNAPSHOT.load(Ordering::SeqCst); + let loaded = (snapshot != u64::MAX).then_some((snapshot & u64::from(u32::MAX)) as usize); + should_exit( + config, + FIRST_INTERACTIVE_EMITTED.load(Ordering::SeqCst), + loaded, + ACTIVE_REPO_READY_EMITTED.load(Ordering::SeqCst), + home_ready_recorded(), + ) +} + +fn should_exit( + config: &StartupProbeConfig, + interactive: bool, + repos_loaded: Option, + active_ready: bool, + home_ready: bool, +) -> bool { + let expected = config.expected_ready_repos.unwrap_or(0); config.enabled && config.auto_exit_after_interactive - && config.expected_ready_repos.unwrap_or(0) == 0 + && interactive + && (expected == 0 || repos_loaded.is_some_and(|loaded| loaded >= expected)) + && (!config.wait_for_active_repo || active_ready) + && (!config.wait_for_home || home_ready) } pub(crate) fn observe_app_state(state: &AppState) -> bool { @@ -70,14 +102,24 @@ pub(crate) fn observe_app_state(state: &AppState) -> bool { if LAST_REPO_PROGRESS_SNAPSHOT.swap(snapshot, Ordering::SeqCst) != snapshot { emit_repo_progress(repos_loaded, repos_total); } + if active_repo_ready(state) { + mark_once( + StartupProbeEvent::ActiveRepoReady, + &ACTIVE_REPO_READY_EMITTED, + ); + } - let config = config(); - config.enabled - && config.auto_exit_after_interactive - && FIRST_INTERACTIVE_EMITTED.load(Ordering::SeqCst) - && config - .expected_ready_repos - .is_some_and(|expected| repos_loaded >= expected) + should_exit_after_first_interactive() +} + +pub(crate) fn home_ready_recorded() -> bool { + HOME_READY_EMITTED.load(Ordering::SeqCst) +} + +/// Called on the frame after Home was prepainted with Git available. The first +/// interactive frame alone may have displayed the Git-checking loading screen. +pub(crate) fn mark_home_ready() -> bool { + mark_once(StartupProbeEvent::HomeReady, &HOME_READY_EMITTED) } pub(crate) fn mark_first_paint() -> bool { @@ -93,10 +135,12 @@ pub(crate) fn mark_first_interactive() -> bool { fn config() -> &'static StartupProbeConfig { CONFIG.get_or_init(|| StartupProbeConfig { - enabled: env_flag(ENABLED_ENV), + enabled: env_flag(ENABLED_ENV) || gitcomet_core::startup_trace::is_enabled(), disable_auto_restore: env_flag(DISABLE_AUTO_RESTORE_ENV), auto_exit_after_interactive: env_flag(AUTO_EXIT_ENV), expected_ready_repos: env_usize(EXPECT_READY_REPOS_ENV), + wait_for_active_repo: env_flag(WAIT_FOR_ACTIVE_REPO_ENV), + wait_for_home: env_flag(WAIT_FOR_HOME_ENV), }) } @@ -118,6 +162,7 @@ fn mark_once(event: StartupProbeEvent, emitted: &AtomicBool) -> bool { } fn emit_event(event: StartupProbeEvent) { + gitcomet_core::startup_trace::mark(event.as_str()); let mut payload = Map::new(); payload.insert("name".to_string(), json!(event.as_str())); payload.insert("rss_kib".to_string(), json!(current_rss_kib())); @@ -152,6 +197,21 @@ fn count_ready_repos(state: &AppState) -> usize { .count() } +/// Opening a handle is much earlier than showing its initial contents. Only +/// the selected repository loads those contents; restored inactive tabs are lazy. +fn active_repo_ready(state: &AppState) -> bool { + state.repos.iter().any(|repo| { + Some(repo.id) == state.active_repo + && matches!(repo.open, Loadable::Ready(())) + && matches!(repo.status, Loadable::Ready(_)) + && matches!(repo.log, Loadable::Ready(_)) + && !repo.loads_in_flight.any_foreground_in_flight() + && repo.local_actions_in_flight == 0 + && repo.pull_in_flight == 0 + && repo.push_in_flight == 0 + }) +} + fn pack_repo_progress(repos_loaded: usize, repos_total: usize) -> u64 { ((repos_total as u64) << 32) | repos_loaded as u64 } @@ -172,7 +232,9 @@ fn current_rss_kib() -> Option { #[cfg(test)] mod tests { - use super::{count_ready_repos, pack_repo_progress}; + use super::{ + StartupProbeConfig, active_repo_ready, count_ready_repos, pack_repo_progress, should_exit, + }; use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{AppState, Loadable, RepoId, RepoState}; use gitcomet_ui_kit::ui_probe::parse_bool_flag; @@ -217,4 +279,102 @@ mod tests { fn pack_repo_progress_keeps_loaded_and_total_counts() { assert_eq!(pack_repo_progress(5, 12), (12u64 << 32) | 5u64); } + + #[test] + fn repository_probe_waits_for_both_interactivity_and_contents_in_either_order() { + let config = StartupProbeConfig { + enabled: true, + disable_auto_restore: false, + auto_exit_after_interactive: true, + expected_ready_repos: Some(5), + wait_for_active_repo: true, + wait_for_home: false, + }; + assert!(!should_exit(&config, true, Some(5), false, false)); + assert!(!should_exit(&config, false, Some(5), true, false)); + assert!(!should_exit(&config, true, Some(4), true, false)); + assert!(!should_exit(&config, true, None, true, false)); + assert!(should_exit(&config, true, Some(5), true, false)); + let empty = StartupProbeConfig { + expected_ready_repos: Some(0), + wait_for_active_repo: false, + ..config + }; + assert!(should_exit(&empty, true, None, false, false)); + let picker = StartupProbeConfig { + wait_for_home: true, + ..empty + }; + assert!(!should_exit(&picker, true, None, false, false)); + assert!(!should_exit(&picker, false, None, false, true)); + assert!(should_exit(&picker, true, None, false, true)); + } + + #[test] + fn active_repo_ready_waits_for_contents_and_foreground_work() { + let mut state = AppState::test_default(); + let mut repo = RepoState::new_opening( + RepoId(1), + RepoSpec { + workdir: PathBuf::from("/tmp/ready"), + }, + ); + state.active_repo = Some(repo.id); + repo.open = Loadable::Ready(()); + state.repos.push(repo); + assert!( + !active_repo_ready(&state), + "an open handle has no contents yet" + ); + state.repos[0].status = Loadable::Ready(Default::default()); + assert!(!active_repo_ready(&state), "history is still missing"); + state.repos[0].log = Loadable::Ready( + gitcomet_core::domain::LogPage { + commits: Vec::new(), + next_cursor: None, + } + .into(), + ); + assert!(active_repo_ready(&state)); + state.repos[0].local_actions_in_flight = 1; + assert!(!active_repo_ready(&state)); + state.repos[0].local_actions_in_flight = 0; + state.repos[0].status = Loadable::Loading; + assert!(!active_repo_ready(&state), "a new status load is not ready"); + } + + #[test] + fn inactive_repositories_do_not_stand_in_for_the_selected_repository() { + let mut state = AppState::test_default(); + state.repos.push(RepoState::new_opening( + RepoId(1), + RepoSpec { + workdir: PathBuf::from("/tmp/inactive"), + }, + )); + let repo = &mut state.repos[0]; + repo.open = Loadable::Ready(()); + repo.status = Loadable::Ready(Default::default()); + repo.log = Loadable::Ready( + gitcomet_core::domain::LogPage { + commits: Vec::new(), + next_cursor: None, + } + .into(), + ); + assert!(!active_repo_ready(&state)); + state.active_repo = Some(RepoId(2)); + assert!(!active_repo_ready(&state)); + state.active_repo = Some(RepoId(1)); + state.repos.push(RepoState::new_opening( + RepoId(2), + RepoSpec { + workdir: PathBuf::from("/tmp/lazy"), + }, + )); + assert!( + active_repo_ready(&state), + "inactive tabs load contents on selection" + ); + } } diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs index 59b078307..4283669e7 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs @@ -486,6 +486,7 @@ impl GitCometView { window: &mut Window, cx: &mut gpui::Context, ) -> Self { + let _stage = gitcomet_core::startup_trace::span("view_init"); let GitCometViewConfig { mut initial_path, initial_repository_launch_mode, @@ -595,7 +596,10 @@ impl GitCometView { // This window's scale: the default, unless the window has its own zoom. let ui_scale = crate::ui_scale::current(cx); ui_preferences.appearance.ui_scale_percent = ui_scale.percent; - let _font_preferences = crate::session_ui::font_preferences(window, &ui_session, cx); + let _font_preferences = { + let _stage = gitcomet_core::startup_trace::span("font_preferences"); + crate::session_ui::font_preferences(window, &ui_session, cx) + }; if should_seed_initial_repository_from_session( view_mode, initial_path.as_deref(), diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view_render.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view_render.rs index aad29a8e3..4c978ce24 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view_render.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view_render.rs @@ -873,10 +873,18 @@ impl Render for GitCometView { root = root.child(UiScaleScrollCapture { view: cx.entity() }); if crate::startup_probe::is_enabled() { - root = root.on_children_prepainted(|_children_bounds, window, _cx| { - if crate::startup_probe::mark_first_paint() { - window.on_next_frame(|_window, cx| { - crate::startup_probe::mark_first_interactive(); + let home_ready = self.is_home_screen_active() && self.state.git_runtime.is_available(); + root = root.on_children_prepainted(move |_children_bounds, window, _cx| { + let first_paint = crate::startup_probe::mark_first_paint(); + let home_frame = home_ready && !crate::startup_probe::home_ready_recorded(); + if first_paint || home_frame { + window.on_next_frame(move |_window, cx| { + if first_paint { + crate::startup_probe::mark_first_interactive(); + } + if home_frame { + crate::startup_probe::mark_home_ready(); + } if crate::startup_probe::should_exit_after_first_interactive() { crate::app::mark_clean_shutdown_requested(cx); cx.quit(); diff --git a/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs b/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs index caf063ef3..ccd1ad666 100644 --- a/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs @@ -50,15 +50,20 @@ pub(super) fn request(cx: &mut gpui::App, force: bool) { }; let (work, adopted) = match begin_git_runtime_probe(force) { Some(probe) => ( - cx.background_executor().spawn(async move { probe.run() }), + cx.background_executor().spawn(async move { + let _stage = gitcomet_core::startup_trace::span("git_runtime_probe_background"); + probe.run() + }), false, ), // Nothing to start: a probe is running or has settled. The browser // probes before its first window exists, and that probe reports to no // store, so a window created meanwhile adopts its result. None => ( - cx.background_executor() - .spawn(async move { wait_for_git_runtime_probe(ADOPT_TIMEOUT) }), + cx.background_executor().spawn(async move { + let _stage = gitcomet_core::startup_trace::span("git_runtime_adopt_background"); + wait_for_git_runtime_probe(ADOPT_TIMEOUT) + }), true, ), }; @@ -73,6 +78,11 @@ pub(super) fn request(cx: &mut gpui::App, force: bool) { if current_git_runtime() != runtime { return; } + gitcomet_core::startup_trace::mark(if runtime.is_available() { + "git_runtime_available_for_ui" + } else { + "git_runtime_unavailable_for_ui" + }); crate::environment::refresh_git(cx); for handle in cx.windows() { if let Some(handle) = handle.downcast::() { diff --git a/crates/gitcomet-ui-gpui/src/windows_renderer.rs b/crates/gitcomet-ui-gpui/src/windows_renderer.rs index 4190ad487..45e4721a3 100644 --- a/crates/gitcomet-ui-gpui/src/windows_renderer.rs +++ b/crates/gitcomet-ui-gpui/src/windows_renderer.rs @@ -165,6 +165,11 @@ mod windows { }, on_event: Some(Rc::new(move |event| match event { Event::Failed { runtime, .. } => { + gitcomet_core::startup_trace::mark(if runtime { + "renderer_runtime_failure" + } else { + "renderer_startup_fallback" + }); // Rare failure path: finish the narrow transaction before GPUI's fallback/fatal handling. let writer = observer.borrow().writer.clone(); let sequence = writer.next(); @@ -177,6 +182,10 @@ mod windows { backend, overridden, } => { + gitcomet_core::startup_trace::mark(match backend { + Backend::Dx11 => "renderer_dx11", + Backend::Dx12 => "renderer_dx12", + }); let mut state = observer.borrow_mut(); state.active = Some(backend); state.overridden = overridden; diff --git a/crates/gitcomet-ui-kit/src/font_preferences.rs b/crates/gitcomet-ui-kit/src/font_preferences.rs index 7d2d07caa..17571fbd3 100644 --- a/crates/gitcomet-ui-kit/src/font_preferences.rs +++ b/crates/gitcomet-ui-kit/src/font_preferences.rs @@ -320,6 +320,7 @@ fn system_font_catalog(window: Option<&Window>) -> &'static SystemFontCatalog { } fn collect_system_font_catalog(window: &Window) -> SystemFontCatalog { + let _stage = gitcomet_base::startup_trace::span("system_font_catalog"); let (_, fontdb_monospace_families) = collect_fontdb_families(); let all_families = normalize_font_names(window.text_system().all_font_names()); let available_family_keys = all_families @@ -351,6 +352,7 @@ fn collect_fontdb_system_font_catalog() -> SystemFontCatalog { } fn collect_fontdb_families() -> (Vec, Vec) { + let _stage = gitcomet_base::startup_trace::span("fontdb_scan"); let mut db = fontdb::Database::new(); bundled_fonts::load_into_fontdb(&mut db); db.load_system_fonts(); diff --git a/crates/gitcomet/src/bin/perf_app_launch.rs b/crates/gitcomet/src/bin/perf_app_launch.rs index f6eeeecb5..775871dd3 100644 --- a/crates/gitcomet/src/bin/perf_app_launch.rs +++ b/crates/gitcomet/src/bin/perf_app_launch.rs @@ -27,6 +27,7 @@ const DEFAULT_BENCH: &str = "app_launch/cold_empty_workspace"; const DEFAULT_TIMEOUT_MS: u64 = 30_000; const SESSION_FILE_VERSION: u32 = 2; const EXPECT_READY_REPOS_ENV: &str = "GITCOMET_PERF_STARTUP_EXPECT_READY_REPOS"; +const WAIT_FOR_ACTIVE_REPO_ENV: &str = "GITCOMET_PERF_STARTUP_WAIT_FOR_ACTIVE_REPO"; const CLI_USAGE_EXIT_CODE: i32 = 2; const ENVIRONMENT_BLOCKER_EXIT_CODE: i32 = 3; const ENVIRONMENT_BLOCKER_MARKER: &str = "This is an environment blocker, not a benchmark result."; @@ -76,13 +77,19 @@ struct ObservedMilestone { alloc_metrics: Option, } -#[derive(Debug)] -struct HarnessRunResult { - status: ExitStatus, +#[derive(Debug, Default)] +struct LaunchProgress { first_paint: Option, first_interactive: Option, + active_repo_ready: Option, repos_loaded: u64, repos_total: u64, +} + +#[derive(Debug)] +struct HarnessRunResult { + status: ExitStatus, + progress: LaunchProgress, stderr_tail: Vec, } @@ -333,6 +340,15 @@ fn run_harness(args: &CliArgs) -> Result<(), String> { .arg("--child") .env("GITCOMET_PERF_STARTUP_PROBE", "1") .env("GITCOMET_PERF_STARTUP_AUTO_EXIT", "1") + .env("GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY", "0") + .env( + WAIT_FOR_ACTIVE_REPO_ENV, + if fixture.expected_ready_repos > 0 { + "1" + } else { + "0" + }, + ) .env( EXPECT_READY_REPOS_ENV, fixture.expected_ready_repos.to_string(), @@ -377,23 +393,12 @@ fn run_harness(args: &CliArgs) -> Result<(), String> { let timeout = Duration::from_millis(args.timeout_ms.max(1)); let rss_limit_kib = benchmark_ram_limit_kib(); let started_at = Instant::now(); - let mut first_paint = None; - let mut first_interactive = None; - let mut repos_loaded = 0u64; - let mut repos_total = 0u64; + let mut progress = LaunchProgress::default(); let mut stderr_tail = VecDeque::with_capacity(12); while started_at.elapsed() <= timeout { match rx.recv_timeout(Duration::from_millis(50)) { - Ok(Ok(line)) => process_probe_line( - line, - started_at, - &mut first_paint, - &mut first_interactive, - &mut repos_loaded, - &mut repos_total, - &mut stderr_tail, - ), + Ok(Ok(line)) => process_probe_line(line, started_at, &mut progress, &mut stderr_tail), Ok(Err(err)) => { let _ = terminate_child(&mut child); let _ = reader.join(); @@ -414,24 +419,13 @@ fn run_harness(args: &CliArgs) -> Result<(), String> { .map_err(|err| format!("failed to poll child process: {err}"))? { let _ = reader.join(); - drain_probe_channel( - &rx, - started_at, - &mut first_paint, - &mut first_interactive, - &mut repos_loaded, - &mut repos_total, - &mut stderr_tail, - )?; + drain_probe_channel(&rx, started_at, &mut progress, &mut stderr_tail)?; return finish_harness( args, fixture.expected_ready_repos, HarnessRunResult { status, - first_paint, - first_interactive, - repos_loaded, - repos_total, + progress, stderr_tail: stderr_tail_to_vec(stderr_tail), }, ); @@ -440,24 +434,13 @@ fn run_harness(args: &CliArgs) -> Result<(), String> { let status = terminate_child(&mut child)?; let _ = reader.join(); - drain_probe_channel( - &rx, - started_at, - &mut first_paint, - &mut first_interactive, - &mut repos_loaded, - &mut repos_total, - &mut stderr_tail, - )?; + drain_probe_channel(&rx, started_at, &mut progress, &mut stderr_tail)?; finish_harness( args, fixture.expected_ready_repos, HarnessRunResult { status, - first_paint, - first_interactive, - repos_loaded, - repos_total, + progress, stderr_tail: stderr_tail_to_vec(stderr_tail), }, ) @@ -492,6 +475,15 @@ fn run_first_interactive_probe( .arg("--child") .env("GITCOMET_PERF_STARTUP_PROBE", "1") .env("GITCOMET_PERF_STARTUP_AUTO_EXIT", "1") + .env("GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY", "0") + .env( + WAIT_FOR_ACTIVE_REPO_ENV, + if fixture.expected_ready_repos > 0 { + "1" + } else { + "0" + }, + ) .env( EXPECT_READY_REPOS_ENV, fixture.expected_ready_repos.to_string(), @@ -538,24 +530,13 @@ fn run_first_interactive_probe( let timeout = Duration::from_millis(timeout_ms.max(1)); let rss_limit_kib = benchmark_ram_limit_kib(); let started_at = Instant::now(); - let mut first_paint = None; - let mut first_interactive = None; - let mut repos_loaded = 0u64; - let mut repos_total = 0u64; + let mut progress = LaunchProgress::default(); let mut stderr_tail = VecDeque::with_capacity(12); let mut child_status = None; while started_at.elapsed() <= timeout { match rx.recv_timeout(Duration::from_millis(50)) { - Ok(Ok(line)) => process_probe_line( - line, - started_at, - &mut first_paint, - &mut first_interactive, - &mut repos_loaded, - &mut repos_total, - &mut stderr_tail, - ), + Ok(Ok(line)) => process_probe_line(line, started_at, &mut progress, &mut stderr_tail), Ok(Err(err)) => { let _ = terminate_child(&mut child); let _ = reader.join(); @@ -577,7 +558,10 @@ fn run_first_interactive_probe( break; } - if first_interactive.is_some() { + if progress.first_interactive.is_some() + && (fixture.expected_ready_repos == 0 || progress.active_repo_ready.is_some()) + && progress.repos_loaded >= fixture.expected_ready_repos as u64 + { break; } } @@ -585,15 +569,7 @@ fn run_first_interactive_probe( // Even a successful leader exit can leave inherited pipes in descendants. let _ = terminate_child(&mut child); let _ = reader.join(); - drain_probe_channel( - &rx, - started_at, - &mut first_paint, - &mut first_interactive, - &mut repos_loaded, - &mut repos_total, - &mut stderr_tail, - )?; + drain_probe_channel(&rx, started_at, &mut progress, &mut stderr_tail)?; let stderr_tail = stderr_tail_to_vec(stderr_tail); if let Some(status) = child_status @@ -609,7 +585,7 @@ fn run_first_interactive_probe( )); } - if first_interactive.is_none() { + if progress.first_interactive.is_none() { if let Some(blocker) = detect_linux_display_environment_blocker(bench, stage, &stderr_tail) { return Err(blocker); @@ -618,7 +594,24 @@ fn run_first_interactive_probe( "{stage} did not reach first_interactive before timeout" )); } - require_launch_milestones(bench, stage, first_paint, first_interactive)?; + require_launch_milestones( + bench, + stage, + progress.first_paint, + progress.first_interactive, + )?; + require_active_repo_ready( + bench, + stage, + fixture.expected_ready_repos, + progress.active_repo_ready, + )?; + if progress.repos_loaded < fixture.expected_ready_repos as u64 { + return Err(format!( + "{stage} opened only {} of {} expected repos for {bench}", + progress.repos_loaded, fixture.expected_ready_repos, + )); + } Ok(()) } @@ -641,6 +634,21 @@ fn require_launch_milestones( Ok((first_paint, first_interactive)) } +fn require_active_repo_ready( + bench: &str, + stage: &str, + expected_ready_repos: usize, + active_repo_ready: Option, +) -> Result<(), String> { + if expected_ready_repos == 0 { + return Ok(()); + } + let milestone = active_repo_ready.ok_or_else(|| { + format!("{stage} did not finish loading active repository contents for {bench}") + })?; + require_launch_alloc_metrics(bench, stage, "active_repo_ready", milestone) +} + fn require_launch_alloc_metrics( bench: &str, stage: &str, @@ -652,7 +660,7 @@ fn require_launch_alloc_metrics( } Err(format!( - "{stage} emitted {milestone_name} for {bench} without allocation metrics; app_launch requires first_paint and first_interactive allocation snapshots before preflight succeeds or a sidecar is written" + "{stage} emitted {milestone_name} for {bench} without allocation metrics; app_launch requires allocation snapshots for its startup milestones before preflight succeeds or a sidecar is written" )) } @@ -663,12 +671,16 @@ fn finish_harness( ) -> Result<(), String> { let HarnessRunResult { status, + progress, + stderr_tail, + } = result; + let LaunchProgress { first_paint, first_interactive, + active_repo_ready, repos_loaded, repos_total, - stderr_tail, - } = result; + } = progress; if !status.success() { if let Some(blocker) = detect_linux_display_environment_blocker( @@ -705,16 +717,28 @@ fn finish_harness( )); } - let metrics = build_launch_sidecar_metrics(first_paint, first_interactive, repos_loaded); + require_active_repo_ready( + &args.bench, + "launch probe child", + expected_ready_repos, + active_repo_ready, + )?; + let metrics = build_launch_sidecar_metrics( + first_paint, + first_interactive, + active_repo_ready, + repos_loaded, + ); let report = PerfSidecarReport::new(&args.bench, metrics); let sidecar_path = write_criterion_sidecar(&report) .map_err(|err| format!("failed to write sidecar report for {}: {err}", args.bench))?; println!( - "{} first_paint_ms={} first_interactive_ms={} repos_loaded={} sidecar={}", + "{} first_paint_ms={} first_interactive_ms={} active_repo_ready_ms={:?} repos_loaded={} sidecar={}", args.bench, first_paint.elapsed_ms, first_interactive.elapsed_ms, + active_repo_ready.map(|milestone| milestone.elapsed_ms), repos_loaded, sidecar_path.display() ); @@ -724,6 +748,7 @@ fn finish_harness( fn build_launch_sidecar_metrics( first_paint: ObservedMilestone, first_interactive: ObservedMilestone, + active_repo_ready: Option, repos_loaded: u64, ) -> Map { let mut metrics = Map::new(); @@ -744,6 +769,15 @@ fn build_launch_sidecar_metrics( if let Some(alloc_metrics) = first_interactive.alloc_metrics { alloc_metrics.append_to_payload_with_prefix(&mut metrics, "first_interactive_"); } + if let Some(ready) = active_repo_ready { + metrics.insert("active_repo_ready_ms".to_string(), json!(ready.elapsed_ms)); + if let Some(rss_kib) = ready.rss_kib { + metrics.insert("rss_at_active_repo_ready_kib".to_string(), json!(rss_kib)); + } + if let Some(alloc_metrics) = ready.alloc_metrics { + alloc_metrics.append_to_payload_with_prefix(&mut metrics, "active_repo_ready_"); + } + } metrics.insert("repos_loaded".to_string(), json!(repos_loaded)); metrics } @@ -808,10 +842,7 @@ fn duration_to_millis_u64(duration: Duration) -> u64 { fn process_probe_line( line: String, started_at: Instant, - first_paint: &mut Option, - first_interactive: &mut Option, - repos_loaded: &mut u64, - repos_total: &mut u64, + progress: &mut LaunchProgress, stderr_tail: &mut VecDeque, ) { if let Some(event) = parse_probe_event_line(&line) { @@ -821,13 +852,18 @@ fn process_probe_line( alloc_metrics: alloc_metrics_from_event(&event), }; match event.name.as_str() { - "first_paint" if first_paint.is_none() => *first_paint = Some(milestone), - "first_interactive" if first_interactive.is_none() => { - *first_interactive = Some(milestone) + "first_paint" if progress.first_paint.is_none() => { + progress.first_paint = Some(milestone) + } + "first_interactive" if progress.first_interactive.is_none() => { + progress.first_interactive = Some(milestone) + } + "active_repo_ready" if progress.active_repo_ready.is_none() => { + progress.active_repo_ready = Some(milestone) } "repos_loaded" => { - *repos_loaded = (*repos_loaded).max(event.repos_loaded.unwrap_or(0)); - *repos_total = (*repos_total).max(event.repos_total.unwrap_or(0)); + progress.repos_loaded = progress.repos_loaded.max(event.repos_loaded.unwrap_or(0)); + progress.repos_total = progress.repos_total.max(event.repos_total.unwrap_or(0)); } _ => {} } @@ -851,23 +887,12 @@ fn alloc_metrics_from_event(event: &StartupProbeEvent) -> Option>, started_at: Instant, - first_paint: &mut Option, - first_interactive: &mut Option, - repos_loaded: &mut u64, - repos_total: &mut u64, + progress: &mut LaunchProgress, stderr_tail: &mut VecDeque, ) -> Result<(), String> { loop { match rx.try_recv() { - Ok(Ok(line)) => process_probe_line( - line, - started_at, - first_paint, - first_interactive, - repos_loaded, - repos_total, - stderr_tail, - ), + Ok(Ok(line)) => process_probe_line(line, started_at, progress, stderr_tail), Ok(Err(err)) => return Err(format!("failed while reading child stderr: {err}")), Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return Ok(()), } @@ -1637,6 +1662,40 @@ mod tests { assert_eq!(event.repos_total, Some(5)); } + #[test] + fn active_repo_milestone_is_distinct_from_open_handles_and_first_interactive() { + let mut progress = LaunchProgress::default(); + let mut stderr = VecDeque::new(); + let start = Instant::now(); + for payload in [ + r#"{"name":"first_interactive"}"#, + r#"{"name":"repos_loaded","repos_loaded":5,"repos_total":5}"#, + ] { + process_probe_line( + format!("{EVENT_PREFIX}{payload}"), + start, + &mut progress, + &mut stderr, + ); + } + assert!(progress.first_interactive.is_some()); + assert_eq!(progress.repos_loaded, 5); + assert!(progress.active_repo_ready.is_none()); + assert!(require_active_repo_ready("fixture", "child", 5, None).is_err()); + assert!(require_active_repo_ready("empty", "child", 0, None).is_ok()); + process_probe_line( + format!( + "{EVENT_PREFIX}{}", + r#"{"name":"active_repo_ready","rss_kib":123}"# + ), + start, + &mut progress, + &mut stderr, + ); + assert_eq!(progress.active_repo_ready.unwrap().rss_kib, Some(123)); + assert!(stderr.is_empty()); + } + #[test] fn parse_probe_event_line_reads_allocation_payload() { let event = parse_probe_event_line( @@ -1682,7 +1741,12 @@ mod tests { }), }; - let metrics = build_launch_sidecar_metrics(first_paint, first_interactive, 5); + let metrics = build_launch_sidecar_metrics( + first_paint, + first_interactive, + Some(first_interactive), + 5, + ); assert_eq!(metrics.get("first_paint_ms"), Some(&Value::from(111))); assert_eq!(metrics.get("first_interactive_ms"), Some(&Value::from(222))); @@ -1708,6 +1772,15 @@ mod tests { Some(&Value::from(6_144)) ); assert_eq!(metrics.get("repos_loaded"), Some(&Value::from(5))); + assert_eq!(metrics.get("active_repo_ready_ms"), Some(&Value::from(222))); + assert_eq!( + metrics.get("active_repo_ready_alloc_bytes"), + Some(&Value::from(8_192)) + ); + assert_eq!( + metrics.get("rss_at_active_repo_ready_kib"), + Some(&Value::from(33_333)) + ); } #[test] diff --git a/scripts/profiling/README.md b/scripts/profiling/README.md index b2a9a0253..82e9b428f 100644 --- a/scripts/profiling/README.md +++ b/scripts/profiling/README.md @@ -27,6 +27,67 @@ instrumented captures separate from latency measurements. Paired drivers alternate execution order and retain raw samples and metadata. Use fresh output directories, normally under `target/` or `tmp/`. +## Capture a slow startup from the normal app + +Use a build that includes startup tracing; no benchmark features are required. +Close existing GitComet windows first so the new process does not forward its +request to an already running instance. On Windows, run: + +```powershell +./scripts/profiling/capture-startup.ps1 -Binary ./target/release/gitcomet.exe +``` + +This uses the usual saved settings, leaves the app open, and prints the capture +directory. Share `startup.jsonl`, `capture.json`, and `stderr.log` from that +directory. The startup trace contains stage names and timings; stderr can include +local paths or other diagnostic details, so review it before sharing. The helper +stops observing after the first interactive frame or 90 seconds; the trace keeps +recording if the picker is still waiting for Git. A timeout does not terminate +the app. The helper restores all environment variables it sets. + +To measure the empty repository picker independently of saved settings, use: + +```powershell +./scripts/profiling/capture-startup.ps1 -Binary ./target/release/gitcomet.exe -EmptyProfile -AutoExit +``` + +`-EmptyProfile` uses a fresh `GITCOMET_PROFILE_ROOT` below the capture directory, +isolating session, themes, browser-instance broker and crash data. `-AutoExit` +records the first interactive frame (the ready Home picker with `-EmptyProfile`), +waits one second, then requests normal main-window closure. It waits up to five +seconds for exit and warns if the app remains open; it never force-kills the app. +This uses the normal shutdown lifecycle so background startup work can finish. +It does not wait for repository loading. Without `-AutoExit`, the app stays open. +Compare matching release binaries without concurrent builds. The helper's +`markerObservedMs` is a coarse parent-observed upper bound including process +creation and up to 100 ms polling delay, not a precise benchmark measurement. +The marker time is recorded before the optional shutdown delay. + +The same opt-in trace works on Linux and macOS without the PowerShell helper: + +```sh +GITCOMET_STARTUP_TRACE="$PWD/startup.jsonl" ./target/release/gitcomet +``` + +Choose a file in an existing local directory. The app appends JSON lines tagged +with its process id. `elapsed_ms` starts at the first trace call in application +entry, so it excludes OS executable loading before `main`. `begin`/`end` pairs +identify a slow or stalled stage; `duration_ms` is that stage's wall time. Nested +and background spans overlap and must not be added together. In particular, +`git_runtime_probe_background` does not block window creation, but the Home picker +displays a loading screen until Git validation completes. +`native_application` covers native platform, GPU and native font-system setup; +Windows additionally reports renderer choice and fallback. `fontdb_scan` is a +separate installed-font scan used when custom font preferences need validation. +`first_paint` marks prepaint of the initial view and `first_interactive` its next +frame callback, not an OS presentation timestamp or measured input latency. These +may refer to the Git-checking loading screen. `home_ready` is the frame after the +Home picker was prepainted with Git available, so use it for empty-picker startup +latency. `GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY=1` makes probe autoexit wait for +that milestone; the default remains the original first-interactive behavior. +Trace writes are synchronous and opt-in; use untraced runs for final latency +comparisons and traced runs to locate the delay. + ## Application investigation and regression workflow `performance.py` joins corpus preparation, native UI scenarios, Git/LFS/annex @@ -259,6 +320,26 @@ Harness verification: `python3 -m unittest discover -s scripts/profiling -p ## Backend measurements +For a slow repository open or unchanged refresh, `repository-load-probe` times +the individual backend stages against an existing repository. Build and run: + +```sh +cargo build --release -p gitcomet-git-gix --features benchmarks --example repository-load-probe +target/release/examples/repository-load-probe REPO --refreshes 2 > target/repository-load-local.jsonl +target/release/examples/repository-load-probe REPO --refreshes 2 --remote-tags > target/repository-load-with-remotes.jsonl +``` + +The default run reads local repository data. `--remote-tags` also queries the +configured remotes, so use it explicitly when investigating network delay. +`--all-branches` selects history across all branches. Each stage emits a start +record immediately and a finish record with elapsed time, a result witness, +errors and captured Git-command timings. Iteration zero is the initial load; +later iterations reuse the repository handle. Stages run sequentially to expose +expensive operations: their sum does not measure the application's concurrent +startup or foreground latency. Command capture is thread-local, so remote-tag +subprocesses on child threads contribute to stage elapsed time but are absent +from the command breakdown. On Windows append `.exe` to the executable path. + `application-probe.py` enables `benchmarks,test-support` for the backend's `operation-context-probe`, whose Git subprocesses use an isolated test environment. To build that probe directly, run `cargo build --release -p gitcomet-git-gix @@ -644,6 +725,24 @@ forwards additional Cargo build arguments. | `calibrate-indexed-history.py` | Record five accepted release Criterion roots for budget calibration. | | `windows-workflow-probe.py` | Cache traversal and linker launch timing with `--baseline`, `--output` and `--samples`; both checkouts need the corresponding CI helpers. | +App-launch sidecars distinguish the initial window from usable repository data: + +| Metric | Completion condition | +| --- | --- | +| `first_paint_ms` | The first root view reaches the end of prepaint. | +| `first_interactive_ms` | The frame callback after that first prepaint runs; repository contents may still be loading. | +| `repos_loaded` | Number of repository handles opened; inactive restored tabs load their contents on selection. | +| `active_repo_ready_ms` | The active repository has status and history, with its foreground loads and actions settled. Optional remote-tag discovery and other-checkout scans may continue. | + +Repository launch scenarios and their warmup passes wait for `active_repo_ready` +as well as the expected open handles. Empty-workspace scenarios omit this +milestone. It measures model readiness, not a subsequent rendered frame or a +complete history index. `compare-perf-runs.sh` includes this timing by default; +older archives without it have no comparable value. For a directly launched app, +enable `GITCOMET_PERF_STARTUP_PROBE=1` to emit these events. With automatic exit, +`GITCOMET_PERF_STARTUP_WAIT_FOR_ACTIVE_REPO=1` waits for repository contents; +the launch harness sets it for repository scenarios. + Results are labelled by what they time (sidecar `measurement.kind`): `backend_operation`, `prepared_row_work` (row preparation, no layout or paint: this includes the `frame_timing`, `display` and `keyboard` groups), diff --git a/scripts/profiling/capture-startup.ps1 b/scripts/profiling/capture-startup.ps1 new file mode 100644 index 000000000..b3181586e --- /dev/null +++ b/scripts/profiling/capture-startup.ps1 @@ -0,0 +1,165 @@ +<# +.SYNOPSIS +Capture startup stages from the normal GitComet executable. +.DESCRIPTION +Close existing GitComet windows first. By default this uses your usual saved +settings and leaves the app running. -EmptyProfile uses a fresh isolated profile +to measure the repository picker. -AutoExit requests normal window closure one +second after its first interactive frame (or the ready picker with +-EmptyProfile), then waits up to five seconds for exit. If the app stays open, +it is left running. Neither switch is needed for reporter diagnostics. +.EXAMPLE +./scripts/profiling/capture-startup.ps1 -Binary ./target/release/gitcomet.exe +.EXAMPLE +./scripts/profiling/capture-startup.ps1 -Binary ./target/release/gitcomet.exe -EmptyProfile -AutoExit +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Binary, + [string]$OutputDirectory = "", + [switch]$EmptyProfile, + [switch]$AutoExit, + [ValidateRange(1, 600)][int]$TimeoutSeconds = 90 +) + +$ErrorActionPreference = 'Stop' +$binaryPath = (Resolve-Path -LiteralPath $Binary).Path +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $PSScriptRoot ("../../target/startup-captures/" + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) +} +$captureRoot = [System.IO.Path]::GetFullPath($OutputDirectory) +[void][System.IO.Directory]::CreateDirectory($captureRoot) +$tracePath = Join-Path $captureRoot 'startup.jsonl' +if (Test-Path -LiteralPath $tracePath) { + throw "A startup trace already exists in $captureRoot. Choose a fresh output directory." +} + +# Change only this child's inherited environment, restoring the shell even if +# launch fails. PROFILE_ROOT also isolates the instance broker and crash data. +$changes = @{ + GITCOMET_STARTUP_TRACE = $tracePath + GITCOMET_PERF_STARTUP_PROBE = '1' + GITCOMET_PERF_STARTUP_AUTO_EXIT = '0' + GITCOMET_PERF_STARTUP_EXPECT_READY_REPOS = '0' + GITCOMET_PERF_STARTUP_WAIT_FOR_ACTIVE_REPO = '0' + GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY = '0' + GITCOMET_PERF_STARTUP_DISABLE_AUTO_RESTORE = '0' +} +# Even with -AutoExit, close through the normal window lifecycle below. The +# frame probe's immediate process exit can interrupt background tool probes. +if ($EmptyProfile) { + $changes.GITCOMET_PROFILE_ROOT = Join-Path $captureRoot 'profile' + $changes.GITCOMET_PERF_STARTUP_DISABLE_AUTO_RESTORE = '1' + $changes.GITCOMET_PERF_STARTUP_WAIT_FOR_HOME_READY = '1' + # A more specific inherited override must not escape profile isolation. + $changes.GITCOMET_SESSION_FILE = $null + $changes.GITCOMET_BROWSER_INSTANCE_FILE = $null +} +$previous = @{} +$clock = [System.Diagnostics.Stopwatch]::StartNew() +try { + foreach ($key in $changes.Keys) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') + if ($null -eq $changes[$key]) { + [Environment]::SetEnvironmentVariable($key, [NullString]::Value, 'Process') + } else { + [Environment]::SetEnvironmentVariable($key, $changes[$key], 'Process') + } + } + # This is the interactive application being measured. SW_HIDE suppresses + # frame callbacks, so the ready/interactive markers cannot be reached. + $process = Start-Process -FilePath $binaryPath -PassThru -WindowStyle Normal ` + -RedirectStandardOutput (Join-Path $captureRoot 'stdout.log') ` + -RedirectStandardError (Join-Path $captureRoot 'stderr.log') +} finally { + foreach ($key in $previous.Keys) { + if ($null -eq $previous[$key]) { + [Environment]::SetEnvironmentVariable($key, [NullString]::Value, 'Process') + } else { + [Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process') + } + } +} + +$targetMarker = if ($EmptyProfile) { 'home_ready' } else { 'first_interactive' } +$markerObservedMs = $null +$records = @() +while ($clock.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + if (Test-Path -LiteralPath $tracePath) { + # A writer can be between writes; discard an incomplete final line and + # retry instead of treating it as a corrupt capture. + $records = @(Get-Content -LiteralPath $tracePath | ForEach-Object { + try { $_ | ConvertFrom-Json -ErrorAction Stop } catch { } + } | Where-Object { $_.pid -eq $process.Id }) + if ($records | Where-Object { $_.name -eq $targetMarker }) { + $markerObservedMs = $clock.Elapsed.TotalMilliseconds + break + } + } + $process.Refresh() + if ($process.HasExited) { break } + Start-Sleep -Milliseconds 100 +} +$clock.Stop() +# Re-read after process exit: its last marker may have arrived between the +# previous file read and the HasExited check. +if (Test-Path -LiteralPath $tracePath) { + $records = @(Get-Content -LiteralPath $tracePath | ForEach-Object { + try { $_ | ConvertFrom-Json -ErrorAction Stop } catch { } + } | Where-Object { $_.pid -eq $process.Id }) + if ($null -eq $markerObservedMs -and ($records | Where-Object { $_.name -eq $targetMarker })) { + $markerObservedMs = $clock.Elapsed.TotalMilliseconds + } +} +$process.Refresh() +$closeRequested = $false +$closeError = $null +if ($AutoExit -and $null -ne $markerObservedMs -and -not $process.HasExited) { + # Keep the recorded marker time separate from this shutdown grace period. + Start-Sleep -Milliseconds 1000 + $process.Refresh() + if (-not $process.HasExited) { + try { + $closeRequested = $process.CloseMainWindow() + if ($closeRequested) { + [void]$process.WaitForExit(5000) + } else { + $closeError = 'The launched app did not accept a main-window close request.' + } + } catch { + $process.Refresh() + if (-not $process.HasExited) { $closeError = $_.Exception.Message } + } + $process.Refresh() + if (-not $process.HasExited) { + Write-Warning "The launched app is still running after the graceful-close request; it has been left open. $closeError" + } + } +} +$metadata = [ordered]@{ + binary = $binaryPath + sha256 = (Get-FileHash -LiteralPath $binaryPath -Algorithm SHA256).Hash + capturedAtUtc = [DateTime]::UtcNow.ToString('o') + processId = $process.Id + emptyProfile = [bool]$EmptyProfile + autoExit = [bool]$AutoExit + closeMode = $(if ($AutoExit) { 'main_window_close' } else { 'leave_open' }) + closeDelayMs = $(if ($AutoExit) { 1000 } else { $null }) + closeRequested = $closeRequested + closeError = $closeError + observedMarker = $targetMarker + markerObservedMs = $markerObservedMs + observationTimeoutSeconds = $TimeoutSeconds + processExited = $process.HasExited + exitCode = $(if ($process.HasExited) { $process.ExitCode } else { $null }) +} +$metadata | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $captureRoot 'capture.json') -Encoding UTF8 +$records | Where-Object { $_.kind -ne 'begin' } | + Select-Object name, kind, @{n='elapsed_ms';e={[Math]::Round($_.elapsed_ms, 2)}}, + @{n='duration_ms';e={if ($null -ne $_.duration_ms) {[Math]::Round($_.duration_ms, 2)}}} | + Format-Table -AutoSize +if ($null -eq $markerObservedMs) { + Write-Warning "No $targetMarker marker was observed. Check startup.jsonl and stderr.log. Close existing GitComet windows before retrying; the executable must include startup tracing support." + if (-not $process.HasExited) { Write-Output 'The launched app is still running; it has been left open.' } +} +Write-Output "Capture saved to $captureRoot" diff --git a/scripts/profiling/compare-perf-runs.sh b/scripts/profiling/compare-perf-runs.sh index 835758e2b..4bb8563c9 100755 --- a/scripts/profiling/compare-perf-runs.sh +++ b/scripts/profiling/compare-perf-runs.sh @@ -197,6 +197,9 @@ metric_column_stem() { sidecar:first_interactive_ms) printf 'fi_ms\n' ;; + sidecar:active_repo_ready_ms) + printf 'repo_ready_ms\n' + ;; sidecar:*) printf '%s\n' "${1#sidecar:}" ;; @@ -758,6 +761,7 @@ if [[ ${#metrics[@]} -eq 0 ]]; then "net_alloc_bytes" "first_paint_ms" "first_interactive_ms" + "active_repo_ready_ms" "first_paint_alloc_bytes" "first_interactive_alloc_bytes" ) From bfb28b9fb2cc13d8b0e3c652897ad1dd8704d816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:35:37 +0300 Subject: [PATCH 06/67] perf(fonts): skip installed-font scans for native system UI aliases Treat the native System UI font alias as known on platforms that resolve it directly. Preserve catalog lookup on Linux and FreeBSD, where the alias needs resolution, and for user-selected installed families. Cover catalog-free native startup and retain existing font-resolution tests. Local launch measurements show renderer initialization still dominates total startup. --- .../gitcomet-ui-kit/src/font_preferences.rs | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/gitcomet-ui-kit/src/font_preferences.rs b/crates/gitcomet-ui-kit/src/font_preferences.rs index 17571fbd3..2e1fbfc94 100644 --- a/crates/gitcomet-ui-kit/src/font_preferences.rs +++ b/crates/gitcomet-ui-kit/src/font_preferences.rs @@ -519,13 +519,18 @@ fn resolve_with_catalog( use_font_ligatures: Option, catalog: impl FnOnce() -> &'static FontOptionCatalog, ) -> AppFontPreferences { - // Bundled families are registered before windows open. Only a custom - // selection or the font picker needs the complete installed-font catalog. + // Bundled families are registered before windows open. Native system-font + // aliases are also always valid and need no installed-font catalog. Linux + // resolves that alias to a concrete installed family, so keeps its catalog. let ui = ui_font_family.unwrap_or(DEFAULT_UI_FONT_FAMILY); let editor = editor_font_family .filter(|family| *family != LEGACY_EDITOR_MONOSPACE_FONT_FAMILY) .unwrap_or(EDITOR_MONOSPACE_FONT_FAMILY); - if is_bundled_font_family(ui) && is_bundled_font_family(editor) { + let known_family = |family: &str| { + is_bundled_font_family(family) + || (family == UI_SYSTEM_FONT_FAMILY && !should_resolve_system_ui_font(family)) + }; + if known_family(ui) && known_family(editor) { return AppFontPreferences { ui_font_family: ui.to_string(), editor_font_family: editor.to_string(), @@ -561,6 +566,35 @@ fn font_option_catalog(window: &Window) -> &'static FontOptionCatalog { mod tests { use super::*; + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + #[test] + fn native_system_font_startup_does_not_enumerate_installed_fonts() { + for (ui, editor) in [ + (Some(UI_SYSTEM_FONT_FAMILY), None), + (None, Some(UI_SYSTEM_FONT_FAMILY)), + (Some(UI_SYSTEM_FONT_FAMILY), Some(UI_SYSTEM_FONT_FAMILY)), + ] { + let resolved = resolve_with_catalog(ui, editor, Some(true), || { + panic!("the native system alias is always an available selection") + }); + assert_eq!( + resolved.ui_font_family, + ui.unwrap_or(DEFAULT_UI_FONT_FAMILY) + ); + assert_eq!( + resolved.editor_font_family, + editor.unwrap_or(EDITOR_MONOSPACE_FONT_FAMILY) + ); + assert!(resolved.use_font_ligatures); + for family in [&resolved.ui_font_family, &resolved.editor_font_family] { + assert_eq!( + applied_font_family(family, || panic!("native aliases need no catalog")), + *family + ); + } + } + } + #[test] fn bundled_font_startup_and_application_do_not_enumerate_system_fonts() { for (ui, editor) in [ From 670b07a04ac168a09de72b3ad9b4f74a4f7aab77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:43:16 +0300 Subject: [PATCH 07/67] chore(ci): account for startup diagnostics in the host source budget Reconcile the merged dev ratchet with the new readiness instrumentation and background-refresh spinner coverage. Record the 266 added host lines explicitly while keeping the other nine structural limits unchanged. --- scripts/ci/ratchets.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/ratchets.txt b/scripts/ci/ratchets.txt index 5c4fa43d4..89e500fc8 100644 --- a/scripts/ci/ratchets.txt +++ b/scripts/ci/ratchets.txt @@ -1,6 +1,7 @@ # Refactor round 2 ratchets: . See docs/refactor-round-2.md. # Host counts include window wheel zoom and Markdown diagram height integration. -ui_gpui_production_lines max 244493 +# Host counts also include startup readiness diagnostics and quiet background refreshes. +ui_gpui_production_lines max 244759 gitcomet_view_fields max 162 super_glob_imports_in_ui_production max 331 impl_gitcomet_view_files_outside_root max 23 From a362d8fd8490639349ffe3f7248a46023112bb55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:52:13 +0300 Subject: [PATCH 08/67] perf(git): skip redundant status checks for empty submodule checkouts Avoid the supplemental Git process only for positively empty, unchanged stage-zero gitlink directories. Recheck checkout and ancestor metadata on every scan; preserve Git fallback for staged changes, initialized or missing checkouts, sparse entries, conflicts, nested repositories and reparse points. Replace native Ignore::All submodule setup with the equivalent no-op provider, since Git owns child status. Add all-lane parity and same-handle initialization regressions. All 393 backend tests passed; two five-pair release runs reduced warm GitComet status from about 101 ms to 56 ms (44%). --- crates/gitcomet-git-gix/src/refs/mod.rs | 4 +- crates/gitcomet-git-gix/src/repo/status.rs | 104 ++++--- .../src/repo/status/refresh_tests.rs | 264 ++++++++++++++++++ 3 files changed, 337 insertions(+), 35 deletions(-) diff --git a/crates/gitcomet-git-gix/src/refs/mod.rs b/crates/gitcomet-git-gix/src/refs/mod.rs index 778fdfbf6..26ad72c6e 100644 --- a/crates/gitcomet-git-gix/src/refs/mod.rs +++ b/crates/gitcomet-git-gix/src/refs/mod.rs @@ -9,8 +9,8 @@ mod write; pub(crate) use config::reload_conditional_includes; pub(crate) use revision::{resolve, resolve_required}; pub(crate) use state::{ - RootRef, diff_tree_to_tree, has_modules, head_tree_id_or_empty, - index_or_load_from_head_or_empty, operation_state, root_ref, submodule_state, submodules, + RootRef, diff_tree_to_tree, head_tree_id_or_empty, index_or_load_from_head_or_empty, + operation_state, root_ref, submodule_state, submodules, }; pub(crate) use write::{create, delete, delete_batch, delete_named}; diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs index b9cf03d18..0ecd9dfb9 100644 --- a/crates/gitcomet-git-gix/src/repo/status.rs +++ b/crates/gitcomet-git-gix/src/repo/status.rs @@ -131,7 +131,6 @@ impl GixRepo { &repo, &self.stat_refreshed_index, &mut unstaged, - may_have_gitlinks, cancellation, )?; cancellation.check_cancelled()?; @@ -175,7 +174,6 @@ impl GixRepo { &repo, &self.stat_refreshed_index, &mut unstaged, - may_have_gitlinks, cancellation, )?; cancellation.check_cancelled()?; @@ -696,7 +694,6 @@ fn collect_index_worktree_status_direct( repo: &gix::Repository, stat_refresh: &std::sync::Mutex>, unstaged: &mut Vec, - may_have_gitlinks: bool, cancellation: &CancellationToken, ) -> Result { let index = repo @@ -722,7 +719,6 @@ fn collect_index_worktree_status_direct( .as_ref() .map_or(&index, |refreshed| &refreshed.index), unstaged, - may_have_gitlinks, cancellation, ) .and_then(|result| { @@ -805,7 +801,6 @@ fn collect_index_worktree_status_direct_from_index( repo: &gix::Repository, index: &gix::index::File, unstaged: &mut Vec, - may_have_gitlinks: bool, cancellation: &CancellationToken, ) -> Result<(DirectIndexWorktreeStatus, Vec)> { let dirwalk_options = repo @@ -816,33 +811,17 @@ fn collect_index_worktree_status_direct_from_index( ))) })? .emit_untracked(gix::dir::walk::EmissionMode::Matching); - let collection = if may_have_gitlinks && crate::refs::has_modules(repo)? { - let submodule = gix::status::index_worktree::BuiltinSubmoduleStatus::new( - repo.clone().into_sync(), - gix::status::Submodule::Given { - ignore: gix::submodule::config::Ignore::All, - check_dirty: false, - }, - ) - .map_err(|e| Error::new(ErrorKind::Backend(format!("gix status submodules: {e}"))))?; - collect_index_worktree_status_direct_with_submodule( - repo, - index, - dirwalk_options, - unstaged, - submodule, - cancellation, - )? - } else { - collect_index_worktree_status_direct_with_submodule( - repo, - index, - dirwalk_options, - unstaged, - NoopSubmoduleStatus, - cancellation, - )? - }; + // Git supplies child status below. The previous built-in provider used + // Ignore::All, which also always returns None but still loads .gitmodules + // and inspects each child's repository layout before ignoring the result. + let collection = collect_index_worktree_status_direct_with_submodule( + repo, + index, + dirwalk_options, + unstaged, + NoopSubmoduleStatus, + cancellation, + )?; Ok(( DirectIndexWorktreeStatus { has_conflicted_unstaged: collection.has_conflicted_unstaged, @@ -1432,7 +1411,25 @@ fn gitlink_status_paths(repo: &gix::Repository, staged: &[FileStatus]) -> Option let mut paths = Vec::new(); for entry in index.entries() { if entry.mode == gix::index::entry::Mode::COMMIT { - paths.push(path_buf_from_git_bytes(entry.path(&index), "gitlink status path").ok()?); + let path = path_buf_from_git_bytes(entry.path(&index), "gitlink status path").ok()?; + // An unchanged gitlink with an empty checkout has no child status + // to inspect. Recheck the filesystem each time: initializing the + // child must restore the Git query without an index rewrite. + if entry.stage_raw() == 0 + && !entry.flags.intersects( + gix::index::entry::Flags::SKIP_WORKTREE + | gix::index::entry::Flags::INTENT_TO_ADD, + ) + && !staged + .iter() + .any(|change| change.path.starts_with(&path) || path.starts_with(&change.path)) + && repo + .workdir() + .is_some_and(|workdir| is_empty_gitlink_checkout(workdir, &path)) + { + continue; + } + paths.push(path); } } // Deleted/replaced gitlinks are absent from the index. Check only the @@ -1463,5 +1460,46 @@ fn gitlink_status_paths(repo: &gix::Repository, staged: &[FileStatus]) -> Option (bytes < 16_000).then_some(paths) } +fn is_empty_gitlink_checkout(workdir: &Path, path: &Path) -> bool { + if path.as_os_str().is_empty() { + return false; + } + let mut absolute = workdir.to_path_buf(); + let mut components = path.components().peekable(); + while let Some(component) = components.next() { + let std::path::Component::Normal(name) = component else { + return false; + }; + absolute.push(name); + // Do not follow symlinks/junctions in the checkout or its ancestors. + // Missing paths, replacements and unreadable metadata stay with Git. + let Ok(metadata) = std::fs::symlink_metadata(&absolute) else { + return false; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + // FILE_ATTRIBUTE_REPARSE_POINT also covers junctions and other + // redirects which need not be exposed as Rust symlink types. + if metadata.file_attributes() & 0x400 != 0 { + return false; + } + } + if components.peek().is_some() { + // A nested repository boundary makes this layout uncertain. + match std::fs::symlink_metadata(absolute.join(".git")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return false, + } + } + } + // Even a broken .git file, another hidden file, or an enumeration error + // retains the existing query. Only a positively empty directory is safe. + std::fs::read_dir(absolute).is_ok_and(|mut entries| entries.next().is_none()) +} + #[cfg(test)] pub(crate) mod tests; diff --git a/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs b/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs index 903f04fbe..e687dbd30 100644 --- a/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs +++ b/crates/gitcomet-git-gix/src/repo/status/refresh_tests.rs @@ -3,6 +3,270 @@ use gitcomet_core::domain::{FileStatus, FileStatusKind}; use gitcomet_core::services::CancellationToken; use std::path::Path; +const CHILD: &str = "modules/child [literal] é"; + +fn empty_gitlink_fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + init_test_repo(root); + write_file(root, "file.txt", "base\n"); + git_success(root, &["add", "."]); + git_success(root, &["commit", "-qm", "seed"]); + let oid = crate::refs::head_oid(&gix::open(root).unwrap()) + .unwrap() + .unwrap() + .to_string(); + write_file( + root, + ".gitmodules", + &format!("[submodule \"child\"]\n path = {CHILD}\n url = ../child\n"), + ); + git_success(root, &["add", ".gitmodules"]); + git_success( + root, + &[ + "update-index", + "--add", + "--cacheinfo", + "160000", + &oid, + CHILD, + ], + ); + git_success(root, &["commit", "-qm", "gitlink"]); + std::fs::create_dir_all(root.join(CHILD)).unwrap(); + dir +} + +fn selected_gitlinks(root: &Path, staged: &[FileStatus]) -> Option> { + super::gitlink_status_paths(&gix::open(root).unwrap(), staged) +} + +fn gitlink_status_from_git(root: &Path) -> (Vec, Vec) { + let output = crate::util::git_workdir_cmd_for(root) + .args([ + "--literal-pathspecs", + "--no-optional-locks", + "status", + "--porcelain=v2", + "-z", + "--ignore-submodules=none", + "--", + CHILD, + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let (mut staged, mut unstaged) = (Vec::new(), Vec::new()); + for record in output.stdout.split(|byte| *byte == 0) { + if record.starts_with(b"1 ") { + super::apply_porcelain_v2_gitlink_status_record(record, &mut staged, &mut unstaged) + .unwrap(); + } + } + (staged, unstaged) +} + +fn assert_gitlink_lanes_match_git( + root: &Path, + repo: &super::super::GixRepo, + needs_commands: (bool, bool), +) -> (Vec, Vec) { + let expected = gitlink_status_from_git(root); + let token = CancellationToken::new(); + let (combined, commands) = crate::command_trace::capture(|| repo.status_impl().unwrap()); + assert_eq!(*combined.staged, expected.0); + assert_eq!(*combined.unstaged, expected.1); + assert_eq!( + commands.len(), + usize::from(needs_commands.0), + "{commands:?}" + ); + let (worktree, commands) = + crate::command_trace::capture(|| repo.worktree_status_cancellable_impl(&token).unwrap()); + assert_eq!(worktree, expected.1); + assert_eq!( + commands.len(), + usize::from(needs_commands.1), + "{commands:?}" + ); + assert_eq!( + repo.staged_status_cancellable_impl(&token).unwrap(), + expected.0 + ); + expected +} + +fn assert_ignored_native_submodule_provider_is_equivalent(root: &Path) { + use gix::status::plumbing::index_as_worktree::traits::SubmoduleStatus as _; + let repo = gix::open(root).unwrap(); + let index = repo.index_or_empty().unwrap(); + let mut builtin = gix::status::index_worktree::BuiltinSubmoduleStatus::new( + repo.clone().into_sync(), + gix::status::Submodule::Given { + ignore: gix::submodule::config::Ignore::All, + check_dirty: false, + }, + ) + .unwrap(); + for entry in index + .entries() + .iter() + .filter(|entry| entry.mode.is_submodule()) + { + assert!(builtin.status(entry, entry.path(&index)).unwrap().is_none()); + assert!( + super::NoopSubmoduleStatus + .status(entry, entry.path(&index)) + .unwrap() + .is_none() + ); + } +} + +#[test] +fn empty_gitlinks_skip_git_but_child_initialization_and_dirt_restore_it() { + let dir = empty_gitlink_fixture(); + let root = dir.path(); + let child = root.join(CHILD); + let index_before = std::fs::read(root.join(".git/index")).unwrap(); + // Check cold staged requests too, before the combined-status cache exists. + let repo = open_repo(root); + let (staged, commands) = crate::command_trace::capture(|| { + repo.staged_status_cancellable_impl(&CancellationToken::new()) + .unwrap() + }); + assert!(staged.is_empty()); + assert!(commands.is_empty()); + for _ in 0..2 { + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (false, false)); + assert!(staged.is_empty() && unstaged.is_empty()); + } + assert_ignored_native_submodule_provider_is_equivalent(root); + + init_test_repo(&child); + write_file(&child, "child.txt", "base\n"); + git_success(&child, &["add", "."]); + git_success(&child, &["commit", "-qm", "child"]); + assert_eq!( + std::fs::read(root.join(".git/index")).unwrap(), + index_before + ); + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (true, true)); + assert!(staged.is_empty()); + assert_eq!(unstaged[0].kind, FileStatusKind::Modified); + assert_ignored_native_submodule_provider_is_equivalent(root); + + git_success(root, &["add", "--", CHILD]); + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (true, true)); + assert_eq!(staged[0].kind, FileStatusKind::Modified); + assert!(unstaged.is_empty()); + git_success(root, &["commit", "-qm", "child pointer"]); + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (true, true)); + assert!(staged.is_empty() && unstaged.is_empty()); + for name in ["child.txt", "untracked.txt"] { + write_file(&child, name, "changed\n"); + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (true, true)); + assert!(staged.is_empty()); + assert_eq!(unstaged[0].kind, FileStatusKind::Modified); + assert_ignored_native_submodule_provider_is_equivalent(root); + if name == "child.txt" { + git_success(&child, &["checkout", "--", name]); + } + } + + std::fs::remove_dir_all(&child).unwrap(); + assert_eq!(selected_gitlinks(root, &[]), Some(vec![CHILD.into()])); + assert_gitlink_lanes_match_git(root, &repo, (true, true)); + std::fs::create_dir(&child).unwrap(); + let (staged, unstaged) = assert_gitlink_lanes_match_git(root, &repo, (false, false)); + assert!(staged.is_empty() && unstaged.is_empty()); +} + +#[test] +fn gitlink_selection_keeps_uncertain_layouts_and_staged_changes() { + let dir = empty_gitlink_fixture(); + let root = dir.path(); + let child = root.join(CHILD); + let selected = Some(vec![CHILD.into()]); + assert_eq!(selected_gitlinks(root, &[]), Some(Vec::new())); + for name in [".git", "ordinary.txt"] { + write_file(&child, name, "not an initialized repository\n"); + assert_eq!(selected_gitlinks(root, &[]), selected); + std::fs::remove_file(child.join(name)).unwrap(); + } + write_file(root, "modules/.git", "gitdir: missing\n"); + assert_eq!(selected_gitlinks(root, &[]), selected); + std::fs::remove_file(root.join("modules/.git")).unwrap(); + std::fs::remove_dir(&child).unwrap(); + assert_eq!(selected_gitlinks(root, &[]), selected); + write_file(root, CHILD, "replacement\n"); + assert_eq!(selected_gitlinks(root, &[]), selected); + std::fs::remove_file(&child).unwrap(); + std::fs::create_dir(&child).unwrap(); + for path in [CHILD, "modules"] { + let changed = FileStatus { + path: path.into(), + kind: FileStatusKind::Modified, + conflict: None, + }; + assert_eq!(selected_gitlinks(root, &[changed]), selected); + } + + let repo = open_repo(root); + git_success(root, &["rm", "--cached", "--", CHILD]); + let (staged, _) = assert_gitlink_lanes_match_git(root, &repo, (true, false)); + assert_eq!(staged[0].kind, FileStatusKind::Deleted); + std::fs::remove_dir(&child).unwrap(); + write_file(root, CHILD, "replacement\n"); + git_success(root, &["add", "--", CHILD]); + let (staged, _) = assert_gitlink_lanes_match_git(root, &repo, (true, false)); + assert_eq!(staged[0].kind, FileStatusKind::Modified); +} + +#[test] +fn gitlink_selection_keeps_sparse_intent_to_add_and_conflicted_entries() { + use gix::index::entry::{Flags, Stage}; + let dir = empty_gitlink_fixture(); + let root = dir.path(); + let repo = gix::open(root).unwrap(); + let original = repo.index_or_empty().unwrap(); + let position = original + .entries() + .iter() + .position(|entry| entry.mode.is_submodule()) + .unwrap(); + for flags in [ + Flags::SKIP_WORKTREE | Flags::EXTENDED, + Flags::INTENT_TO_ADD | Flags::EXTENDED, + Flags::from_stage(Stage::Ours), + ] { + let mut index = (*original).clone(); + index.entries_mut()[position].flags = flags; + index.write(Default::default()).unwrap(); + assert_eq!(selected_gitlinks(root, &[]), Some(vec![CHILD.into()])); + } +} + +#[cfg(unix)] +#[test] +fn gitlink_selection_keeps_symlinked_checkout_and_ancestors() { + use std::os::unix::fs::symlink; + let dir = empty_gitlink_fixture(); + let root = dir.path(); + let child = root.join(CHILD); + let target = root.join("elsewhere"); + std::fs::create_dir(&target).unwrap(); + std::fs::remove_dir(&child).unwrap(); + symlink(&target, &child).unwrap(); + assert_eq!(selected_gitlinks(root, &[]), Some(vec![CHILD.into()])); + std::fs::remove_file(&child).unwrap(); + std::fs::remove_dir(root.join("modules")).unwrap(); + std::fs::create_dir(target.join(child.file_name().unwrap())).unwrap(); + symlink(&target, root.join("modules")).unwrap(); + assert_eq!(selected_gitlinks(root, &[]), Some(vec![CHILD.into()])); +} + fn shell_path(path: &Path) -> String { format!( "'{}'", From c880ce35d3294f6697422143ea78112b3535bc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 17:54:23 +0300 Subject: [PATCH 09/67] perf(state): defer selected-path reads until activation is checked RepoActivated no longer prepares selected HEAD gitlinks or filesystem markers before the asynchronous monitor check. The resulting RepoExternallyChanged message still prepares the inputs its reducer uses, avoiding duplicate store-thread I/O on every focus. Cover empty activation preparation, maintenance clock and throttling, and preserved selected-path inputs for full, index and worktree refreshes. --- .../src/store/reducer/prepared.rs | 69 +++++++++++++++++-- .../src/store/tests/prepared_inputs.rs | 50 ++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/crates/gitcomet-state/src/store/reducer/prepared.rs b/crates/gitcomet-state/src/store/reducer/prepared.rs index 30ba78dd6..e00bb1639 100644 --- a/crates/gitcomet-state/src/store/reducer/prepared.rs +++ b/crates/gitcomet-state/src/store/reducer/prepared.rs @@ -527,7 +527,6 @@ fn needs_preparation(message: &Msg) -> bool { | Msg::GlobalNavForward { .. } | Msg::ReloadRepo { .. } | Msg::RepoExternallyChanged { .. } - | Msg::RepoActivated { .. } | Msg::SetTextOverride { .. } | Msg::FilesystemPathsChanged(_) | Msg::FilesystemFinished(_) @@ -682,9 +681,6 @@ fn capture_message( Msg::RepoExternallyChanged { repo_id, change } => { inputs.external_change(state, repos, *repo_id, change, None); } - Msg::RepoActivated { repo_id } => { - inputs.external_change(state, repos, *repo_id, &RepoExternalChange::all(), None); - } Msg::SetTextOverride { repo_id, path, @@ -1159,3 +1155,68 @@ fn capture_command( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::msg::{Effect, RepoCommandEffect}; + use gitcomet_core::domain::{FileStatus, RepoSpec, RepoStatus}; + + #[test] + fn repo_activation_defers_selected_path_inputs_but_keeps_maintenance_clock() { + let repo_id = RepoId(1); + let repos = RepositoryRuntimes::default(); + for kind in [FileStatusKind::Deleted, FileStatusKind::Modified] { + let path = PathBuf::from("vendor/submodule"); + let mut repo = RepoState::new_opening( + repo_id, + RepoSpec { + workdir: PathBuf::from("activation-preparation-test"), + }, + ); + repo.set_open(Loadable::Ready(())); + repo.set_status(Loadable::Ready(Arc::new(RepoStatus { + staged: Arc::default(), + unstaged: Arc::new(vec![FileStatus { + path: path.clone(), + kind, + conflict: None, + }]), + }))); + repo.diff_state.diff_target = Some(DiffTarget::working_tree(path, DiffArea::Unstaged)); + repo.common_dir = Some(Arc::from(repo.spec.workdir.join(".git"))); + let mut state = AppState::test_default(); + state.maintenance_settings.recommend = true; + state.repos.push(repo); + state.active_repo = Some(repo_id); + let message = Msg::RepoActivated { repo_id }; + + assert!(!needs_preparation(&message)); + assert!(capture_paths(&message, &state).0.is_empty()); + let mut queries = InputQueries::default(); + capture_message(&message, &state, &repos, &mut queries); + assert!(queries.head.is_empty()); + assert!(queries.markers.is_empty()); + assert!(!queries.needs_preferences); + + let shared = RwLock::new(Arc::new(state)); + let before = SystemTime::now(); + let (_, inputs) = PreparedReduction::for_worker(message, &shared, &repos).into_parts(); + assert!(inputs.head.is_empty()); + assert!(inputs.markers.is_empty()); + assert!(inputs.now() >= before && inputs.now() <= SystemTime::now()); + let mut state = shared.into_inner().unwrap(); + let state = Arc::make_mut(&mut state); + assert!(matches!( + super::super::maintenance::request_check(&inputs, state, repo_id), + Some(Effect::RepoCommand(RepoCommandEffect::CheckRepoMaintenance { repo_id: id })) + if id == repo_id + )); + assert_eq!( + state.repos[0].maintenance.check_requested_at, + Some(inputs.now()) + ); + assert!(super::super::maintenance::request_check(&inputs, state, repo_id).is_none()); + } + } +} diff --git a/crates/gitcomet-state/src/store/tests/prepared_inputs.rs b/crates/gitcomet-state/src/store/tests/prepared_inputs.rs index 7cb0b95d8..5ecb16b23 100644 --- a/crates/gitcomet-state/src/store/tests/prepared_inputs.rs +++ b/crates/gitcomet-state/src/store/tests/prepared_inputs.rs @@ -302,6 +302,56 @@ fn selected_status(kind: FileStatusKind) -> Vec { }] } +#[test] +fn checked_activation_refresh_keeps_selected_path_inputs() { + use crate::msg::RepoExternalChange; + use crate::store::reducer::prepared::PreparedReduction; + + let directory = tempfile::tempdir().unwrap(); + fs::create_dir_all(directory.path().join("vendor/submodule")).unwrap(); + fs::write( + directory.path().join("vendor/submodule/.git"), + b"gitdir: ../../.git/modules/submodule\n", + ) + .unwrap(); + for (change, kind, expected_head_queries) in [ + (RepoExternalChange::all(), FileStatusKind::Deleted, 1), + (RepoExternalChange::all(), FileStatusKind::Modified, 0), + (RepoExternalChange::Index, FileStatusKind::Modified, 0), + (RepoExternalChange::Worktree, FileStatusKind::Modified, 0), + ] { + let (mut state, mut repos, target, queries) = + selected_fixture(directory.path(), Some(selected_status(kind)), Ok(true)); + state.repos[0].diff_state.diff_target = Some(target.clone()); + let shared = std::sync::RwLock::new(Arc::new(state)); + // The checked monitor reply reaches preparation as an external change. + let (message, inputs) = PreparedReduction::for_worker( + Msg::RepoExternallyChanged { + repo_id: RepoId(1), + change, + }, + &shared, + &repos, + ) + .into_parts(); + let mut state = shared.into_inner().unwrap(); + let state = Arc::make_mut(&mut state); + let effects = + reducer::reduce_prepared(&inputs, &mut repos, &AtomicU64::new(2), state, message); + + assert_eq!( + queries.try_iter().collect::>(), + vec![target.file_path().unwrap().to_path_buf(); expected_head_queries] + ); + assert_eq!(state.repos[0].diff_state.diff_target, Some(target.clone())); + assert!(effects.iter().any(|effect| matches!( + effect, + Effect::LoadSubmoduleSummary { repo_id: RepoId(1), target: requested } + if *requested == target + ))); + } +} + #[test] fn selected_head_lookup_queries_only_deleted_and_unknown_status() { let directory = tempfile::tempdir().unwrap(); From 43655ce2d81f326a453132df223770d63b565c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 18:52:25 +0300 Subject: [PATCH 10/67] Avoid selected-path preparation for unchanged repository tabs --- crates/gitcomet-state/src/store/mod.rs | 4 +- .../src/store/reducer/prepared.rs | 27 +++ .../src/store/reducer/repo_management.rs | 64 ++++--- .../src/store/tests/prepared_inputs.rs | 176 ++++++++++++++++++ 4 files changed, 247 insertions(+), 24 deletions(-) diff --git a/crates/gitcomet-state/src/store/mod.rs b/crates/gitcomet-state/src/store/mod.rs index 4a3dd320f..dff3303de 100644 --- a/crates/gitcomet-state/src/store/mod.rs +++ b/crates/gitcomet-state/src/store/mod.rs @@ -954,8 +954,8 @@ impl AppStore { } let unchanged = switching && worker_ctx.repo_monitors.unchanged_since_inactive(repo_id); - let (_, inputs) = PreparedReduction::for_worker( - Msg::SetActiveRepo { repo_id }, worker_ctx.thread_state, &repos, + let (_, inputs) = PreparedReduction::for_repo_switch( + repo_id, worker_ctx.thread_state, &repos, unchanged, ).into_parts(); worker_ctx.reduce_and_handle( &mut repos, diff --git a/crates/gitcomet-state/src/store/reducer/prepared.rs b/crates/gitcomet-state/src/store/reducer/prepared.rs index e00bb1639..82f3b2bb7 100644 --- a/crates/gitcomet-state/src/store/reducer/prepared.rs +++ b/crates/gitcomet-state/src/store/reducer/prepared.rs @@ -280,6 +280,28 @@ impl InputQueries { } impl PreparedReduction { + pub(in crate::store) fn for_repo_switch( + repo_id: RepoId, + state: &RwLock>, + repos: &RepositoryRuntimes, + unchanged: bool, + ) -> Self { + let now = SystemTime::now(); + let queries = { + let state = state.read().unwrap_or_else(|error| error.into_inner()); + let mut queries = InputQueries::default(); + if !super::repo_management::repo_switch_reuses_selected_diff(&state, repo_id, unchanged) + { + capture_message(&Msg::SetActiveRepo { repo_id }, &state, repos, &mut queries); + } + queries + }; + Self { + message: Msg::SetActiveRepo { repo_id }, + inputs: queries.resolve(now), + } + } + pub(in crate::store) fn for_worker( message: Msg, state: &RwLock>, @@ -326,6 +348,11 @@ impl PreparedReduction { pub(in crate::store) fn into_parts(self) -> (Msg, ReducerInputs) { (self.message, self.inputs) } + + #[cfg(test)] + pub(in crate::store) fn selected_input_counts(&self) -> (usize, usize) { + (self.inputs.head.len(), self.inputs.markers.len()) + } } #[derive(Default)] diff --git a/crates/gitcomet-state/src/store/reducer/repo_management.rs b/crates/gitcomet-state/src/store/reducer/repo_management.rs index 049935208..1b416737b 100644 --- a/crates/gitcomet-state/src/store/reducer/repo_management.rs +++ b/crates/gitcomet-state/src/store/reducer/repo_management.rs @@ -890,6 +890,29 @@ fn repo_switch_state_complete(repo_state: &RepoState, git_log_settings: GitLogSe && !repo_state.loads_in_flight.any_in_flight() } +/// Decide before preparing filesystem inputs: a retained diff that will not +/// reload also needs no fresh HEAD or submodule-marker classification. +pub(super) fn repo_switch_reuses_selected_diff( + state: &AppState, + repo_id: RepoId, + unchanged: bool, +) -> bool { + unchanged + && state.active_repo != Some(repo_id) + && state.repos.iter().any(|repo| { + repo.id == repo_id + && matches!(repo.open, Loadable::Ready(())) + && repo_switch_state_complete(repo, state.git_log_settings) + && matches!(repo.diff_state.diff, Loadable::Ready(_)) + && !matches!(repo.diff_state.diff_file, Loadable::Loading) + && repo + .diff_state + .diff_target + .as_ref() + .is_some_and(|target| selected_conflict_target(repo, target).is_none()) + }) +} + fn fill_set_active_repo_inline_impl( inputs: &ReducerInputs, state: &mut AppState, @@ -913,11 +936,14 @@ fn fill_set_active_repo_inline_impl( let now = inputs.now(); let previous_active = state.active_repo; let changed = previous_active != Some(repo_id); + let reuse_selected_diff = repo_switch_reuses_selected_diff(state, repo_id, unchanged); if changed { // The inactive tab may have observed a HEAD move while no monitor was // attached to it. Refresh its retained path before capturing the diff // reload plan below. - super::refresh_selected_head_gitlink(inputs, state, repo_id); + if !reuse_selected_diff { + super::refresh_selected_head_gitlink(inputs, state, repo_id); + } // Leaving cancels whatever is in flight, but keeps the values it was // about to replace. if let Some(previous) = state @@ -950,22 +976,23 @@ fn fill_set_active_repo_inline_impl( // selection below. Commit file metadata distinguishes submodules and // added/deleted preview-only files, but the selection reset deliberately // clears that metadata from the details pane. - let selected_diff_reload = if changed && matches!(repo_state.open, Loadable::Ready(())) { - repo_state.diff_state.diff_target.as_ref().map(|target| { - if let Some(conflict_target) = selected_conflict_target(repo_state, target) { - match conflict_target { - SelectedConflictTarget::Current => SelectedDiffReload::ConflictCurrent, - SelectedConflictTarget::Path(path) => { - SelectedDiffReload::Conflict(path.to_path_buf()) + let selected_diff_reload = + if changed && !reuse_selected_diff && matches!(repo_state.open, Loadable::Ready(())) { + repo_state.diff_state.diff_target.as_ref().map(|target| { + if let Some(conflict_target) = selected_conflict_target(repo_state, target) { + match conflict_target { + SelectedConflictTarget::Current => SelectedDiffReload::ConflictCurrent, + SelectedConflictTarget::Path(path) => { + SelectedDiffReload::Conflict(path.to_path_buf()) + } } + } else { + SelectedDiffReload::Diff(selected_diff_load_plan(inputs, repo_state, target)) } - } else { - SelectedDiffReload::Diff(selected_diff_load_plan(inputs, repo_state, target)) - } - }) - } else { - None - }; + }) + } else { + None + }; if changed { // A repository tab is a workspace context, not a suspended history @@ -1009,13 +1036,6 @@ fn fill_set_active_repo_inline_impl( && !reuse_retained && !repo_switch_can_use_primary_refresh(repo_state, git_log_settings, now); repo_state.last_active_at = Some(now); - let selected_diff_reload = selected_diff_reload.filter(|reload| { - !(reuse_retained - && matches!(reload, SelectedDiffReload::Diff(_)) - && matches!(repo_state.diff_state.diff, Loadable::Ready(_)) - && !matches!(repo_state.diff_state.diff_file, Loadable::Loading)) - }); - let selected_history_reloads = if changed { selected_history_reloads_for_activation(repo_state) } else { diff --git a/crates/gitcomet-state/src/store/tests/prepared_inputs.rs b/crates/gitcomet-state/src/store/tests/prepared_inputs.rs index 5ecb16b23..bab60e920 100644 --- a/crates/gitcomet-state/src/store/tests/prepared_inputs.rs +++ b/crates/gitcomet-state/src/store/tests/prepared_inputs.rs @@ -302,6 +302,182 @@ fn selected_status(kind: FileStatusKind) -> Vec { }] } +fn prepare_retained_selection(state: &mut AppState, target: &DiffTarget) { + let repo = &mut state.repos[0]; + repo.head_branch = Loadable::Ready("main".into()); + repo.upstream_divergence = Loadable::Ready(None); + repo.branches = Loadable::Ready(Arc::default()); + repo.tags = Loadable::Ready(Arc::default()); + repo.remotes = Loadable::Ready(Arc::default()); + repo.remote_branches = Loadable::Ready(Arc::default()); + repo.stashes = Loadable::Ready(Arc::default()); + repo.rebase_in_progress = Loadable::Ready(false); + repo.merge_commit_message = Loadable::Ready(None); + repo.worktrees = Loadable::Ready(Arc::default()); + repo.set_log(Loadable::Ready(Arc::new(LogPage { + commits: Vec::new(), + next_cursor: None, + }))); + repo.diff_state.diff_target = Some(target.clone()); + repo.diff_state.diff = Loadable::Ready(Arc::new(gitcomet_core::domain::Diff { + target: target.clone(), + lines: Vec::new(), + })); + repo.diff_state.diff_file = Loadable::Ready(None); + let mut other = RepoState::new_opening(RepoId(2), repo.spec.clone()); + other.set_open(Loadable::Ready(())); + state.repos.push(other); + state.active_repo = Some(RepoId(2)); +} + +#[test] +fn unchanged_tab_switch_skips_retained_selection_inputs() { + use crate::msg::{PersistenceEffect, RepoCommandEffect}; + use crate::store::reducer::prepared::PreparedReduction; + + let directory = tempfile::tempdir().unwrap(); + for kind in [FileStatusKind::Deleted, FileStatusKind::Modified] { + let (mut state, repos, target, queries) = + selected_fixture(directory.path(), Some(selected_status(kind)), Ok(true)); + prepare_retained_selection(&mut state, &target); + state.maintenance_settings.recommend = true; + state.repos[0].common_dir = Some(Arc::from(directory.path().join(".git"))); + let retained = state.repos[0].diff_state.diff.clone(); + let shared = std::sync::RwLock::new(Arc::new(state)); + + let prepared = PreparedReduction::for_repo_switch(RepoId(1), &shared, &repos, true); + assert_eq!(prepared.selected_input_counts(), (0, 0)); + assert!(queries.try_iter().next().is_none()); + let (_, inputs) = prepared.into_parts(); + let mut state = shared.into_inner().unwrap(); + let state = Arc::make_mut(&mut state); + let mut effects = reducer::SetActiveRepoEffects::new(); + reducer::fill_set_active_repo_inline(&inputs, state, RepoId(1), true, &mut effects); + + assert_eq!(state.active_repo, Some(RepoId(1))); + assert_eq!( + state.repos[0].diff_state.diff_target.as_ref(), + Some(&target) + ); + assert_eq!(state.repos[0].diff_state.diff, retained); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::LoadSelectedDiff { .. })) + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::LoadWorktreeDirty { .. })) + ); + assert!(effects.iter().any(|effect| matches!( + effect, + Effect::Persistence(PersistenceEffect::PersistSession { .. }) + ))); + assert!(effects.iter().any(|effect| matches!( + effect, + Effect::RepoCommand(RepoCommandEffect::CheckRepoMaintenance { repo_id: RepoId(1) }) + ))); + } +} + +#[test] +fn changed_or_incomplete_tab_switch_prepares_retained_selection_inputs() { + use crate::model::LoadKind; + use crate::store::reducer::prepared::PreparedReduction; + + let directory = tempfile::tempdir().unwrap(); + fs::create_dir_all(directory.path().join("vendor/submodule")).unwrap(); + fs::write( + directory.path().join("vendor/submodule/.git"), + b"gitdir: ../../.git/modules/submodule\n", + ) + .unwrap(); + type Invalidate = fn(&mut RepoState); + let cases: [(&str, bool, Invalidate); 8] = [ + ("changed", false, |_| {}), + ("missing status", true, |repo| { + repo.set_status(Loadable::NotLoaded) + }), + ("loading history", true, |repo| { + repo.set_log(Loadable::Loading) + }), + ("missing diff", true, |repo| { + repo.diff_state.diff = Loadable::NotLoaded + }), + ("loading file", true, |repo| { + repo.diff_state.diff_file = Loadable::Loading + }), + ("cancelled refresh", true, |repo| { + repo.left_mid_refresh = true + }), + ("refresh in flight", true, |repo| { + repo.loads_in_flight.request(LoadKind::WorktreeStatus); + }), + ("conflict", true, |repo| { + repo.conflict_state.conflict_file_path = Some(PathBuf::from("vendor/submodule")); + }), + ]; + for (case, unchanged, invalidate) in cases { + for kind in [FileStatusKind::Deleted, FileStatusKind::Modified] { + let (mut state, repos, target, queries) = + selected_fixture(directory.path(), Some(selected_status(kind)), Ok(true)); + prepare_retained_selection(&mut state, &target); + invalidate(&mut state.repos[0]); + let expected_head = + usize::from(kind == FileStatusKind::Deleted || case == "missing status"); + let shared = std::sync::RwLock::new(Arc::new(state)); + let prepared = + PreparedReduction::for_repo_switch(RepoId(1), &shared, &repos, unchanged); + assert_eq!( + prepared.selected_input_counts(), + ( + expected_head, + usize::from( + kind == FileStatusKind::Modified + && case != "missing status" + && case != "conflict" + ) + ), + "{case}" + ); + assert_eq!(queries.try_iter().count(), expected_head, "{case}"); + let (_, inputs) = prepared.into_parts(); + let mut state = shared.into_inner().unwrap(); + let state = Arc::make_mut(&mut state); + let mut effects = reducer::SetActiveRepoEffects::new(); + reducer::fill_set_active_repo_inline( + &inputs, + state, + RepoId(1), + unchanged, + &mut effects, + ); + + assert_eq!(state.active_repo, Some(RepoId(1))); + if case == "conflict" { + assert!( + effects.iter().any(|effect| matches!( + effect, + Effect::LoadSelectedConflictFile { + repo_id: RepoId(1), + .. + } + )), + "{case}: {effects:?}" + ); + } else { + assert!( + effects.iter().any(|effect| matches!(effect, + Effect::LoadSelectedDiff { load_submodule_summary, .. } + if *load_submodule_summary == (case != "missing status"))), + "{case}: {effects:?}" + ); + } + } + } +} + #[test] fn checked_activation_refresh_keeps_selected_path_inputs() { use crate::msg::RepoExternalChange; From 8acbe0fd1070bdd0014e616b804a29823fa7d910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 19:00:36 +0300 Subject: [PATCH 11/67] Skip empty linked-worktree scans on unchanged activation --- crates/gitcomet-state/src/store/mod.rs | 9 +- .../src/store/repo_monitor/activation.rs | 118 +++++++++++++++++- .../src/store/repo_monitor/monitor.rs | 14 +-- .../tests/selective/efficiency.rs | 4 +- .../src/store/tests/repo_monitor.rs | 104 +++++++++++++++ 5 files changed, 227 insertions(+), 22 deletions(-) diff --git a/crates/gitcomet-state/src/store/mod.rs b/crates/gitcomet-state/src/store/mod.rs index dff3303de..3bb0404e4 100644 --- a/crates/gitcomet-state/src/store/mod.rs +++ b/crates/gitcomet-state/src/store/mod.rs @@ -763,10 +763,15 @@ impl AppStore { let Some(repo) = state.repos.iter().find(|repo| repo.id == repo_id) else { continue; }; - let Some(change) = refresh.into_change(repo) else { + let Some(refresh) = refresh.into_refresh(repo) else { continue; }; - (Msg::RepoExternallyChanged { repo_id, change }, None) + if refresh.refresh_worktree_dirty && state.active_repo == Some(repo_id) { + deferred_commands.push_front(StoreWorkerCommand::Msg(Box::new( + Msg::LoadWorktreeDirty { repo_id }, + ))); + } + (Msg::RepoExternallyChanged { repo_id, change: refresh.change }, None) } StoreWorkerCommand::Shutdown => break, StoreWorkerCommand::Repository { diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index c5230caaa..1f75ae8c6 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -85,24 +85,47 @@ pub(in crate::store) struct CheckedActivation { pub change: RepoExternalChange, } +pub(in crate::store) struct ActivationDecision { + pub change: RepoExternalChange, + pub refresh_worktree_dirty: bool, +} + impl CheckedActivation { /// Loads can finish while the monitor is inspecting the filesystem. Only /// the store can decide whether its current view still matches the input. - pub(in crate::store) fn into_change( + pub(in crate::store) fn into_refresh( self, repo: &crate::model::RepoState, - ) -> Option { + ) -> Option { if repo.lifetime() != self.context.lifetime { return None; } - Some(if self.context == ActivationContext::for_repo(repo) { + let change = if self.context == ActivationContext::for_repo(repo) { self.change } else { self.change.union(RepoExternalChange::all()) + }; + Some(ActivationDecision { + // Full refreshes already request this scan. For a checked, + // unchanged single checkout, its last successful empty result is + // still complete: there is no other worktree to inspect. Linked + // checkouts must still scan working files that native events miss. + refresh_worktree_dirty: !change.git_state && !settled_single_checkout(repo), + change, }) } } +fn settled_single_checkout(repo: &crate::model::RepoState) -> bool { + use crate::model::Loadable; + matches!(repo.open, Loadable::Ready(())) + && matches!(&repo.worktrees, Loadable::Ready(worktrees) + if worktrees.len() == 1 && worktrees[0].path == repo.spec.workdir) + && matches!(&repo.worktree_dirty, Loadable::Ready(dirty) if dirty.is_empty()) + && !repo.loads_in_flight.any_in_flight() + && repo.local_actions_in_flight == 0 +} + impl ActivationRefresh { pub(super) fn prepare( workdir: &Path, @@ -676,6 +699,8 @@ mod tests { let moved = ActivationRefresh::prepare(dir.path(), &inputs); assert!(refresh.needs_discovery(moved.as_ref())); assert!(refresh.change(dir.path(), &backend).git_state); + fs::remove_dir_all(&linked).unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); fs::create_dir_all(dir.path().join(".git/worktrees/new")).unwrap(); let current = ActivationRefresh::prepare(dir.path(), &inputs); assert!(refresh.needs_discovery(current.as_ref())); @@ -810,18 +835,99 @@ mod tests { ..RepoExternalChange::all() }, }; - assert!(!checked().into_change(&repo).unwrap().git_state); + assert!(!checked().into_refresh(&repo).unwrap().change.git_state); // The UI accepted a different history result while metadata was read. let pending = checked(); repo.log_rev += 1; - assert!(pending.into_change(&repo).unwrap().git_state); + assert!(pending.into_refresh(&repo).unwrap().change.git_state); let reopened = crate::model::RepoState::new_opening(repo.id, repo.spec.clone()); let pending = CheckedActivation { repo_id: repo.id, context, change: RepoExternalChange::all(), }; - assert!(pending.into_change(&reopened).is_none()); + assert!(pending.into_refresh(&reopened).is_none()); + } + + #[test] + fn activation_only_skips_a_settled_empty_single_checkout_scan() { + use crate::model::{LoadKind, Loadable, RepoState}; + use gitcomet_core::domain::{RepoSpec, Worktree, WorktreeDirtySummary}; + + let make_repo = || { + let mut repo = RepoState::new_opening( + RepoId(1), + RepoSpec { + workdir: PathBuf::from("repo"), + }, + ); + repo.set_open(Loadable::Ready(())); + repo.set_worktrees(Loadable::Ready(vec![Worktree { + path: repo.spec.workdir.clone(), + head: None, + branch: Some("main".into()), + detached: false, + }])); + repo.set_worktree_dirty(Loadable::Ready(Vec::new())); + repo + }; + let decide = |repo: &RepoState| { + CheckedActivation { + repo_id: repo.id, + context: ActivationContext::for_repo(repo), + change: RepoExternalChange { + git_state: false, + tags: false, + ..RepoExternalChange::all() + }, + } + .into_refresh(repo) + .unwrap() + }; + assert!(!decide(&make_repo()).refresh_worktree_dirty); + type Invalidate = fn(&mut RepoState); + let cases: [(&str, Invalidate); 8] = [ + ("unknown list", |repo| { + repo.set_worktrees(Loadable::NotLoaded) + }), + ("failed list", |repo| { + repo.set_worktrees(Loadable::Error("unavailable".into())) + }), + ("unknown dirty scan", |repo| { + repo.set_worktree_dirty(Loadable::NotLoaded) + }), + ("failed dirty scan", |repo| { + repo.set_worktree_dirty(Loadable::Error("unavailable".into())) + }), + ("retained dirty rows", |repo| { + repo.set_worktree_dirty(Loadable::Ready(vec![WorktreeDirtySummary::default()])) + }), + ("list in flight", |repo| { + repo.loads_in_flight.request(LoadKind::Worktrees); + }), + ("scan in flight", |repo| { + repo.loads_in_flight + .request_worktree_dirty(crate::model::WorktreeDirtyScope::All); + }), + ("local action", |repo| repo.local_actions_in_flight = 1), + ]; + for (case, invalidate) in cases { + let mut repo = make_repo(); + invalidate(&mut repo); + assert!(decide(&repo).refresh_worktree_dirty, "{case}"); + } + let mut repo = make_repo(); + let mut list = repo.worktrees.ready().unwrap().as_ref().clone(); + let mut linked = list[0].clone(); + linked.path = PathBuf::from("linked"); + list.push(linked.clone()); + repo.set_worktrees(Loadable::Ready(list)); + assert!(decide(&repo).refresh_worktree_dirty, "linked checkout"); + repo.set_worktrees(Loadable::Ready(vec![linked])); + assert!( + decide(&repo).refresh_worktree_dirty, + "sole path is not the current checkout" + ); } #[test] diff --git a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs index 3dbc335fa..c5d465ea1 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs @@ -975,18 +975,8 @@ pub(super) fn repo_monitor_thread( change.git_state, change.tags, ); - // Other checkouts can have working-file edits without any - // changed Git metadata. Keep their background status fallback - // when the current repository's history refresh is skipped. - if !change.git_state - && worktree_owner.is_none() - && active_repo_id.load(Ordering::SeqCst) == repo_id.0 - { - msg_tx.send_repo_monitor_or_log( - Msg::LoadWorktreeDirty { repo_id }, - "repo activation worktree refresh", - ); - } + // The store validates this reply against current state before + // deciding whether linked checkouts need a dirty scan. due_change = Some( due_change.map_or(change.clone(), |pending| merge_change(pending, change)), ); diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs index 16bcc415b..f954761ed 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/efficiency.rs @@ -422,8 +422,8 @@ fn activation_checks_missed_changes_without_reloading_unchanged_git_metadata() { assert!(!unchanged.git_state && !unchanged.tags); assert!(unchanged.worktree && unchanged.index); assert!( - worktree_scan, - "other checkouts still need status revalidation" + !worktree_scan, + "the store decides about linked scans after validating this reply" ); fs::write(root.join("source.txt"), "unseen working edit").unwrap(); assert!(!activate().0.git_state); diff --git a/crates/gitcomet-state/src/store/tests/repo_monitor.rs b/crates/gitcomet-state/src/store/tests/repo_monitor.rs index 27e3ab7f0..3d2198c07 100644 --- a/crates/gitcomet-state/src/store/tests/repo_monitor.rs +++ b/crates/gitcomet-state/src/store/tests/repo_monitor.rs @@ -540,6 +540,110 @@ fn repo_activation_reuses_one_worktree_listing_and_file_edits_add_none() { ); } +#[test] +fn checked_activation_skips_only_the_settled_single_checkout_listing() { + use crate::store::repo_monitor::{ActivationContext, CheckedActivation}; + use gitcomet_core::domain::Worktree; + + for (case, expected_lists) in [ + ("unchanged", 0), + ("linked checkout", 1), + ("unknown list", 1), + ("failed list", 1), + ("unknown dirty scan", 1), + ("failed dirty scan", 1), + ("stale context", 1), + ("explicit refresh", 1), + ] { + let dir = tempfile::tempdir().unwrap(); + let workdir = canonicalize_or_original(dir.path().to_path_buf()); + let repo_id = RepoId(24); + let calls = Arc::new(RepoActivationCallCounts::default()); + let mut state = active_ready_repo_state(repo_id, workdir.clone()); + let repo = &mut state.repos[0]; + repo.set_head_branch(Loadable::Ready("main".into())); + repo.set_log(Loadable::Ready(Arc::new(LogPage { + commits: Vec::new(), + next_cursor: None, + }))); + repo.set_branches(Loadable::Ready(Vec::new())); + repo.set_remote_branches(Loadable::Ready(Vec::new())); + repo.set_tags(Loadable::Ready(Vec::new())); + repo.upstream_divergence = Loadable::Ready(None); + repo.sequencer_state = Loadable::Ready(Default::default()); + repo.merge_commit_message = Loadable::Ready(None); + let own = Worktree { + path: workdir.clone(), + head: None, + branch: Some("main".into()), + detached: false, + }; + repo.set_worktrees(Loadable::Ready(vec![own.clone()])); + repo.set_worktree_dirty(Loadable::Ready(Vec::new())); + match case { + "linked checkout" => repo.set_worktrees(Loadable::Ready(vec![ + own, + Worktree { + path: workdir.join("linked"), + head: None, + branch: Some("linked".into()), + detached: false, + }, + ])), + "unknown list" => repo.set_worktrees(Loadable::NotLoaded), + "failed list" => repo.set_worktrees(Loadable::Error("unavailable".into())), + "unknown dirty scan" => repo.set_worktree_dirty(Loadable::NotLoaded), + "failed dirty scan" => repo.set_worktree_dirty(Loadable::Error("unavailable".into())), + _ => {} + } + let checked = CheckedActivation { + repo_id, + context: ActivationContext::for_repo(repo), + change: RepoExternalChange { + git_state: false, + tags: false, + ..RepoExternalChange::all() + }, + }; + if case == "stale context" { + repo.log_rev += 1; + } + let (store, _events) = AppStore::new_test(Arc::new(FailingBackend)); + store.disable_repo_monitors_for_test(); + store.replace_snapshot_for_test(Arc::new(state)); + store.insert_repo_for_test( + repo_id, + Arc::new(RepoActivationRecordingRepo::new(workdir, calls.clone())), + ); + if case == "explicit refresh" { + store.dispatch(Msg::LoadWorktreeDirty { repo_id }); + } else { + store.msg_tx.send_activation_refresh_or_log(checked); + } + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + super::worktree_redirect::wait_until(case, || { + !store.snapshot().repos[0].loads_in_flight.any_in_flight() + }); + store + .shutdown_for_test() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + assert_eq!( + calls.worktree_lists.load(Ordering::Relaxed), + expected_lists, + "{case}" + ); + assert_eq!( + calls.log.load(Ordering::Relaxed) > 0, + case == "stale context", + "{case}" + ); + } +} + #[test] fn repo_monitor_stop_send_failures_are_recorded() { let before = monitor_impl::monitor_failure_count(monitor_impl::MonitorFailureKind::Stop); From f63fd1639688b80b303529db186469aa12b38b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 19:06:11 +0300 Subject: [PATCH 12/67] Add benchmark-only native status stage profiling --- .../examples/repository-load-probe.rs | 38 +++++- crates/gitcomet-git-gix/src/lib.rs | 3 + crates/gitcomet-git-gix/src/repo/status.rs | 62 ++++++++- crates/gitcomet-git-gix/src/status_trace.rs | 123 ++++++++++++++++++ 4 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 crates/gitcomet-git-gix/src/status_trace.rs diff --git a/crates/gitcomet-git-gix/examples/repository-load-probe.rs b/crates/gitcomet-git-gix/examples/repository-load-probe.rs index 1032e6b64..724d3e790 100644 --- a/crates/gitcomet-git-gix/examples/repository-load-probe.rs +++ b/crates/gitcomet-git-gix/examples/repository-load-probe.rs @@ -28,7 +28,8 @@ fn measure( ) -> Option { emit(json!({"event": "start", "iteration": iteration, "stage": stage})); let start = Instant::now(); - let (result, commands) = gitcomet_git_gix::command_trace::capture(run); + let ((result, native_status), commands) = + gitcomet_git_gix::command_trace::capture(|| gitcomet_git_gix::status_trace::capture(run)); let elapsed = start.elapsed(); let commands: Vec<_> = commands .iter() @@ -42,6 +43,37 @@ fn measure( }) }) .collect(); + let native_status = json!({ + "stages": native_status.stages.iter().map(|stage| { + json!({"stage": stage.label, "milliseconds": stage.elapsed.as_secs_f64() * 1000.0}) + }).collect::>(), + "walks": native_status.walks.iter().map(|walk| { + let tracked = &walk.outcome.tracked_file_modification; + json!({ + "windows_metadata_cache": cfg!(windows) && walk.filesystem_cache, + "thread_limit": walk.thread_limit, + "entries_to_process": tracked.entries_to_process, + "entries_processed": tracked.entries_processed, + "skipped_by_common_prefix": tracked.entries_skipped_by_common_prefix, + "skipped_by_pathspec": tracked.entries_skipped_by_pathspec, + "skipped_by_flags": tracked.entries_skipped_by_entry_flags, + "live_metadata_calls": tracked.symlink_metadata_calls, + "entries_to_update": tracked.entries_to_update, + "racy_clean": tracked.racy_clean, + "worktree_bytes": tracked.worktree_bytes, + "worktree_files_read": tracked.worktree_files_read, + "odb_bytes": tracked.odb_bytes, + "odb_objects_read": tracked.odb_objects_read, + "untracked_walk": walk.outcome.dirwalk.as_ref().map(|walk| { + json!({ + "read_dir_calls": walk.read_dir_calls, + "seen_entries": walk.seen_entries, + "returned_entries": walk.returned_entries, + }) + }), + }) + }).collect::>(), + }); let (witness, error) = match &result { Ok(value) => (describe(value), None), Err(error) => (Value::Null, Some(error.to_string())), @@ -50,6 +82,7 @@ fn measure( "event": "finish", "iteration": iteration, "stage": stage, "milliseconds": elapsed.as_secs_f64() * 1000.0, "witness": witness, "error": error, "commands": commands, + "native_status": native_status, })); result.ok() } @@ -81,6 +114,9 @@ fn main() { "event": "configuration", "debug_assertions": cfg!(debug_assertions), "refreshes": refreshes, "history_mode": format!("{mode:?}"), "remote_tags": remote_tags, "execution": "sequential_backend_stages", + "os": std::env::consts::OS, "arch": std::env::consts::ARCH, + "available_parallelism": std::thread::available_parallelism().ok().map(usize::from), + "native_status_profiling": true, })); let Some(repo) = measure(0, "open", || GixBackend.open(&root), |_| json!("opened")) else { std::process::exit(1); diff --git a/crates/gitcomet-git-gix/src/lib.rs b/crates/gitcomet-git-gix/src/lib.rs index 903aa5a7a..c513c1158 100644 --- a/crates/gitcomet-git-gix/src/lib.rs +++ b/crates/gitcomet-git-gix/src/lib.rs @@ -5,6 +5,9 @@ mod ignore; mod open; mod refs; mod repo; +#[cfg(feature = "benchmarks")] +#[doc(hidden)] +pub mod status_trace; #[cfg(any(test, feature = "test-support"))] mod test_support; mod util; diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs index 0ecd9dfb9..296263575 100644 --- a/crates/gitcomet-git-gix/src/repo/status.rs +++ b/crates/gitcomet-git-gix/src/repo/status.rs @@ -84,21 +84,31 @@ impl GixRepo { cancellation: &CancellationToken, ) -> Result { cancellation.check_cancelled()?; + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("repository-config"); let repo = self.status_repo(); + #[cfg(feature = "benchmarks")] + drop(timing); // Bare repositories can display history, but have no checkout whose // changes we can report. Do not walk the Git directory or compare HEAD // against a missing index (which would report every file as deleted). if repo.workdir().is_none() { return Ok(RepoStatus::default()); } + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("index-fingerprint-gitlinks"); let index_stamp = repo_index_stamp(&repo); let may_have_gitlinks = self.may_have_gitlink_status_supplement(&repo, &index_stamp); cancellation.check_cancelled()?; + #[cfg(feature = "benchmarks")] + drop(timing); // Check whether HEAD and the index file are unchanged since the last // status call. When both match, the staged (Tree→Index) result is // identical and we can skip the tree comparison entirely, using the // cheaper index-worktree-only iterator. + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("staged-status"); let head_oid = super::history::gix_head_id_or_none(&repo)?; cancellation.check_cancelled()?; @@ -121,6 +131,8 @@ impl GixRepo { collect_staged_status_from_tree_index(&repo, &tree)? }; cancellation.check_cancelled()?; + #[cfg(feature = "benchmarks")] + drop(timing); // Use the same direct worktree walk on the first scan as on cached // scans. The generic status iterator keeps its stat updates private // and can only persist them to disk; status must remain read-only. @@ -135,6 +147,8 @@ impl GixRepo { )?; cancellation.check_cancelled()?; + #[cfg(feature = "benchmarks")] + let _timing = crate::status_trace::StageTimer::new("finalize-and-gitlink-supplement"); if !used_cached_staged { *self .tree_index_cache @@ -696,6 +710,8 @@ fn collect_index_worktree_status_direct( unstaged: &mut Vec, cancellation: &CancellationToken, ) -> Result { + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("index-snapshot"); let index = repo .index_or_empty() .map_err(|e| Error::new(ErrorKind::Backend(format!("gix index: {e}"))))?; @@ -713,6 +729,8 @@ fn collect_index_worktree_status_direct( #[cfg(test)] let walk_started = tests::STAT_REFRESH_CLOCK.with(|clock| clock.get().unwrap_or(walk_started)); let stamp = filetime::FileTime::from_unix_time(walk_started.unix_seconds() - 1, 0); + #[cfg(feature = "benchmarks")] + drop(timing); let result = collect_index_worktree_status_direct_from_index( repo, refreshed @@ -737,6 +755,8 @@ fn collect_index_worktree_status_direct( return Err(error); } }; + #[cfg(feature = "benchmarks")] + let _timing = crate::status_trace::StageTimer::new("retain-index-stats"); let refreshed = apply_stat_refresh(refreshed, &index, changes, stamp); *stat_refresh .lock() @@ -803,6 +823,8 @@ fn collect_index_worktree_status_direct_from_index( unstaged: &mut Vec, cancellation: &CancellationToken, ) -> Result<(DirectIndexWorktreeStatus, Vec)> { + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("walk-options"); let dirwalk_options = repo .dirwalk_options() .map_err(|e| { @@ -811,6 +833,8 @@ fn collect_index_worktree_status_direct_from_index( ))) })? .emit_untracked(gix::dir::walk::EmissionMode::Matching); + #[cfg(feature = "benchmarks")] + drop(timing); // Git supplies child status below. The previous built-in provider used // Ignore::All, which also always returns None but still loads .gitmodules // and inspects each child's repository layout before ignoring the result. @@ -868,6 +892,8 @@ where > + Send + Clone, { + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("attributes-and-excludes"); let workdir = repo .workdir() .ok_or_else(|| Error::new(ErrorKind::Backend("gix status missing workdir".into())))?; @@ -879,6 +905,10 @@ where None, ) .map_err(|e| Error::new(ErrorKind::Backend(format!("gix status attributes: {e}"))))?; + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("pathspec-realpath-fs-options"); let (pathspec, _pathspec_attr_stack) = gix::Pathspec::new( repo, false, @@ -904,7 +934,15 @@ where .filesystem_options() .map_err(|e| Error::new(ErrorKind::Backend(format!("gix status fs options: {e}"))))?; let fscache = filesystem_cache_enabled(repo); + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("case-insensitive-index"); let accelerate_lookup = fs_caps.ignore_case.then(|| index.prepare_icase_backing()); + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("resource-cache"); let resource_cache = gix::diff::resource_cache( repo, gix::diff::blob::pipeline::Mode::ToGit, @@ -919,10 +957,23 @@ where "gix status resource cache: {e}" ))) })?; + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("cancellation-watcher"); let mut collector = StatusEntryCollector::new(unstaged); let interrupt = StatusCancellation::new(cancellation)?; let mut progress = cancellation::progress(&interrupt.interrupt); - gix::status::plumbing::index_as_worktree_with_renames( + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("worker-policy"); + let thread_limit = worker_limit::for_index(repo, index, cancellation)?; + #[cfg(feature = "benchmarks")] + drop(timing); + #[cfg(feature = "benchmarks")] + let timing = crate::status_trace::StageTimer::new("native-walk"); + let outcome = gix::status::plumbing::index_as_worktree_with_renames( index, workdir, &mut collector, @@ -948,7 +999,7 @@ where fscache, tracked_file_modifications: gix::status::plumbing::index_as_worktree::Options { fs: fs_caps, - thread_limit: worker_limit::for_index(repo, index, cancellation)?, + thread_limit, fscache, stat: repo.stat_options().map_err(|e| { Error::new(ErrorKind::Backend(format!("gix status stat options: {e}"))) @@ -967,6 +1018,13 @@ where ))) } })?; + #[cfg(feature = "benchmarks")] + { + drop(timing); + crate::status_trace::record_walk(&outcome, fscache, thread_limit); + } + #[cfg(not(feature = "benchmarks"))] + let _ = outcome; cancellation.check_cancelled()?; collector.finish() diff --git a/crates/gitcomet-git-gix/src/status_trace.rs b/crates/gitcomet-git-gix/src/status_trace.rs new file mode 100644 index 000000000..4f245dc62 --- /dev/null +++ b/crates/gitcomet-git-gix/src/status_trace.rs @@ -0,0 +1,123 @@ +//! Benchmark-only diagnostics for native status work on the calling thread. +//! Stages are disjoint wall-time regions; the native walk includes concurrent +//! tracked-file checks and untracked traversal, whose times cannot be added. + +use std::{cell::RefCell, time::Duration, time::Instant}; + +#[derive(Debug)] +pub struct StageTiming { + pub label: &'static str, + pub elapsed: Duration, +} + +#[derive(Debug)] +pub struct WalkCounters { + pub outcome: gix::status::plumbing::index_as_worktree_with_renames::Outcome, + pub filesystem_cache: bool, + pub thread_limit: Option, +} + +#[derive(Debug, Default)] +pub struct StatusTrace { + pub stages: Vec, + pub walks: Vec, +} + +thread_local! { + static RECORDS: RefCell> = const { RefCell::new(None) }; +} + +/// Capture only work on this thread. Nested captures and unwinding restore +/// the previous capture; gix worker counters are collected after they join. +pub fn capture(run: impl FnOnce() -> T) -> (T, StatusTrace) { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + RECORDS.with(|records| *records.borrow_mut() = self.0.take()); + } + } + let restore = Restore(RECORDS.with(|records| records.replace(Some(StatusTrace::default())))); + let value = run(); + let records = RECORDS.with(|records| records.borrow_mut().take().unwrap()); + drop(restore); + (value, records) +} + +pub(crate) struct StageTimer { + label: &'static str, + started: Option, +} + +impl StageTimer { + pub(crate) fn new(label: &'static str) -> Self { + Self { + label, + started: RECORDS + .with(|records| records.borrow().is_some()) + .then(Instant::now), + } + } +} + +impl Drop for StageTimer { + fn drop(&mut self) { + if let Some(started) = self.started { + let elapsed = started.elapsed(); + RECORDS.with(|records| { + if let Some(records) = records.borrow_mut().as_mut() { + records.stages.push(StageTiming { + label: self.label, + elapsed, + }); + } + }); + } + } +} + +pub(crate) fn record_walk( + outcome: &gix::status::plumbing::index_as_worktree_with_renames::Outcome, + filesystem_cache: bool, + thread_limit: Option, +) { + RECORDS.with(|records| { + if let Some(records) = records.borrow_mut().as_mut() { + records.walks.push(WalkCounters { + outcome: outcome.clone(), + filesystem_cache, + thread_limit, + }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_captures_unwinding_and_threads_keep_separate_records() { + let (_, outer) = capture(|| { + drop(StageTimer::new("outer")); + let (_, inner) = capture(|| { + drop(StageTimer::new("inner")); + std::thread::spawn(|| drop(StageTimer::new("other-thread"))) + .join() + .unwrap(); + }); + assert_eq!(inner.stages.len(), 1); + assert_eq!(inner.stages[0].label, "inner"); + assert!(std::panic::catch_unwind(|| capture(|| panic!("probe failure"))).is_err()); + drop(StageTimer::new("after-unwind")); + }); + assert_eq!( + outer + .stages + .iter() + .map(|stage| stage.label) + .collect::>(), + ["outer", "after-unwind"] + ); + assert!(StageTimer::new("disabled").started.is_none()); + } +} From d8e213a98b3a4207bcdd7bdab1e34f237cca3614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 19:16:49 +0300 Subject: [PATCH 13/67] Match Windows repository paths in profiling witnesses --- .../src/view/scenario_driver.rs | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/scenario_driver.rs b/crates/gitcomet-ui-gpui/src/view/scenario_driver.rs index d003f8e66..8f1e02641 100644 --- a/crates/gitcomet-ui-gpui/src/view/scenario_driver.rs +++ b/crates/gitcomet-ui-gpui/src/view/scenario_driver.rs @@ -1110,7 +1110,7 @@ impl Driver { .state .repos .iter() - .any(|repo| repo.spec.workdir == *path); + .any(|repo| repo_witness_paths_match(&repo.spec.workdir, path)); } let Some(repo) = view.active_repo() else { return false; @@ -1150,7 +1150,7 @@ impl Driver { } WitnessKind::RepoClosed { .. } => unreachable!("answered above"), WitnessKind::RepoOpen { path } => { - repo.spec.workdir == *path + repo_witness_paths_match(&repo.spec.workdir, path) && matches!(repo.open, Loadable::Ready(())) && matches!(repo.status, Loadable::Ready(_)) && matches!(repo.history_state.log, Loadable::Ready(_)) @@ -1534,6 +1534,15 @@ fn canonical(path: &std::path::Path) -> Result { std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display())) } +fn repo_witness_paths_match(actual: &std::path::Path, expected: &std::path::Path) -> bool { + // Windows canonicalization adds a verbatim prefix that the backend strips. + // Normalize only for comparison: keep the original paths for filesystem IO, + // including UNC paths and paths longer than MAX_PATH. + actual == expected + || gitcomet_core::path_utils::strip_windows_verbatim_prefix(actual.to_path_buf()) + == gitcomet_core::path_utils::strip_windows_verbatim_prefix(expected.to_path_buf()) +} + fn write_fixture_files( originals: &[(PathBuf, Option>)], contents: Option<&str>, @@ -1562,6 +1571,54 @@ fn write_fixture_files( mod tests { use super::*; + #[cfg(windows)] + #[test] + fn repo_witness_paths_match_windows_canonical_disk_paths() { + let dir = tempfile::tempdir().expect("temporary repository path"); + let expected = canonical(dir.path()).expect("canonical repository path"); + let actual = gitcomet_core::path_utils::strip_windows_verbatim_prefix(expected.clone()); + assert_ne!( + actual, expected, + "exercise the backend/canonical prefix mismatch" + ); + assert!(repo_witness_paths_match(&actual, &expected)); + assert!(repo_witness_paths_match(&expected, &actual)); + assert!(!repo_witness_paths_match(&actual.join("other"), &expected)); + } + + #[cfg(windows)] + #[test] + fn repo_witness_paths_match_windows_unc_paths() { + let actual = std::path::Path::new(r"\\server\share\repo"); + let expected = std::path::Path::new(r"\\?\UNC\server\share\repo"); + assert!(repo_witness_paths_match(actual, expected)); + assert!(repo_witness_paths_match(expected, actual)); + assert!(!repo_witness_paths_match( + std::path::Path::new(r"\\server\other-share\repo"), + expected, + )); + assert!(!repo_witness_paths_match( + std::path::Path::new(r"\\other-server\share\repo"), + expected, + )); + } + + #[cfg(windows)] + #[test] + fn repo_witness_paths_match_windows_long_paths_without_filesystem_access() { + let relative = "long-component\\".repeat(24) + "repo"; + for (ordinary_root, verbatim_root) in [ + (r"C:\", r"\\?\C:\"), + (r"\\server\share\", r"\\?\UNC\server\share\"), + ] { + let actual = PathBuf::from(format!("{ordinary_root}{relative}")); + let expected = PathBuf::from(format!("{verbatim_root}{relative}")); + assert!(actual.as_os_str().len() > 260); + assert!(repo_witness_paths_match(&actual, &expected)); + assert!(!repo_witness_paths_match(&actual.join("other"), &expected)); + } + } + /// Every step shape scripts/profiling/live-ui.py writes must parse; a /// rename on either side would otherwise fail only in a live run. #[test] From 5354dfbfd0faf03c0b24cf54c98378b1f7b5c1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 22:02:51 +0300 Subject: [PATCH 14/67] Fix font startup tracing after the dev merge Remove the orphaned fontdb scan span left by the deleted scanner and include GPUI font discovery in the remaining catalog span. Update the profiling guide to match the merged implementation. --- crates/gitcomet-ui-kit/src/font_preferences.rs | 3 +-- scripts/profiling/README.md | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/gitcomet-ui-kit/src/font_preferences.rs b/crates/gitcomet-ui-kit/src/font_preferences.rs index a2e1c4e54..8cb089496 100644 --- a/crates/gitcomet-ui-kit/src/font_preferences.rs +++ b/crates/gitcomet-ui-kit/src/font_preferences.rs @@ -219,8 +219,8 @@ fn system_font_catalog(text_system: &TextSystem) -> &'static SystemFontCatalog { } fn collect_system_font_catalog(text_system: &TextSystem) -> SystemFontCatalog { - let all_families = normalize_font_names(text_system.all_font_names()); let _stage = gitcomet_base::startup_trace::span("system_font_catalog"); + let all_families = normalize_font_names(text_system.all_font_names()); let resolved_system_ui_family = resolved_system_ui_font_family(&all_families); SystemFontCatalog { @@ -229,7 +229,6 @@ fn collect_system_font_catalog(text_system: &TextSystem) -> SystemFontCatalog { } } - let _stage = gitcomet_base::startup_trace::span("fontdb_scan"); fn normalize_font_names(names: impl IntoIterator) -> Vec { let mut names_by_key = BTreeMap::new(); for name in names { diff --git a/scripts/profiling/README.md b/scripts/profiling/README.md index 82e9b428f..1229d3cf2 100644 --- a/scripts/profiling/README.md +++ b/scripts/profiling/README.md @@ -77,8 +77,9 @@ and background spans overlap and must not be added together. In particular, `git_runtime_probe_background` does not block window creation, but the Home picker displays a loading screen until Git validation completes. `native_application` covers native platform, GPU and native font-system setup; -Windows additionally reports renderer choice and fallback. `fontdb_scan` is a -separate installed-font scan used when custom font preferences need validation. +Windows additionally reports renderer choice and fallback. `system_font_catalog` +covers font-family discovery through GPUI when font preferences or the font +picker require the installed-font catalog. `first_paint` marks prepaint of the initial view and `first_interactive` its next frame callback, not an OS presentation timestamp or measured input latency. These may refer to the Git-checking loading screen. `home_ready` is the frame after the From 0e9240357603cdc61460ea2a03c72d6f219cf980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Fri, 16 Oct 2026 22:06:28 +0300 Subject: [PATCH 15/67] Allow session lock tests to wait through parallel writers Keep the blocked-writer assertion while allowing ten seconds for completion after releasing the shared session lock. Other parallel session tests can contend for that process-wide lock; include the writer label in timeout diagnostics. --- crates/gitcomet-state/src/session/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/gitcomet-state/src/session/tests.rs b/crates/gitcomet-state/src/session/tests.rs index 05655e6db..e9b7dcfb1 100644 --- a/crates/gitcomet-state/src/session/tests.rs +++ b/crates/gitcomet-state/src/session/tests.rs @@ -165,9 +165,11 @@ fn assert_session_writer_waits_for_shared_lock( ); drop(guard); + // Parallel session tests also use this process-wide lock. This wait detects + // deadlocks, not write latency; the assertion above checks the lock contract. done_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("writer finished after lock release") + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap_or_else(|err| panic!("{label} writer did not finish after lock release: {err}")) .expect("writer persist succeeds"); handle.join().expect("writer thread joins"); } From 0412b579b571760fb406cc252072a171638d0a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 08:46:18 +0300 Subject: [PATCH 16/67] Match only filter attributes when selecting Windows status workers Worker classification only needs the effective filter=lfs assignment. Use gix's selected-attribute lookup instead of resolving every attribute for every indexed path, preserving macro overrides and lazy nested/index attribute discovery. --- .../src/repo/status/worker_limit.rs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/gitcomet-git-gix/src/repo/status/worker_limit.rs b/crates/gitcomet-git-gix/src/repo/status/worker_limit.rs index a302c06d9..5036360d4 100644 --- a/crates/gitcomet-git-gix/src/repo/status/worker_limit.rs +++ b/crates/gitcomet-git-gix/src/repo/status/worker_limit.rs @@ -51,7 +51,9 @@ fn production_limit( ) else { return Ok(Some(bounded_limit())); }; - let mut outcome = gix::attrs::search::Outcome::default(); + // Only the effective filter controls this policy. Let gix stop matching + // once it resolves that attribute, including through macros and overrides. + let mut outcome = attributes.selected_attribute_matches(["filter"]); let mut filtered = 0usize; let mut bytes = 0u64; for entry in index.entries().iter().filter(regular) { @@ -61,8 +63,8 @@ fn production_limit( return Ok(Some(bounded_limit())); }; platform.matching_attributes(&mut outcome); - let lfs = outcome.iter().any(|matched| { - matched.assignment.name.as_str() == "filter" && matches!(matched.assignment.state, gix::attrs::StateRef::Value(value) if value.as_bstr() == super::super::large_files::LFS_FILTER_DRIVER) + let lfs = outcome.iter_selected().any(|matched| { + matches!(matched.assignment.state, gix::attrs::StateRef::Value(value) if value.as_bstr() == super::super::large_files::LFS_FILTER_DRIVER) }); if !lfs { continue; @@ -160,15 +162,52 @@ mod tests { #[test] fn resolves_effective_attributes_and_index_fallback() { - let (dir, repo) = fixture("[attr]asset filter=lfs\n*.bin asset\n"); + let (dir, repo) = + fixture("[attr]asset filter=lfs -diff -text\n*.bin asset\n*.bin eol=lf\n"); assert_eq!(policy(&repo), Some(1)); - std::fs::write(dir.path().join(".git/info/attributes"), b"a.bin -filter\n").unwrap(); - assert_eq!(policy(&repo), None); + for assignment in ["-filter", "!filter", "filter", "filter=other"] { + std::fs::write( + dir.path().join(".git/info/attributes"), + format!("a.bin {assignment} text eol=lf\n"), + ) + .unwrap(); + assert_eq!(policy(&repo), None, "override: {assignment}"); + } std::fs::remove_file(dir.path().join(".git/info/attributes")).unwrap(); std::fs::remove_file(dir.path().join(".gitattributes")).unwrap(); assert_eq!(policy(&repo), Some(1)); } + #[test] + fn selection_discovers_nested_filter_and_preserves_worktree_over_index_precedence() { + let (dir, _) = fixture("* text=auto\n"); + let root = dir.path(); + std::fs::create_dir(root.join("nested")).unwrap(); + std::fs::write(root.join("nested/b.bin"), "asset").unwrap(); + git_success(root, &["add", "nested/b.bin"]); + let repo = open_gix(root); + assert_eq!(policy(&repo), None); + + // The selection is created before at_entry discovers this first + // definition of filter. Unrelated later rules must not hide its value. + let nested_attributes = root.join("nested/.gitattributes"); + std::fs::write( + &nested_attributes, + "*.bin filter=lfs\n*.bin text eol=lf -diff\n", + ) + .unwrap(); + assert_eq!(policy(&repo), Some(bounded_limit())); + + git_success(root, &["add", "nested/.gitattributes"]); + let repo = open_gix(root); + std::fs::remove_file(&nested_attributes).unwrap(); + assert_eq!(policy(&repo), Some(bounded_limit())); + std::fs::write(&nested_attributes, "*.bin !filter\n").unwrap(); + assert_eq!(policy(&repo), None); + std::fs::remove_file(&nested_attributes).unwrap(); + assert_eq!(policy(&repo), Some(bounded_limit())); + } + #[test] fn global_attributes_and_nested_overrides_control_the_worker_policy() { let (dir, _) = fixture(""); From 9a1c77c01de7198f5e8345d45cdaf0e51d042b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 09:52:29 +0300 Subject: [PATCH 17/67] Skip line-statistics jobs for clean status snapshots Settle empty line counts and large-file rows inline after both fresh status lanes and their replays finish. Preserve the generation protocol so obsolete replies cannot publish or release newer work, while dirty snapshots continue through the backend. Cover clean loads, dirty-to-clean transitions, replay and error recovery, and stale replies across subsequent dirty refreshes. --- .../src/store/reducer/line_stats_tests.rs | 185 +++++++++++++++++- .../gitcomet-state/src/store/reducer/util.rs | 10 + .../src/store/tests/external_and_history.rs | 33 ++-- 3 files changed, 197 insertions(+), 31 deletions(-) diff --git a/crates/gitcomet-state/src/store/reducer/line_stats_tests.rs b/crates/gitcomet-state/src/store/reducer/line_stats_tests.rs index 66bc3dc86..ae4ba512f 100644 --- a/crates/gitcomet-state/src/store/reducer/line_stats_tests.rs +++ b/crates/gitcomet-state/src/store/reducer/line_stats_tests.rs @@ -52,6 +52,180 @@ fn no_stats(effects: &[Effect]) { ))); } +fn previous_counts() -> Arc { + let mut counts = gitcomet_core::domain::UncommittedLineStats::default(); + counts.staged.insert( + PathBuf::from("staged.txt"), + gitcomet_core::domain::LineStats::UNKNOWN, + ); + counts.unstaged.insert( + PathBuf::from("changed.txt"), + gitcomet_core::domain::LineStats::UNKNOWN, + ); + Arc::new(counts) +} + +fn assert_empty_counts_settled(repo: &RepoState) { + assert!(matches!(&repo.uncommitted_line_stats, + Loadable::Ready(counts) if counts.staged.is_empty() && counts.unstaged.is_empty())); + assert!(repo.uncommitted_large_files.is_empty()); + assert!(!repo.loads_in_flight.line_stats_in_flight()); +} + +#[test] +fn clean_status_settles_counts_and_large_file_rows_without_a_backend_job() { + for large_files in [false, true] { + let (mut state, id) = fixture(); + if large_files { + effects::large_file_support_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(lfs_support()), + ); + } + // Initial clean loading and subsequent dirty-to-clean refreshes both + // settle immediately, including stale LFS rows from the earlier view. + for after_dirty in [false, true] { + if after_dirty { + state.repos[0].set_uncommitted_line_stats(Loadable::Ready(previous_counts())); + if large_files { + state.repos[0] + .set_uncommitted_large_files(Arc::new(large_files_for("changed.txt"))); + } + } + let mut requested = Vec::new(); + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + no_stats(&effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(RepoStatus::default()), + )); + assert_empty_counts_settled(&state.repos[0]); + assert!(!state.repos[0].loads_in_flight.any_in_flight()); + } + } +} + +#[test] +fn clean_counts_wait_for_both_status_replays_and_failed_lane_recovery() { + let (mut state, id) = fixture(); + let previous = previous_counts(); + state.repos[0].set_uncommitted_line_stats(Loadable::Ready(Arc::clone(&previous))); + let mut requested = Vec::new(); + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + let replay = effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(RepoStatus::default()), + ); + no_stats(&replay); + assert_eq!(replay.len(), 2); + assert!(matches!(&state.repos[0].uncommitted_line_stats, + Loadable::Ready(counts) if Arc::ptr_eq(counts, &previous))); + no_stats(&effects::worktree_status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(vec![]), + )); + assert!(matches!(&state.repos[0].uncommitted_line_stats, + Loadable::Ready(counts) if Arc::ptr_eq(counts, &previous))); + no_stats(&effects::staged_status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Err(Error::new(ErrorKind::Backend("index unavailable".into()))), + )); + assert!(matches!(&state.repos[0].uncommitted_line_stats, + Loadable::Ready(counts) if Arc::ptr_eq(counts, &previous))); + + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + no_stats(&effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(RepoStatus::default()), + )); + assert_empty_counts_settled(&state.repos[0]); + assert!(!state.repos[0].loads_in_flight.any_in_flight()); +} + +#[test] +fn clean_counts_after_an_obsolete_job_reject_late_results_and_allow_new_dirty_counts() { + let (mut state, id) = fixture(); + effects::large_file_support_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(lfs_support()), + ); + let mut requested = Vec::new(); + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + let (obsolete, _) = stats_job(&effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(snapshot()), + )); + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + no_stats(&effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(RepoStatus::default()), + )); + assert!(state.repos[0].loads_in_flight.line_stats_in_flight()); + no_stats(&effects::uncommitted_line_stats_loaded( + &mut state, + id, + obsolete, + Ok(previous_counts().as_ref().clone()), + Some(Ok(large_files_for("obsolete.bin"))), + )); + assert_empty_counts_settled(&state.repos[0]); + + // The empty generation must not leave the lane busy or let an old reply + // release the new dirty job. A staged-only snapshot still needs that job. + util::append_requested_status_refresh_effects(&mut state.repos[0], &mut requested); + let staged = RepoStatus { + staged: snapshot().unstaged, + unstaged: Arc::default(), + }; + let (current, supplied) = stats_job(&effects::status_loaded( + &crate::store::reducer::test_inputs::clock(), + &mut state, + id, + Ok(staged.clone()), + )); + assert_ne!(current, obsolete); + assert_eq!(*supplied, staged); + no_stats(&effects::uncommitted_line_stats_loaded( + &mut state, + id, + obsolete, + Ok(previous_counts().as_ref().clone()), + Some(Ok(large_files_for("obsolete.bin"))), + )); + assert!(state.repos[0].loads_in_flight.line_stats_in_flight()); + assert!(state.repos[0].uncommitted_large_files.is_empty()); + let next = previous_counts(); + no_stats(&effects::uncommitted_line_stats_loaded( + &mut state, + id, + current, + Ok(next.as_ref().clone()), + Some(Ok(large_files_for("current.bin"))), + )); + assert!(matches!(&state.repos[0].uncommitted_line_stats, + Loadable::Ready(counts) if **counts == *next)); + assert!(!state.repos[0].uncommitted_large_files.is_empty()); + assert!(!state.repos[0].loads_in_flight.any_in_flight()); +} + #[test] fn initial_refresh_publishes_status_before_counts_and_reuses_its_arcs() { for full in [false, true] { @@ -330,20 +504,13 @@ fn already_up_to_date_pull_still_refreshes_once_and_counts_settle_after_status() .count(), 1 ); - let (generation, supplied) = stats_job(&effects::status_loaded( + no_stats(&effects::status_loaded( &crate::store::reducer::test_inputs::clock(), &mut state, id, Ok(RepoStatus::default()), )); - assert!(supplied.unstaged.is_empty()); - effects::uncommitted_line_stats_loaded( - &mut state, - id, - generation, - Ok(Default::default()), - None, - ); + assert_empty_counts_settled(&state.repos[0]); let loads = &state.repos[0].loads_in_flight; assert!(!loads.is_in_flight(LoadKind::WorktreeStatus)); assert!(!loads.is_in_flight(LoadKind::StagedStatus)); diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index c3aad5f83..c1a0a1939 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -706,6 +706,16 @@ pub(super) fn append_ready_line_stats_effect( else { unreachable!("start_line_stats requires ready status lanes"); }; + if staged.is_empty() && unstaged.is_empty() { + // Both fresh lanes and their replays have settled. There are no + // rows to count or classify, so finish through the same generation + // protocol without another backend task and completion message. + let current = repo_state.loads_in_flight.finish_line_stats(generation); + debug_assert!(current); + repo_state.set_uncommitted_line_stats(Loadable::Ready(Arc::default())); + repo_state.set_uncommitted_large_files(Arc::default()); + return; + } crate::store::repo_load_trace::trace!( "line_stats_start repo_id={:?} generation={} snapshot=reused staged={} unstaged={}", repo_id, diff --git a/crates/gitcomet-state/src/store/tests/external_and_history.rs b/crates/gitcomet-state/src/store/tests/external_and_history.rs index 889353b42..35926bd08 100644 --- a/crates/gitcomet-state/src/store/tests/external_and_history.rs +++ b/crates/gitcomet-state/src/store/tests/external_and_history.rs @@ -1185,30 +1185,12 @@ fn external_worktree_refresh_replays_coalesced_change_then_settles() { .all(|e| !matches!(e, Effect::Status(StatusEffect::LoadWorktreeStatus { repo_id: rid }) if *rid == repo_id)), "with no pending change the lane should stop replaying, got {effects:?}" ); - let generation = effects - .iter() - .find_map(|effect| match effect { - Effect::Status(StatusEffect::LoadUncommittedLineStats { generation, .. }) => { - Some(*generation) - } - _ => None, - }) - .expect("settled worktree status should schedule counts"); - let effects = reduce( - &mut repos, - &id_alloc, - &mut state, - Msg::Internal(crate::msg::InternalMsg::UncommittedLineStatsLoaded { - repo_id, - generation, - result: Ok(Default::default()), - large_files: None, - }), - ); assert!( effects.is_empty(), - "counting must not start another status scan" + "clean status must settle counts without another backend task" ); + assert!(matches!(&state.repos[0].uncommitted_line_stats, + Loadable::Ready(counts) if counts.staged.is_empty() && counts.unstaged.is_empty())); assert!( !state.repos[0].loads_in_flight.any_in_flight(), "in-flight flags should settle once no refresh is pending" @@ -4503,7 +4485,14 @@ fn line_stats_completion_replays_one_pending_refresh_then_settles() { workdir: PathBuf::from("/tmp/repo"), }, )); - state.repos[0].set_status(Loadable::Ready(Arc::new(RepoStatus::default()))); + state.repos[0].set_status(Loadable::Ready(Arc::new(RepoStatus { + staged: Arc::default(), + unstaged: Arc::new(vec![gitcomet_core::domain::FileStatus { + path: PathBuf::from("changed.txt"), + kind: gitcomet_core::domain::FileStatusKind::Modified, + conflict: None, + }]), + }))); state.repos[0].loads_in_flight.invalidate_line_stats(); let mut generation = state.repos[0] .loads_in_flight From 77b9e6f0aa390427cfeeeb16adac92c38fdda458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 12:01:43 +0300 Subject: [PATCH 18/67] Derive the remote metadata worker name from product identity Fix the product-identity lint regression introduced by the remote metadata pool while preserving its default thread name and the existing literal exceptions. Reproduced the failing PR #633 CI check locally; identity_literals.py now passes, all seven IdentityLiteralTests pass, and Rustfmt and diff checks pass. --- crates/gitcomet-state/src/store/executor.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/gitcomet-state/src/store/executor.rs b/crates/gitcomet-state/src/store/executor.rs index 1ac6b474e..94cc1fd29 100644 --- a/crates/gitcomet-state/src/store/executor.rs +++ b/crates/gitcomet-state/src/store/executor.rs @@ -217,6 +217,12 @@ impl TaskExecutor { static SIGNATURES: OnceLock> = OnceLock::new(); static METADATA: OnceLock> = OnceLock::new(); static REMOTE_METADATA: OnceLock> = OnceLock::new(); + static REMOTE_METADATA_NAME: std::sync::LazyLock = std::sync::LazyLock::new(|| { + format!( + "{}-store-remote-metadata", + gitcomet_core::identity::current().executable_name() + ) + }); static SESSION_PERSIST: OnceLock> = OnceLock::new(); static MAINTENANCE: OnceLock> = OnceLock::new(); @@ -224,9 +230,7 @@ impl TaskExecutor { StoreExecutorPool::Primary => (&PRIMARY, "gitcomet-store-primary"), StoreExecutorPool::Signatures => (&SIGNATURES, "gitcomet-store-signatures"), StoreExecutorPool::Metadata => (&METADATA, "gitcomet-store-metadata"), - StoreExecutorPool::RemoteMetadata => { - (&REMOTE_METADATA, "gitcomet-store-remote-metadata") - } + StoreExecutorPool::RemoteMetadata => (&REMOTE_METADATA, REMOTE_METADATA_NAME.as_str()), StoreExecutorPool::SessionPersist => { (&SESSION_PERSIST, "gitcomet-store-session-persist") } From 324a58f54afb6892fb28fd329c9652ff40762c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:03:32 +0300 Subject: [PATCH 19/67] Deduplicate aliased control-file reads in activation snapshots Watch discovery retains lexical and resolved spellings of the same file. Share control contents within each snapshot using the resolved existing-file path, while preserving both lexical discovery entries and fresh reads next capture. Proof on Windows: a portable parent/../ alias to one 3 MiB file failed before because two reads exceeded the 4 MiB budget. It now reads/budgets exactly 3 MiB; both discovery paths remain and subsequent edits invalidate the snapshot. The original duplicate-control test and regression both pass. Final state Clippy and 35 activation tests pass with this implementation unchanged. --- .../src/store/repo_monitor/activation.rs | 85 +++++++++++++++---- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index 1f75ae8c6..709a3c3cd 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -234,6 +234,7 @@ pub(super) struct MetadataSnapshot { entries: BTreeMap>, discovery: BTreeMap>, coverage: BTreeMap, + file_contents: BTreeMap>, git_dirs: Vec, inputs: Vec, bytes: u64, @@ -404,25 +405,35 @@ impl MetadataSnapshot { .and_then(|stamp| stamp.as_ref()) .and_then(|stamp| stamp.contents.clone()) } else if read_contents && !directory { - if metadata.len().saturating_add(self.bytes) > MAX_CONTROL_FILE_BYTES { - return Err(std::io::Error::other( - "activation control file limit exceeded", - )); - } - // HEAD, refs and configuration can be rewritten in place while - // preserving timestamps. Their bytes are the actual input. - use std::io::Read; - let mut contents = Vec::new(); - fs::File::open(path)? - .take(MAX_CONTROL_FILE_BYTES - self.bytes + 1) - .read_to_end(&mut contents)?; - self.bytes += contents.len() as u64; - if self.bytes > MAX_CONTROL_FILE_BYTES { - return Err(std::io::Error::other( - "activation control file limit exceeded", - )); + // Watch inputs intentionally retain lexical and resolved aliases. + // Keep their discovery entries, but read and budget the existing + // file only once within this snapshot. + let identity = normalized(&fs::canonicalize(path)?); + if let Some(contents) = self.file_contents.get(&identity) { + Some(contents.clone()) + } else { + if metadata.len().saturating_add(self.bytes) > MAX_CONTROL_FILE_BYTES { + return Err(std::io::Error::other( + "activation control file limit exceeded", + )); + } + // HEAD, refs and configuration can be rewritten in place while + // preserving timestamps. Their bytes are the actual input. + use std::io::Read; + let mut contents = Vec::new(); + fs::File::open(&identity)? + .take(MAX_CONTROL_FILE_BYTES - self.bytes + 1) + .read_to_end(&mut contents)?; + self.bytes += contents.len() as u64; + if self.bytes > MAX_CONTROL_FILE_BYTES { + return Err(std::io::Error::other( + "activation control file limit exceeded", + )); + } + let contents: Arc<[u8]> = contents.into(); + self.file_contents.insert(identity, contents.clone()); + Some(contents) } - Some(contents.into()) } else { None }; @@ -617,6 +628,44 @@ mod tests { ); } + #[test] + fn aliased_control_inputs_share_the_content_budget_and_keep_discovery_paths() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("nested")).unwrap(); + let config = dir.path().join("config"); + let alias = dir.path().join("nested/../config"); + fs::write(&config, vec![b'#'; 3 * 1024 * 1024]).unwrap(); + assert_ne!(config, alias); + assert_eq!( + config.canonicalize().unwrap(), + alias.canonicalize().unwrap() + ); + + let mut snapshot = MetadataSnapshot::default(); + snapshot.add_discovery(&config).unwrap(); + snapshot.add_discovery(&alias).unwrap(); + assert_eq!(snapshot.bytes, 3 * 1024 * 1024); + for path in [&config, &alias] { + assert!(snapshot.discovery.contains_key(path)); + assert_eq!( + snapshot.entries[path] + .as_ref() + .unwrap() + .contents + .as_ref() + .unwrap() + .len(), + 3 * 1024 * 1024 + ); + } + let previous = snapshot; + fs::write(&config, "changed").unwrap(); + let mut current = MetadataSnapshot::default(); + current.add_discovery(&config).unwrap(); + current.add_discovery(&alias).unwrap(); + assert!(!previous.discovery_still_matches(¤t)); + } + #[test] fn unavailable_metadata_snapshot_does_not_request_watcher_rebuild() { let (dir, backend) = fixture(); From 3edce214f9b9fc362e71345c18666c94f23aad72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:03:47 +0300 Subject: [PATCH 20/67] Track discovery link targets without scanning unrelated directories Known symlinks and Windows junctions in discovery inputs previously made the activation snapshot unavailable, disabling unchanged-focus narrowing. Record link spelling and resolved target stamps so target edits and retargets remain visible. Stamp ancestor targets nonrecursively; recursive links and unknown reparse layouts retain the conservative fallback. Proof: an unprivileged Windows junction regression failed before and now checks stable snapshots, target edits, equal-byte retargeting, absent children, and no traversal of unrelated data. Recursive rejection leaves an empty snapshot. Final 35 activation tests and state Clippy pass; the full state suite passed 1402 tests (7 ignored) before the final conservative recursive guard. Two privilege-dependent symlink fixtures await native Unix CI. --- .../src/store/repo_monitor/activation.rs | 243 +++++++++++++++++- 1 file changed, 233 insertions(+), 10 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index 709a3c3cd..5e776dcb7 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -216,6 +216,8 @@ struct FileStamp { created: Option, directory: bool, contents: Option>, + link: Option, + resolved: Option, #[cfg(unix)] inode: u64, #[cfg(unix)] @@ -330,6 +332,12 @@ impl MetadataSnapshot { fn add_discovery(&mut self, path: &Path) -> std::io::Result<()> { self.add(path, false, true)?; + if let Some(resolved) = self.entries[path] + .as_ref() + .and_then(|stamp| stamp.resolved.clone()) + { + self.add_discovery(&resolved)?; + } self.discovery .insert(path.to_path_buf(), self.entries[path].clone()); Ok(()) @@ -368,11 +376,18 @@ impl MetadataSnapshot { // optional input. Validate the nearest existing ancestor. for ancestor in path.ancestors().skip(1) { match fs::symlink_metadata(ancestor) { - Ok(metadata) if metadata.is_dir() => break, - Ok(_) => { - return Err(std::io::Error::other( - "activation input has a non-directory ancestor", - )); + Ok(metadata) => { + let directory = if metadata_link(ancestor, &metadata)?.is_some() { + fs::metadata(ancestor)?.is_dir() + } else { + metadata.is_dir() + }; + if !directory { + return Err(std::io::Error::other( + "activation input has a non-directory ancestor", + )); + } + break; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error), @@ -391,15 +406,42 @@ impl MetadataSnapshot { } Err(error) => return Err(error), }; - // Fall back to the full refresh for unusual layouts instead of - // mistaking an unchanged link for an unchanged target. - if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { + let link = metadata_link(path, &metadata)?; + // The discovered ancestor/control links need only nonrecursive stamps. + // A link inside refs or sequencer data could lead into objects/private + // caches or an unrelated tree; retain the full-refresh fallback there. + if recursive && link.is_some() { + return Err(std::io::Error::other( + "unsupported recursive activation metadata link", + )); + } + if link.is_none() && !metadata.is_file() && !metadata.is_dir() { return Err(std::io::Error::other( "unsupported activation metadata input", )); } - let directory = metadata.is_dir(); - let contents = if previous.is_some_and(|coverage| coverage.contents) { + let resolved = link + .as_ref() + .map(|_| fs::canonicalize(path).map(|target| normalized(&target))) + .transpose()?; + let target = if let Some(resolved) = &resolved { + // Ancestor links such as /var are explicit discovery inputs. Their + // targets need identity stamps, not an unbounded directory walk. + // Canonicalization rejects dangling links and link-resolution loops. + if resolved == path { + return Err(std::io::Error::other("unresolved activation metadata link")); + } + self.add(resolved, false, read_contents)?; + Some(self.entries[resolved].as_ref().ok_or_else(|| { + std::io::Error::other("activation metadata link target disappeared") + })?) + } else { + None + }; + let directory = target.map_or_else(|| metadata.is_dir(), |stamp| stamp.directory); + let contents = if let Some(target) = target { + target.contents.clone() + } else if previous.is_some_and(|coverage| coverage.contents) { self.entries .get(path) .and_then(|stamp| stamp.as_ref()) @@ -437,6 +479,10 @@ impl MetadataSnapshot { } else { None }; + // Adding a link's target may have used the final available entry. + if previous.is_none() && self.entries.len() >= MAX_METADATA_ENTRIES { + return Err(std::io::Error::other("activation metadata limit exceeded")); + } self.entries.insert( path.to_path_buf(), Some(FileStamp { @@ -451,6 +497,8 @@ impl MetadataSnapshot { created: metadata.created().ok(), directory, contents, + link, + resolved, #[cfg(unix)] inode: std::os::unix::fs::MetadataExt::ino(&metadata), #[cfg(unix)] @@ -485,6 +533,24 @@ impl MetadataSnapshot { } } +/// `read_link` accepts ordinary symlinks and Windows junctions. Other reparse +/// layouts must fail closed rather than silently treating their target as a +/// plain, unchanged input. +fn metadata_link(path: &Path, metadata: &fs::Metadata) -> std::io::Result> { + let link = metadata.file_type().is_symlink(); + #[cfg(windows)] + let link = link || { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + }; + if link { + fs::read_link(path).map(Some) + } else { + Ok(None) + } +} + #[cfg(test)] mod tests { use super::*; @@ -666,6 +732,163 @@ mod tests { assert!(!previous.discovery_still_matches(¤t)); } + fn check_directory_link_inputs(create_link: impl Fn(&Path, &Path) -> bool) { + let (dir, mut backend) = fixture(); + let external = tempfile::tempdir().unwrap(); + let first = external.path().join("target-a"); + let second = external.path().join("target-b"); + let link = external.path().join("linked"); + for target in [&first, &second] { + fs::create_dir(target).unwrap(); + fs::write(target.join("config"), "same configuration").unwrap(); + // An ancestor link must not expand into unrelated directory data. + fs::write( + target.join("unrelated"), + vec![b'x'; MAX_CONTROL_FILE_BYTES as usize + 1], + ) + .unwrap(); + } + if !create_link(&first, &link) { + return; + } + let mut recursive = MetadataSnapshot::default(); + assert!(recursive.add(&link, true, true).is_err()); + assert!( + recursive.entries.is_empty(), + "recursive links must fail before walking their targets" + ); + backend + .0 + .as_mut() + .unwrap() + .ignore_inputs + .extend([link.join("config"), link.join("missing")]); + let capture = || { + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + MetadataSnapshot::capture(dir.path(), &inputs).unwrap() + }; + let snapshot = capture(); + assert!(snapshot.discovery.contains_key(&normalized(&link))); + assert!( + snapshot + .discovery + .contains_key(&normalized(&first.canonicalize().unwrap())) + ); + assert!(!snapshot.entries.contains_key(&first.join("unrelated"))); + assert!(!snapshot.entries.contains_key(&link.join("unrelated"))); + let mut refresh = ActivationRefresh::default(); + refresh.complete(Some(snapshot), ActivationContext::ready_for_test()); + assert_working_changes_only( + refresh.complete(Some(capture()), ActivationContext::ready_for_test()), + ); + fs::write(first.join("config"), "edited configuration").unwrap(); + let edited = capture(); + assert!(refresh.needs_discovery(Ok(&edited))); + assert!( + refresh + .complete(Some(edited), ActivationContext::ready_for_test()) + .git_state + ); + fs::write(first.join("config"), "same configuration").unwrap(); + refresh.complete(Some(capture()), ActivationContext::ready_for_test()); + #[cfg(windows)] + fs::remove_dir(&link).unwrap(); + #[cfg(unix)] + fs::remove_file(&link).unwrap(); + assert!(create_link(&second, &link)); + let retargeted = capture(); + assert!( + refresh.needs_discovery(Ok(&retargeted)), + "equal file bytes must not hide link retargeting" + ); + assert!( + refresh + .complete(Some(retargeted), ActivationContext::ready_for_test()) + .git_state + ); + assert_working_changes_only( + refresh.complete(Some(capture()), ActivationContext::ready_for_test()), + ); + } + + #[test] + fn directory_link_inputs_track_targets_and_retarget_without_recursive_scans() { + check_directory_link_inputs(|target, link| { + gitcomet_core::test_support::symlink::directory(target, link) + }); + } + + #[cfg(windows)] + #[test] + fn directory_junction_inputs_track_targets_and_retarget_without_recursive_scans() { + check_directory_link_inputs(|target, link| { + // Fixed relative fixture names keep this shell invocation independent + // of user-profile spelling and require no symlink privilege. + let target_name = target.file_name().unwrap().to_str().unwrap(); + assert!(matches!(target_name, "target-a" | "target-b")); + assert_eq!(link.file_name().unwrap(), "linked"); + let output = std::process::Command::new("cmd") + .current_dir(link.parent().unwrap()) + .args(["/d", "/c", "mklink", "/J", "linked", target_name]) + .output() + .unwrap(); + assert!(output.status.success(), "junction fixture: {output:?}"); + true + }); + } + + #[test] + fn file_link_inputs_track_target_contents_and_intermediate_retargeting() { + let (dir, mut backend) = fixture(); + let external = tempfile::tempdir().unwrap(); + let first = external.path().join("first"); + let second = external.path().join("second"); + let middle = external.path().join("middle"); + let link = external.path().join("link"); + fs::write(&first, "same").unwrap(); + fs::write(&second, "same").unwrap(); + if !gitcomet_core::test_support::symlink::file(Path::new("first"), &middle) + || !gitcomet_core::test_support::symlink::file(Path::new("middle"), &link) + { + return; + } + backend.0.as_mut().unwrap().ignore_inputs.push(link.clone()); + let mut refresh = ActivationRefresh::default(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write(&first, "edit").unwrap(); + assert!(refresh.change(dir.path(), &backend).git_state); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + fs::write(&first, "same").unwrap(); + refresh.change(dir.path(), &backend); + fs::remove_file(&middle).unwrap(); + assert!(gitcomet_core::test_support::symlink::file( + Path::new("second"), + &middle + )); + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + let retargeted = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(retargeted.is_ok()); + assert!(refresh.needs_discovery(retargeted.as_ref())); + assert!( + refresh + .complete(retargeted.ok(), ActivationContext::ready_for_test()) + .git_state + ); + assert_working_changes_only(refresh.change(dir.path(), &backend)); + // Canonical identity and bytes stay unchanged, but the link's spelling + // still changes and must invalidate discovery. + fs::remove_file(&middle).unwrap(); + assert!(gitcomet_core::test_support::symlink::file( + Path::new("./second"), + &middle + )); + let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); + let retargeted = ActivationRefresh::prepare(dir.path(), &inputs); + assert!(retargeted.is_ok()); + assert!(refresh.needs_discovery(retargeted.as_ref())); + } + #[test] fn unavailable_metadata_snapshot_does_not_request_watcher_rebuild() { let (dir, backend) = fixture(); From e4b33508b91cf7f6bfb233bef967f7df6ad3c745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:03:52 +0300 Subject: [PATCH 21/67] Avoid Git subprocesses for conventional single-worktree listings Read the sole primary checkout from fresh configuration and refs when trust, layout, ref storage and environment permit it. Return raw HEAD IDs without loading commit objects. Preserve Git fallback for linked/unusual layouts and propagate cancellation. The negative layout check borrows shared repository paths and releases its lock before filesystem I/O. Windows release proof: two reversed AB/BA sessions, 10 process pairs per case, 5 warmups and 15 measured calls each (100 processes, 1500 warm calls). Project listing median fell 40.4844 -> 1.0428 ms; 20000-commit repo 40.4991 -> 0.9755 ms; detached checkout 40.8603 -> 1.0001 ms, about 97.5% less time. Git commands per eligible call fell 1 -> 0. Linked controls keep one Git command; paired confidence intervals cross zero, so no linked speedup is claimed. Full porcelain rows and index hashes/mtimes matched throughout. These are API timings, not app launch. Validated exact integrated source: 16 focused tests; full backend suite 422 passed, 13 ignored; release Clippy with benchmarks and warnings denied; locked production release builds. Raw evidence: target/performance-investigation/ iteration-4/worktrees-v3-analysis.json and candidate-v3-aligned-build.json. --- crates/gitcomet-git-gix/src/repo/mod.rs | 2 +- crates/gitcomet-git-gix/src/repo/worktrees.rs | 373 +++++++++++++++++- 2 files changed, 370 insertions(+), 5 deletions(-) diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index d1a2d38d5..8f1467d7c 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -1521,7 +1521,7 @@ impl GitRepository for GixRepo { fn list_worktrees(&self, cancellation: &CancellationToken) -> Result> { cancellation.check_cancelled()?; - let worktrees = self.list_worktrees_impl()?; + let worktrees = self.list_worktrees_cancellable_impl(cancellation)?; cancellation.check_cancelled()?; Ok(worktrees) } diff --git a/crates/gitcomet-git-gix/src/repo/worktrees.rs b/crates/gitcomet-git-gix/src/repo/worktrees.rs index aee869379..863aff4c7 100644 --- a/crates/gitcomet-git-gix/src/repo/worktrees.rs +++ b/crates/gitcomet-git-gix/src/repo/worktrees.rs @@ -1,21 +1,67 @@ use super::GixRepo; use crate::util::{ - path_buf_from_git_bytes, run_git_capture_bytes, run_git_with_output, validate_ref_like_arg, + path_buf_from_git_bytes, run_git_capture_bytes_cancellable, run_git_with_output, + validate_ref_like_arg, }; use gitcomet_core::domain::{CommitId, Worktree}; use gitcomet_core::path_utils::canonicalize_or_original; -use gitcomet_core::services::{CommandOutput, Result}; +use gitcomet_core::services::{CancellationToken, CommandOutput, Result}; +use gix::bstr::ByteSlice as _; use std::path::Path; use std::process::Command; impl GixRepo { pub(super) fn list_worktrees_impl(&self) -> Result> { + self.list_worktrees_cancellable_impl(&CancellationToken::new()) + } + + pub(super) fn list_worktrees_cancellable_impl( + &self, + cancellation: &CancellationToken, + ) -> Result> { + cancellation.check_cancelled()?; + // The existing layout can only reject the optimization. In particular, + // linked checkouts should not reparse configuration before invoking Git. + // A positive still refreshes refs/config and rechecks the whole layout; + // registrations and configuration can change while this handle is open. + if !worktree_environment_overridden() + && self.may_be_sole_primary_worktree() + && let Ok(repo) = self.reopen_repo() + { + cancellation.check_cancelled()?; + if let Some(worktree) = sole_primary_worktree(&repo) { + cancellation.check_cancelled()?; + return Ok(vec![worktree]); + } + } + cancellation.check_cancelled()?; let mut cmd = self.git_workdir_cmd(); cmd.arg("worktree").arg("list").arg("--porcelain").arg("-z"); - let output = run_git_capture_bytes(cmd, "git worktree list --porcelain -z")?; + let output = run_git_capture_bytes_cancellable( + cmd, + "git worktree list --porcelain -z", + cancellation, + )?; parse_git_worktree_list_porcelain_z(&output) } + /// A stale negative only forgoes the native path; a positive is not evidence + /// that a listing can be returned without the fresh authoritative checks. + fn may_be_sole_primary_worktree(&self) -> bool { + let common_dir = { + let store = self.store.read().expect("repo store"); + let git_dir = store.repo.git_dir(); + let common_dir = store.repo.common_dir.as_deref().unwrap_or(git_dir); + if git_dir != common_dir { + return false; + } + common_dir.to_path_buf() + }; + // The store guard must not outlive the path read: filesystem I/O and + // the subsequent fresh open must not delay a store replacement. + no_worktree_registrations(&common_dir) + } + pub(super) fn add_worktree_with_output_impl( &self, path: &Path, @@ -42,6 +88,92 @@ impl GixRepo { } } +fn worktree_environment_overridden() -> bool { + [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + ] + .iter() + .any(|name| std::env::var_os(name).is_some()) +} + +fn no_worktree_registrations(common_dir: &Path) -> bool { + let registrations = common_dir.join("worktrees"); + match std::fs::symlink_metadata(®istrations) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Ok(metadata) if plain_directory(&metadata) => { + // An entry or a failed enumeration may represent another checkout. + std::fs::read_dir(®istrations).is_ok_and(|mut entries| entries.next().is_none()) + } + _ => false, + } +} + +/// Only the conventional primary checkout with a resolved HEAD is handled +/// here. Git remains authoritative for linked, bare and unusual layouts. +fn sole_primary_worktree(repo: &gix::Repository) -> Option { + if repo.git_dir_trust() != gix::sec::Trust::Full + || repo.is_bare() + || repo.git_dir() != repo.common_dir() + || crate::refs::backend(repo).ok()? != crate::refs::RefBackend::Files + || repo.config_snapshot().string("core.worktree").is_some() + { + return None; + } + let workdir = repo.workdir()?; + let dot_git = workdir.join(".git"); + if !plain_directory(&std::fs::symlink_metadata(workdir).ok()?) + || !plain_directory(&std::fs::symlink_metadata(&dot_git).ok()?) + || !same_file::is_same_file(repo.git_dir(), &dot_git).ok()? + { + return None; + } + + if !no_worktree_registrations(repo.common_dir()) { + return None; + } + + let refs = crate::refs::view(repo).ok()?; + let (id, branch, detached) = match refs.find_exact(b"HEAD").ok()??.target { + gix::refs::Target::Symbolic(name) => { + let branch = name.as_bstr().strip_prefix(b"refs/heads/")?; + // Symbolic chains and unborn refs retain Git's existing behavior. + let gix::refs::Target::Object(id) = refs.find_exact(name.as_bstr()).ok()??.target + else { + return None; + }; + (id, Some(branch.to_str_lossy().into_owned()), false) + } + gix::refs::Target::Object(id) => (id, None, true), + }; + // Like Git's porcelain listing, return the ref's OID without loading its + // object. Missing objects and detached tag targets still have a worktree row. + let mut worktree = Worktree { + path: workdir.to_path_buf(), + head: Some(CommitId(super::oid_to_arc_str(&id))), + branch, + detached, + }; + canonicalize_worktree_path(&mut worktree); + Some(worktree) +} + +fn plain_directory(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + // Junctions and other reparse points are not all exposed as symlinks. + if metadata.file_attributes() & 0x400 != 0 { + return false; + } + } + metadata.is_dir() && !metadata.file_type().is_symlink() +} + /// Append `git worktree -- []` and return the display /// label. /// @@ -131,12 +263,245 @@ fn canonicalize_worktree_path(worktree: &mut Worktree) { #[cfg(test)] mod tests { - use super::{parse_git_worktree_list_porcelain_z, push_worktree_args}; + use super::{ + GixRepo, Worktree, parse_git_worktree_list_porcelain_z, push_worktree_args, + sole_primary_worktree, + }; + use crate::test_support::{git_output, git_stdout, git_success, init_test_repo, open_repo}; use gitcomet_core::path_utils::canonicalize_or_original; + use gitcomet_core::services::{CancellationToken, GitRepository as _}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Command; + fn committed_primary() -> (tempfile::TempDir, GixRepo) { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("repository space"); + std::fs::create_dir(&path).unwrap(); + init_test_repo(&path); + git_success(&path, &["commit", "--allow-empty", "-m", "initial"]); + git_success(&path, &["branch", "-M", "main"]); + let repo = open_repo(&path); + (root, repo) + } + + fn oracle(repo: &GixRepo) -> Vec { + let output = git_output( + &repo.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + assert!(output.status.success(), "{output:?}"); + parse_git_worktree_list_porcelain_z(&output.stdout).unwrap() + } + + fn assert_listing(repo: &GixRepo, commands: usize) { + let (actual, trace) = + crate::command_trace::capture(|| repo.list_worktrees(&CancellationToken::new())); + assert_eq!(actual.unwrap(), oracle(repo)); + assert_eq!(trace.len(), commands, "{trace:?}"); + } + + #[test] + fn native_single_worktree_matches_git_after_head_changes() { + let (_root, repo) = committed_primary(); + assert_listing(&repo, 0); + git_success(&repo.spec.workdir, &["checkout", "-b", "topic/nested"]); + git_success( + &repo.spec.workdir, + &["commit", "--allow-empty", "-m", "second"], + ); + git_success(&repo.spec.workdir, &["pack-refs", "--all"]); + assert_listing(&repo, 0); + git_success(&repo.spec.workdir, &["checkout", "--detach", "HEAD~1"]); + assert_listing(&repo, 0); + git_success(&repo.spec.workdir, &["checkout", "main"]); + assert_listing(&repo, 0); + } + + #[test] + fn native_single_worktree_rechecks_linked_registrations_each_time() { + let (root, repo) = committed_primary(); + assert!(repo.may_be_sole_primary_worktree()); + assert_listing(&repo, 0); + let linked = root.path().join("linked checkout"); + git_success( + &repo.spec.workdir, + &[ + "worktree", + "add", + "--detach", + linked.to_str().unwrap(), + "HEAD", + ], + ); + assert!(!repo.may_be_sole_primary_worktree()); + assert_listing(&repo, 1); + let linked_repo = open_repo(&linked); + assert!(!linked_repo.may_be_sole_primary_worktree()); + assert_listing(&linked_repo, 1); + drop(linked_repo); + git_success( + &repo.spec.workdir, + &["worktree", "lock", linked.to_str().unwrap()], + ); + assert_listing(&repo, 1); + git_success( + &repo.spec.workdir, + &["worktree", "unlock", linked.to_str().unwrap()], + ); + git_success( + &repo.spec.workdir, + &["worktree", "remove", linked.to_str().unwrap()], + ); + assert!(repo.may_be_sole_primary_worktree()); + assert_listing(&repo, 0); + } + + #[test] + fn native_single_worktree_keeps_git_for_unborn_and_separate_git_dirs() { + let unborn = tempfile::tempdir().unwrap(); + init_test_repo(unborn.path()); + assert_listing(&open_repo(unborn.path()), 1); + + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("checkout"); + std::fs::create_dir(&path).unwrap(); + let separate = root.path().join("separate metadata"); + git_success( + &path, + &["init", "--separate-git-dir", separate.to_str().unwrap()], + ); + git_success( + &path, + &[ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + ); + assert_listing(&open_repo(&path), 1); + } + + #[test] + fn native_single_worktree_reads_oid_without_requiring_its_object() { + let (_root, repo) = committed_primary(); + git_success(&repo.spec.workdir, &["tag", "-a", "tagged", "-m", "tag"]); + let tag = git_stdout(&repo.spec.workdir, &["rev-parse", "refs/tags/tagged"]); + std::fs::write(repo.spec.workdir.join(".git/HEAD"), format!("{tag}\n")).unwrap(); + assert_listing(&repo, 0); + let absent = "1111111111111111111111111111111111111111"; + std::fs::write(repo.spec.workdir.join(".git/HEAD"), format!("{absent}\n")).unwrap(); + assert_listing(&repo, 0); + std::fs::write( + repo.spec.workdir.join(".git/refs/heads/main"), + format!("{absent}\n"), + ) + .unwrap(); + std::fs::write( + repo.spec.workdir.join(".git/HEAD"), + b"ref: refs/heads/main\n", + ) + .unwrap(); + assert_listing(&repo, 0); + } + + #[test] + fn native_single_worktree_defers_reduced_trust_to_git() { + let (_root, repo) = committed_primary(); + let reduced = gix::open_opts( + repo.spec.workdir.join(".git"), + gix::open::Options::default() + .permissions(gix::open::Permissions::isolated()) + .with(gix::sec::Trust::Reduced), + ) + .unwrap(); + assert_eq!(reduced.git_dir_trust(), gix::sec::Trust::Reduced); + assert!(sole_primary_worktree(&reduced).is_none()); + } + + #[test] + fn native_single_worktree_defers_uncertain_layouts_and_symbolic_chains() { + let (_root, repo) = committed_primary(); + let registrations = repo.spec.workdir.join(".git/worktrees"); + std::fs::write(®istrations, b"not a registration directory").unwrap(); + assert!(!repo.may_be_sole_primary_worktree()); + assert!(sole_primary_worktree(&repo.reopen_repo().unwrap()).is_none()); + let (_, trace) = + crate::command_trace::capture(|| repo.list_worktrees(&CancellationToken::new())); + assert_eq!(trace.len(), 1); + std::fs::remove_file(®istrations).unwrap(); + std::fs::create_dir(®istrations).unwrap(); + std::fs::create_dir(registrations.join("unknown checkout")).unwrap(); + assert!(!repo.may_be_sole_primary_worktree()); + assert!(sole_primary_worktree(&repo.reopen_repo().unwrap()).is_none()); + std::fs::remove_dir(registrations.join("unknown checkout")).unwrap(); + assert!(repo.may_be_sole_primary_worktree()); + assert_listing(&repo, 0); + + git_success( + &repo.spec.workdir, + &["symbolic-ref", "refs/heads/alias", "refs/heads/main"], + ); + git_success( + &repo.spec.workdir, + &["symbolic-ref", "HEAD", "refs/heads/alias"], + ); + assert_listing(&repo, 1); + git_success( + &repo.spec.workdir, + &["symbolic-ref", "HEAD", "refs/heads/main"], + ); + git_success(&repo.spec.workdir, &["config", "core.worktree", "."]); + // The layout-only preflight remains positive, but the fresh config + // must still reject the native path after a same-handle change. + assert!(repo.may_be_sole_primary_worktree()); + assert!(sole_primary_worktree(&repo.reopen_repo().unwrap()).is_none()); + assert_listing(&repo, 1); + } + + #[test] + fn native_single_worktree_pre_cancelled_load_never_starts_git() { + let (_root, repo) = committed_primary(); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let (result, trace) = crate::command_trace::capture(|| repo.list_worktrees(&cancellation)); + assert!(matches!( + result.unwrap_err().kind(), + gitcomet_core::error::ErrorKind::Cancelled + )); + assert!(trace.is_empty()); + } + + #[cfg(any(unix, windows))] + #[test] + fn native_single_worktree_does_not_follow_registration_directory_links() { + let (root, repo) = committed_primary(); + let target = root.path().join("registration target"); + std::fs::create_dir(&target).unwrap(); + let registrations = repo.spec.workdir.join(".git/worktrees"); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, ®istrations).unwrap(); + #[cfg(windows)] + { + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(®istrations) + .arg(&target) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + } + assert!(!repo.may_be_sole_primary_worktree()); + assert_listing(&repo, 1); + } + #[test] fn worktree_add_places_separator_before_path_and_reference() { let mut cmd = Command::new("git"); From 5bd43e676be9ee16fef6b310a6546f99f15bf29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:03:58 +0300 Subject: [PATCH 22/67] Reuse status footer positions across unchanged diff renders Retain one source/projection/plan position in the details pane. Check source, selection, view and order identities before resolving the split section or materializing drawn order. Distinguish an authoritative missing file from an unavailable owner so fallback behavior remains intact; clear on repo changes. Windows release helper proof (48 cases, nine samples each): repeated selected- end lookup at 50000 files fell 2.854713 -> 0.000033 ms combined and 7.279625 -> 0.000033 ms split. This measures exact extracted lookup/order helpers versus the production cache, not complete UI frames. Actual GPUI caller at 20000 entries confirms zero warm section/position scans, projection/plan builds or order materializations. Combined-tree allocation requests fell 422182 -> 6 bytes/call; split-tree 237774 -> 6 bytes/call. The existing six-byte preference allocation remains. Four GPUI regression tests, the caller probe, 54 helper correctness cases and UI Clippy pass. Raw evidence: target/performance-investigation/iteration-4/footer-probe/ analysis-final.json with tested source/binary hashes and scope limitations. --- .../src/view/diff_commands.rs | 17 + crates/gitcomet-ui-gpui/src/view/mod.rs | 1 + .../src/view/panels/main/status_nav.rs | 139 ++++- .../src/view/panels/tests/mod.rs | 1 + .../src/view/panels/tests/status_footer.rs | 473 ++++++++++++++++++ .../src/view/panes/details.rs | 22 +- .../src/view/panes/diff/services.rs | 12 + .../src/view/status_position_cache.rs | 115 +++++ 8 files changed, 777 insertions(+), 3 deletions(-) create mode 100644 crates/gitcomet-ui-gpui/src/view/panels/tests/status_footer.rs create mode 100644 crates/gitcomet-ui-gpui/src/view/status_position_cache.rs diff --git a/crates/gitcomet-ui-gpui/src/view/diff_commands.rs b/crates/gitcomet-ui-gpui/src/view/diff_commands.rs index 8dec3b96d..bfe8260e7 100644 --- a/crates/gitcomet-ui-gpui/src/view/diff_commands.rs +++ b/crates/gitcomet-ui-gpui/src/view/diff_commands.rs @@ -195,6 +195,23 @@ impl DiffNavigation for WindowNavigation { .ok() .flatten() } + fn status_position( + &self, + repo: &RepoState, + path: &Path, + area: DiffArea, + view: ChangeTrackingView, + cx: &App, + ) -> Option> { + self.0 + .read_with(cx, |root, cx| { + root.details_pane + .read(cx) + .status_footer_position(repo, path, area, view) + }) + .ok() + .flatten() + } fn file_order(&self, repo: RepoId, range: bool, cx: &App) -> Option> { self.0 .read_with(cx, |root, cx| { diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index f83ee40d9..aaeff3a0b 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -324,6 +324,7 @@ mod sidebar_sticky; mod splash; mod state_apply; mod status_actions; +mod status_position_cache; pub(in crate::view) use gitcomet_text::tab_width; mod terminal_panel; mod terminal_preferences; diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs index 6ccbf0701..cd3149001 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs @@ -1,5 +1,103 @@ use super::*; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(in crate::view) struct StatusFooterWork { + pub(in crate::view) section_resolutions: u64, + pub(in crate::view) position_lookups: u64, + pub(in crate::view) projection_builds: u64, + pub(in crate::view) plan_builds: u64, + pub(in crate::view) order_materializations: u64, +} + +#[cfg(test)] +thread_local! { + static STATUS_FOOTER_WORK: std::cell::Cell = Default::default(); +} + +impl DetailsPaneView { + #[cfg(test)] + pub(in crate::view) fn record_status_footer_work(update: impl FnOnce(&mut StatusFooterWork)) { + STATUS_FOOTER_WORK.with(|work| { + let mut next = work.get(); + update(&mut next); + work.set(next); + }); + } + + #[cfg(test)] + pub(in crate::view) fn take_status_footer_work() -> StatusFooterWork { + STATUS_FOOTER_WORK.with(|work| work.replace(StatusFooterWork::default())) + } + + /// Resolve the footer from the same source and plan as the status rows. + /// Unchanged renders compare retained identities before locating the file's + /// section or materializing its drawn order. + pub(in crate::view) fn status_footer_position( + &self, + renderer_repo: &RepoState, + path: &std::path::Path, + area: DiffArea, + view: ChangeTrackingView, + ) -> Option> { + use crate::view::status_position_cache::{OrderSnapshot, PositionQuery}; + + let repo_id = renderer_repo.id; + let repo = self.active_repo().filter(|repo| repo.id == repo_id)?; + // The panes observe snapshots independently. Use the retained lookup + // only when its source is also the source the renderer is navigating. + let same_source = match ( + repo.status_entries_for_area(area), + renderer_repo.status_entries_for_area(area), + ) { + (Some(displayed), Some(rendered)) => std::ptr::eq(displayed, rendered), + (None, None) => true, + _ => false, + }; + if !same_source { + return None; + } + // Match RepoState::status_entries_for_area, including the legacy + // Ready status used while a split lane has no ready value of its own. + let lane = match area { + DiffArea::Unstaged => &repo.worktree_status, + DiffArea::Staged => &repo.staged_status, + }; + let source = match lane { + Loadable::Ready(source) => Some(source), + _ => match &repo.status { + Loadable::Ready(status) => Some(match area { + DiffArea::Unstaged => &status.unstaged, + DiffArea::Staged => &status.staged, + }), + _ => None, + }, + }; + Some(self.status_footer_position.borrow_mut().lookup( + PositionQuery { + repo: repo_id.0, + source, + path, + area, + view, + }, + |section| { + Some(OrderSnapshot { + projection: self.status_section_order(repo, section)?, + plan: self.status_file_plan(repo, section), + }) + }, + || status_navigation_section(repo, path, area, view), + |section, _| { + let order = self.active_status_section_order(repo_id, section); + let target = DiffTarget::working_tree(path.to_owned(), area); + status_navigation_position(repo, &target, view, order.as_deref()) + .map(|(_, index, count)| (index, count)) + }, + )) + } +} + #[derive(Debug)] pub(super) struct StatusNavigationContext<'a> { section: StatusSection, @@ -106,6 +204,8 @@ pub(super) fn status_navigation_section( area: DiffArea, change_tracking_view: ChangeTrackingView, ) -> Option { + #[cfg(test)] + DetailsPaneView::record_status_footer_work(|work| work.section_resolutions += 1); Some(match area { DiffArea::Staged => StatusSection::Staged, DiffArea::Unstaged => match change_tracking_view { @@ -161,6 +261,8 @@ pub(super) fn status_navigation_position( change_tracking_view: ChangeTrackingView, section_order: Option<&[usize]>, ) -> Option<(StatusSection, usize, usize)> { + #[cfg(test)] + DetailsPaneView::record_status_footer_work(|work| work.position_lookups += 1); let DiffTarget::WorkingTree { path, area, .. } = diff_target else { return None; }; @@ -538,8 +640,21 @@ impl DiffRenderer { context: context.into(), }); } - let file_list_source_indices = self.active_file_list_source_indices(repo_id, cx); let change_tracking_view = self.active_change_tracking_view(cx); + if let Some(repo) = self.active_repo() + && let Some(DiffTarget::WorkingTree { path, area, .. }) = + self.bound_diff_state(repo).diff_target.as_ref() + && let Some(position) = self.commands.navigation.as_ref().and_then(|navigation| { + navigation.status_position(repo, path, *area, change_tracking_view, cx) + }) + { + return position.map(|(section, index, count)| DiffFilePlace { + index, + count, + context: status_section_label(section).into(), + }); + } + let file_list_source_indices = self.active_file_list_source_indices(repo_id, cx); let status_section_order = self.active_status_section_order(repo_id, change_tracking_view, cx); let repo = self.active_repo()?; @@ -553,6 +668,28 @@ impl DiffRenderer { ) } + /// The previous footer path, retained for equivalence and allocation + /// measurements through the actual pane/navigation implementation. + #[cfg(test)] + pub(in crate::view) fn uncached_diff_file_place_for_test( + &self, + repo_id: RepoId, + cx: &mut gpui::Context, + ) -> Option { + let file_list_source_indices = self.active_file_list_source_indices(repo_id, cx); + let view = self.active_change_tracking_view(cx); + let order = self.active_status_section_order(repo_id, view, cx); + let repo = self.active_repo()?; + let target = self.bound_diff_state(repo).diff_target.as_ref()?; + diff_file_position_for_repo( + repo, + target, + view, + file_list_source_indices.as_deref(), + order.as_deref(), + ) + } + /// Both the toolbar and its actions use these neighbors in display order. pub(super) fn inline_diff_file_neighbors( &self, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/mod.rs index ba8edf1ef..0225896d5 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/mod.rs @@ -1551,5 +1551,6 @@ mod folder_menus; mod large_file_diff; mod markdown; mod shortcuts; +mod status_footer; mod status_staging; mod text_encoding; diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/status_footer.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/status_footer.rs new file mode 100644 index 000000000..b14e9421f --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/status_footer.rs @@ -0,0 +1,473 @@ +use super::*; +use crate::view::rows::CommitFileSort; +use gitcomet_core::domain::{FileStatus, FileStatusKind}; +use gitcomet_state::model::{Loadable, RepoId, RepoState}; + +fn status_repo(repo_id: RepoId, entries: usize) -> RepoState { + let mut repo = opening_repo_state(repo_id, Path::new("/tmp/status-footer")); + let files: Vec<_> = (0..entries) + .map(|ix| FileStatus { + path: format!("src/feature_{:04}/file_{ix:06}.rs", ix / 20).into(), + kind: if ix % 3 == 0 { + FileStatusKind::Untracked + } else { + FileStatusKind::Modified + }, + conflict: None, + }) + .collect(); + repo.diff_state.diff_target = files + .last() + .map(|file| DiffTarget::working_tree(file.path.clone(), DiffArea::Unstaged)); + repo.diff_state.diff_state_rev = 1; + repo.worktree_status = Loadable::Ready(Arc::new(files)); + repo.worktree_status_rev = 1; + repo +} + +fn fixture( + cx: &mut gpui::TestAppContext, + repo: RepoState, +) -> (gpui::Entity, &mut gpui::VisualTestContext) { + let repo_id = repo.id; + let (store, events) = AppStore::new_test(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + cx.update(|_window, app| { + view.update(app, |this, cx| { + push_test_state(this, app_state_with_repo(repo, repo_id), cx); + }); + }); + draw_and_drain_test_window(cx); + (view, cx) +} + +fn configure( + cx: &mut gpui::VisualTestContext, + root: &gpui::Entity, + view: ChangeTrackingView, + layout: FileListLayout, + sort: CommitFileSort, +) { + cx.update(|_window, app| { + root.update(app, |root, cx| { + root.change_tracking_view = view; + root.details_pane.update(cx, |pane, _| { + pane.change_tracking_view = view; + pane.file_list_layout = layout; + pane.file_list_layout_override.clear(); + for section in [ + StatusSection::CombinedUnstaged, + StatusSection::Untracked, + StatusSection::Unstaged, + StatusSection::Staged, + ] { + pane.status_file_sort.insert(section, sort); + } + }); + }); + }); +} + +fn assert_matches_previous_path( + cx: &mut gpui::VisualTestContext, + root: &gpui::Entity, +) -> Option<(usize, usize)> { + cx.update(|_window, app| { + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo_id = renderer.active_repo_id().expect("active repo"); + let expected = renderer.uncached_diff_file_place_for_test(repo_id, cx); + let actual = renderer.diff_file_place(repo_id, cx); + assert_eq!(actual, expected); + actual.map(|place| (place.index, place.count)) + }) + }) +} + +/// Exercise WindowNavigation and the real DetailsPaneView caches, including +/// projection/plan acquisition. A cache around only the final path scan would +/// still materialize a whole tree order on every call and fail this test. +#[gpui::test] +fn status_footer_warm_caller_does_not_walk_or_rebuild_large_lists(cx: &mut gpui::TestAppContext) { + let (root, cx) = fixture(cx, status_repo(RepoId(714), 12_000)); + for view in [ + ChangeTrackingView::Combined, + ChangeTrackingView::SplitUntracked, + ] { + for layout in [ + FileListLayout::Flat, + FileListLayout::Tree, + FileListLayout::Groups, + ] { + configure(cx, &root, view, layout, CommitFileSort::PathAscending); + let expected = + assert_matches_previous_path(cx, &root).expect("selected file is listed"); + cx.update(|_window, app| { + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo_id = renderer.active_repo_id(); + // Use the actual bar caller, not just the cache helper. + assert_eq!( + renderer + .diff_bar_nav(repo_id, cx) + .and_then(|nav| nav.position), + Some(expected) + ); + DetailsPaneView::take_status_footer_work(); + for _ in 0..20 { + assert_eq!( + renderer + .diff_bar_nav(repo_id, cx) + .and_then(|nav| nav.position), + Some(expected) + ); + } + let work = DetailsPaneView::take_status_footer_work(); + assert_eq!(work, Default::default(), "{view:?} {layout:?}: {work:?}"); + }); + }); + } + } +} + +#[gpui::test] +fn status_footer_invalidates_for_source_sort_layout_and_line_stats(cx: &mut gpui::TestAppContext) { + let repo_id = RepoId(715); + let mut repo = status_repo(repo_id, 4); + let selected = "root.rs"; + repo.worktree_status = Loadable::Ready(Arc::new(vec![ + FileStatus { + path: selected.into(), + kind: FileStatusKind::Modified, + conflict: None, + }, + FileStatus { + path: "src/a.rs".into(), + kind: FileStatusKind::Modified, + conflict: None, + }, + FileStatus { + path: "src/a.rs".into(), + kind: FileStatusKind::Untracked, + conflict: None, + }, + FileStatus { + path: "z.rs".into(), + kind: FileStatusKind::Modified, + conflict: None, + }, + ])); + repo.diff_state.diff_target = Some(DiffTarget::working_tree( + selected.into(), + DiffArea::Unstaged, + )); + let (root, cx) = fixture(cx, repo); + + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Flat, + CommitFileSort::PathAscending, + ); + let ascending = assert_matches_previous_path(cx, &root).unwrap(); + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Flat, + CommitFileSort::PathDescending, + ); + let descending = assert_matches_previous_path(cx, &root).unwrap(); + assert_ne!(ascending.0, descending.0); + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Tree, + CommitFileSort::PathAscending, + ); + let tree = assert_matches_previous_path(cx, &root).unwrap(); + assert_ne!( + ascending.0, tree.0, + "tree hoists the src directory before root.rs" + ); + + // A new immutable source can have the same length. The selected path is + // now untracked, so split tracking must resolve a different section. + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + let repo = &mut state.repos[0]; + let Loadable::Ready(files) = &mut repo.worktree_status else { + panic!("ready source") + }; + Arc::make_mut(files)[0].kind = FileStatusKind::Untracked; + repo.worktree_status_rev += 1; + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + configure( + cx, + &root, + ChangeTrackingView::SplitUntracked, + FileListLayout::Flat, + CommitFileSort::PathAscending, + ); + assert_eq!(assert_matches_previous_path(cx, &root).unwrap().1, 2); + + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Flat, + CommitFileSort::EditSizeDescending, + ); + let before_counts = assert_matches_previous_path(cx, &root).unwrap(); + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + load_unstaged_line_stats( + &mut state.repos[0], + &[("root.rs", 1, 0), ("src/a.rs", 99, 0), ("z.rs", 50, 0)], + ); + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + let after_counts = assert_matches_previous_path(cx, &root).unwrap(); + assert_ne!( + before_counts.0, after_counts.0, + "line-stat-only arrival reorders the list" + ); +} + +#[gpui::test] +fn status_footer_resolved_missing_and_unavailable_provider_are_distinct( + cx: &mut gpui::TestAppContext, +) { + let (root, cx) = fixture(cx, status_repo(RepoId(716), 5)); + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Flat, + CommitFileSort::PathDescending, + ); + assert_eq!(assert_matches_previous_path(cx, &root), Some((0, 5))); + + cx.update(|_window, app| { + let details = root.read(app).details_pane.clone(); + let saved = details.read(app).state.clone(); + let mut mismatched = (*saved).clone(); + let Loadable::Ready(files) = &mut mismatched.repos[0].worktree_status else { + panic!("ready source") + }; + Arc::make_mut(files)[0].path = "different.rs".into(); + mismatched.repos[0].worktree_status_rev += 1; + details.update(app, |pane, _| pane.state = Arc::new(mismatched)); + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo = renderer.active_repo().unwrap(); + let Some(DiffTarget::WorkingTree { path, area, .. }) = + renderer.bound_diff_state(repo).diff_target.as_ref() + else { + panic!("working-tree target") + }; + assert!( + renderer + .commands + .navigation + .as_ref() + .unwrap() + .status_position(repo, path, *area, ChangeTrackingView::Combined, cx) + .is_none(), + "separately updated panes must not reuse different source snapshots" + ); + assert_eq!( + renderer.diff_file_place(repo.id, cx), + renderer.uncached_diff_file_place_for_test(repo.id, cx) + ); + }); + details.update(app, |pane, _| { + pane.state = Arc::new(AppState::test_default()) + }); + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo_id = renderer.active_repo_id().unwrap(); + let fallback = renderer + .diff_file_place(repo_id, cx) + .expect("source-order fallback"); + assert_eq!((fallback.index, fallback.count), (4, 5)); + assert_eq!( + Some(fallback), + renderer.uncached_diff_file_place_for_test(repo_id, cx) + ); + }); + details.update(app, |pane, _| pane.state = saved); + }); + + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + state.repos[0].diff_state.diff_target = Some(DiffTarget::working_tree( + "missing.rs".into(), + DiffArea::Unstaged, + )); + state.repos[0].diff_state.diff_state_rev += 1; + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + assert_eq!(assert_matches_previous_path(cx, &root), None); + cx.update(|_window, app| { + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo_id = renderer.active_repo_id(); + DetailsPaneView::take_status_footer_work(); + assert!(renderer.diff_bar_nav(repo_id, cx).is_none()); + assert_eq!( + DetailsPaneView::take_status_footer_work(), + Default::default(), + "resolved missing must not enter the uncached fallback" + ); + }); + }); +} + +#[gpui::test] +fn status_footer_uses_ready_split_source_then_legacy_fallback(cx: &mut gpui::TestAppContext) { + let repo_id = RepoId(717); + let mut repo = status_repo(repo_id, 3); + let Loadable::Ready(split) = &repo.worktree_status else { + panic!("ready source") + }; + let legacy = Arc::new(vec![split[2].clone()]); + repo.status = Loadable::Ready( + gitcomet_core::domain::RepoStatus { + staged: Arc::new(vec![]), + unstaged: legacy, + } + .into(), + ); + let (root, cx) = fixture(cx, repo); + configure( + cx, + &root, + ChangeTrackingView::Combined, + FileListLayout::Flat, + CommitFileSort::PathAscending, + ); + assert_eq!(assert_matches_previous_path(cx, &root), Some((2, 3))); + for lane in [ + Loadable::Loading, + Loadable::NotLoaded, + Loadable::Error("transient status error".into()), + ] { + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + state.repos[0].worktree_status = lane; + state.repos[0].worktree_status_rev += 1; + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + assert_eq!(assert_matches_previous_path(cx, &root), Some((0, 1))); + } + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + state.repos[0].status = Loadable::NotLoaded; + state.repos[0].status_rev += 1; + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + assert_eq!(assert_matches_previous_path(cx, &root), None); + + // Changing areas resolves the staged source independently of the missing + // worktree lane and must not keep the cached empty result. + cx.update(|_window, app| { + root.update(app, |root, cx| { + let mut state = (*root.state).clone(); + let repo = &mut state.repos[0]; + let Some(DiffTarget::WorkingTree { path, .. }) = repo.diff_state.diff_target.as_ref() + else { + panic!("working-tree target") + }; + let path = path.clone(); + repo.staged_status = Loadable::Ready(Arc::new(vec![FileStatus { + path: path.clone(), + kind: FileStatusKind::Added, + conflict: None, + }])); + repo.staged_status_rev += 1; + repo.diff_state.diff_target = Some(DiffTarget::working_tree(path, DiffArea::Staged)); + repo.diff_state.diff_state_rev += 1; + push_test_state(root, Arc::new(state), cx); + }); + }); + draw_and_drain_test_window(cx); + assert_eq!(assert_matches_previous_path(cx, &root), Some((0, 1))); +} + +/// Measures the real caller and DetailsPaneView cache acquisition, with the old +/// lookup as its baseline in the same process. Run alone with --ignored +/// --nocapture --test-threads=1 in release for timing and allocation evidence. +#[gpui::test] +#[ignore = "timing probe"] +fn timing_status_footer_lookup_with_large_status(cx: &mut gpui::TestAppContext) { + use std::hint::black_box; + use std::time::Instant; + let entries = std::env::var("GITCOMET_PROBE_STATUS_ENTRIES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(20_000); + let (root, cx) = fixture(cx, status_repo(RepoId(718), entries)); + for view in [ + ChangeTrackingView::Combined, + ChangeTrackingView::SplitUntracked, + ] { + for layout in [FileListLayout::Flat, FileListLayout::Tree] { + configure(cx, &root, view, layout, CommitFileSort::PathAscending); + assert_matches_previous_path(cx, &root).expect("selected file is shown"); + cx.update(|_window, app| { + let renderer = root.read(app).diff_renderer.clone(); + renderer.update(app, |renderer, cx| { + let repo_id = renderer.active_repo_id().unwrap(); + let mut cached_ns = Vec::new(); + let mut previous_ns = Vec::new(); + for sample in 0..20 { + let started = Instant::now(); + if sample % 2 == 0 { + black_box(renderer.uncached_diff_file_place_for_test(repo_id, cx)); + previous_ns.push(started.elapsed().as_nanos()); + let started = Instant::now(); + black_box(renderer.diff_file_place(repo_id, cx)); + cached_ns.push(started.elapsed().as_nanos()); + } else { + black_box(renderer.diff_file_place(repo_id, cx)); + cached_ns.push(started.elapsed().as_nanos()); + let started = Instant::now(); + black_box(renderer.uncached_diff_file_place_for_test(repo_id, cx)); + previous_ns.push(started.elapsed().as_nanos()); + } + } + let (_, previous_allocations) = crate::perf_alloc::measure_allocations(|| { + black_box(renderer.uncached_diff_file_place_for_test(repo_id, cx)); + }); + DetailsPaneView::take_status_footer_work(); + let (_, cached_allocations) = crate::perf_alloc::measure_allocations(|| { + black_box(renderer.diff_file_place(repo_id, cx)); + }); + let work = DetailsPaneView::take_status_footer_work(); + assert_eq!(work, Default::default()); + println!("status_footer entries={entries} view={view:?} layout={layout:?} previous_ns={previous_ns:?} cached_ns={cached_ns:?} previous_allocations={previous_allocations:?} cached_allocations={cached_allocations:?} warm_work={work:?}"); + }); + }); + } + } +} diff --git a/crates/gitcomet-ui-gpui/src/view/panes/details.rs b/crates/gitcomet-ui-gpui/src/view/panes/details.rs index 1d696069c..6193fc4d9 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/details.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/details.rs @@ -151,6 +151,9 @@ pub(in super::super) struct DetailsPaneView { /// One slot per status section: three of them render every frame and a /// single slot would have them evicting each other's sort. status_section_order: std::cell::RefCell<[StatusSectionOrderSlot; 4]>, + pub(in crate::view) status_footer_position: std::cell::RefCell< + crate::view::status_position_cache::StatusPositionCache, + >, pub(in super::super) status_file_sort: FxHashMap, /// Sort and filter for the lists without dedicated fields. Repo-keyed like @@ -550,6 +553,7 @@ impl DetailsPaneView { file_list_layout_override: FxHashMap::default(), file_controllers: Default::default(), status_section_order: std::cell::RefCell::new(Default::default()), + status_footer_position: Default::default(), status_file_sort: FxHashMap::default(), list_sort: FxHashMap::default(), list_filter: FxHashMap::default(), @@ -1841,6 +1845,8 @@ impl DetailsPaneView { return Some(Arc::clone(indexes)); } let base = StatusSectionEntries::source_order_indexes(repo, section)?; + #[cfg(test)] + Self::record_status_footer_work(|work| work.projection_builds += 1); let entries = match section { StatusSection::Staged => repo.staged_status_entries()?, _ => repo.worktree_status_entries()?, @@ -2321,6 +2327,8 @@ impl DetailsPaneView { if !plan.reorders() { return Some(projection); } + #[cfg(test)] + Self::record_status_footer_work(|work| work.order_materializations += 1); Some( plan.ordered() .iter() @@ -2363,7 +2371,9 @@ impl DetailsPaneView { let mut controller = controller.borrow_mut(); let collapsed_groups = controller.collapsed_groups().to_vec(); let labels = controller.kind_labels(); - controller.plan_cache.plan_for( + #[cfg(test)] + let previous_builds = controller.plan_cache.builds(); + let plan = controller.plan_cache.plan_for( key, crate::view::rows::PlanShape { layout, @@ -2406,7 +2416,12 @@ impl DetailsPaneView { labels, ) }, - ) + ); + #[cfg(test)] + Self::record_status_footer_work(|work| { + work.plan_builds += controller.plan_cache.builds() - previous_builds; + }); + plan } fn status_section_alignment_key(section: StatusSection) -> u8 { @@ -2423,6 +2438,9 @@ impl DetailsPaneView { .borrow_mut() .retain(|(id, _), _| next.repos.iter().any(|repo| repo.id == *id)); let prev_active_repo_id = self.state.active_repo; + if prev_active_repo_id != next.active_repo || next.active_repo.is_none() { + *self.status_footer_position.borrow_mut() = Default::default(); + } let prev_selected_commit = prev_active_repo_id.and_then(|repo_id| { self.state .repos diff --git a/crates/gitcomet-ui-gpui/src/view/panes/diff/services.rs b/crates/gitcomet-ui-gpui/src/view/panes/diff/services.rs index d7223fbbb..63b945132 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/diff/services.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/diff/services.rs @@ -68,6 +68,18 @@ pub(in crate::view) trait DiffNavigation { ) -> Option>; fn selected_single(&self, repo: RepoId, area: DiffArea, path: &Path, cx: &App) -> bool; fn change_tracking(&self, cx: &App) -> ChangeTrackingView; + /// Outer `None` means the list owner is unavailable, so the renderer can + /// fall back to source order. `Some(None)` is a resolved missing file. + fn status_position( + &self, + _repo: &gitcomet_state::model::RepoState, + _path: &Path, + _area: DiffArea, + _view: ChangeTrackingView, + _cx: &App, + ) -> Option> { + None + } fn status_order(&self, repo: RepoId, section: StatusSection, cx: &App) -> Option>; fn file_order(&self, repo: RepoId, range: bool, cx: &App) -> Option>; fn worktree_order(&self, repo: RepoId, path: &Path, cx: &App) -> Option>; diff --git a/crates/gitcomet-ui-gpui/src/view/status_position_cache.rs b/crates/gitcomet-ui-gpui/src/view/status_position_cache.rs new file mode 100644 index 000000000..607e3721a --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/status_position_cache.rs @@ -0,0 +1,115 @@ +//! One retained status-footer position, keyed by immutable source and order. +//! +//! The caller resolves source readiness before querying. It supplies immutable +//! order inputs rather than a materialized drawn-order vector. A cache hit asks +//! for the previously resolved section's inputs, so split-untracked selection +//! does not search the source just to decide whether a cached result is usable. + +use crate::view::{ChangeTrackingView, StatusSection}; +use gitcomet_core::domain::{DiffArea, FileStatus}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(crate) struct OrderSnapshot

{ + pub(crate) projection: Arc<[usize]>, + pub(crate) plan: Arc

, +} + +impl

Clone for OrderSnapshot

{ + fn clone(&self) -> Self { + Self { + projection: Arc::clone(&self.projection), + plan: Arc::clone(&self.plan), + } + } +} + +impl

OrderSnapshot

{ + fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.projection, &other.projection) && Arc::ptr_eq(&self.plan, &other.plan) + } +} + +struct Entry

{ + repo: u64, + source: Arc>, + path: PathBuf, + area: DiffArea, + view: ChangeTrackingView, + section: Option, + order: Option>, + position: Option<(usize, usize)>, +} + +pub(crate) struct StatusPositionCache

{ + entry: Option>, +} + +impl

Default for StatusPositionCache

{ + fn default() -> Self { + Self { entry: None } + } +} + +pub(crate) struct PositionQuery<'a> { + pub(crate) repo: u64, + pub(crate) source: Option<&'a Arc>>, + pub(crate) path: &'a Path, + pub(crate) area: DiffArea, + pub(crate) view: ChangeTrackingView, +} + +impl

StatusPositionCache

{ + pub(crate) fn lookup( + &mut self, + query: PositionQuery<'_>, + mut order_for: impl FnMut(StatusSection) -> Option>, + resolve_section: impl FnOnce() -> Option, + compute: impl FnOnce(StatusSection, Option<&OrderSnapshot

>) -> Option<(usize, usize)>, + ) -> Option<(StatusSection, usize, usize)> { + let PositionQuery { + repo, + source, + path, + area, + view, + } = query; + let Some(source) = source else { + self.entry = None; + return None; + }; + if let Some(entry) = self.entry.as_ref() + && entry.repo == repo + && Arc::ptr_eq(&entry.source, source) + && entry.area == area + && entry.view == view + && entry.path == path + { + let section = entry.section?; + let order = order_for(section); + let same_order = match (&entry.order, &order) { + (None, None) => true, + (Some(previous), Some(current)) => previous.same(current), + _ => false, + }; + if same_order { + return entry.position.map(|(index, count)| (section, index, count)); + } + } + + let section = resolve_section(); + let order = section.and_then(&mut order_for); + let position = section.and_then(|section| compute(section, order.as_ref())); + self.entry = Some(Entry { + repo, + source: Arc::clone(source), + path: path.to_owned(), + area, + view, + section, + order, + position, + }); + section.and_then(|section| position.map(|(index, count)| (section, index, count))) + } +} From 72ddcda4f7d0ae408d24ed2eb0d932f7eebbf64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:34:41 +0300 Subject: [PATCH 23/67] Wake Windows Git command waiters when the process exits Wait on the child's existing Windows process handle for whole-millisecond polls instead of sleeping after Git has exited. Retain sub-millisecond sleeps and the error fallback, and preserve cancellation, silence deadlines, process-tree cleanup, and separate pipe-worker draining. Measured on this Windows computer with identical optimized build commands: three same-binary control pairs, then two reversed-order sessions with five pairs per case, five warmups and twenty samples per process. Median process medians (baseline -> candidate): - git --version: 39.46 -> 35.21 ms - HEAD lookup: 39.61 -> 36.87 ms - worktree list with activity context: 64.91 -> 62.30 ms - parsed file list: 39.69 -> 37.23 ms - stdin hashing: 39.83 -> 36.92 ms Combined paired median savings were 2.37-4.41 ms; all five combined 95% bootstrap intervals excluded zero, and observed pooled p95 improved. Both sessions' medians improved, though the second session's worktree interval crossed zero. The control differences were under 0.4 ms. No warm drain sample exceeded 1 ms. All outputs and repository/index/ref stamps matched. This measures Git-backed commands; the clean refresh profile runs no CLI commands. CPU measurements were mixed and are not an improvement claim. Validation: 52 shared-runner tests, six Win32 helper tests, affected-package Clippy with warnings denied, workspace formatting, identity literals, source layout, and git diff --check. Raw samples, frozen binaries, source hashes, and commands are under target/performance-investigation/iteration-5. --- crates/gitcomet-git-gix/src/util/process.rs | 17 ++++- crates/gitcomet-git-gix/src/util/tests.rs | 24 ++++++ crates/win32-window-utils/src/lib.rs | 2 +- crates/win32-window-utils/src/process.rs | 84 ++++++++++++++++++++- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/crates/gitcomet-git-gix/src/util/process.rs b/crates/gitcomet-git-gix/src/util/process.rs index 75a0c74a4..2241c9886 100644 --- a/crates/gitcomet-git-gix/src/util/process.rs +++ b/crates/gitcomet-git-gix/src/util/process.rs @@ -376,7 +376,7 @@ pub(super) fn wait_for_child_with_timeout( if let Some(poll) = git_command_wait_poll(start.elapsed(), timeout.saturating_sub(idle)) { - thread::sleep(poll); + wait_for_next_child_poll(child, poll); } } Err(e) => return Err(Error::from(e)), @@ -389,6 +389,21 @@ pub(super) fn wait_for_child_with_timeout( }) } +fn wait_for_next_child_poll(child: &std::process::Child, poll: Duration) { + // Windows can wake when the child exits instead of waiting for the next + // poll. Keep sub-millisecond sleeps and fall back on a wait error so this + // optimization cannot add a new path that abandons the child or its pipes. + #[cfg(windows)] + if poll >= Duration::from_millis(1) + && gitcomet_win32_window_utils::wait_for_process_exit(child, poll).is_ok() + { + return; + } + #[cfg(not(windows))] + let _ = child; + thread::sleep(poll); +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct OutputDrainOutcome { pub(super) cancelled: bool, diff --git a/crates/gitcomet-git-gix/src/util/tests.rs b/crates/gitcomet-git-gix/src/util/tests.rs index a700f94e7..b6d5aa4c9 100644 --- a/crates/gitcomet-git-gix/src/util/tests.rs +++ b/crates/gitcomet-git-gix/src/util/tests.rs @@ -718,6 +718,30 @@ fn run_command_with_timeout_returns_structured_timeout_failure() { } } +#[cfg(windows)] +#[test] +fn windows_chatty_output_extends_silence_deadline_and_preserves_final_bytes() { + let command = shell_command( + "for ($i=0; $i -lt 8; $i++) { [Console]::Out.Write('out;'); [Console]::Error.Write('err;'); Start-Sleep -Milliseconds 500 }; [Console]::Out.Write('final-out'); [Console]::Error.Write('final-err')", + ); + let output = run_command_with_timeout( + command, + "chatty Windows command", + Duration::from_secs(3), + None, + ) + .expect("continued stdout/stderr must keep the command alive"); + assert!(output.status.success()); + assert_eq!( + output.stdout, + format!("{}final-out", "out;".repeat(8)).as_bytes() + ); + assert_eq!( + output.stderr, + format!("{}final-err", "err;".repeat(8)).as_bytes() + ); +} + #[test] fn git_command_wait_poll_is_short_for_fast_commands_and_capped_for_slow_ones() { assert_eq!( diff --git a/crates/win32-window-utils/src/lib.rs b/crates/win32-window-utils/src/lib.rs index c0ed5edf2..fad02d237 100644 --- a/crates/win32-window-utils/src/lib.rs +++ b/crates/win32-window-utils/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::OnceLock; use std::time::Duration; mod process; -pub use process::terminate_process_tree; +pub use process::{terminate_process_tree, wait_for_process_exit}; use windows_sys::Win32::Foundation::{ CloseHandle, FALSE, FILETIME, HANDLE, HWND, LPARAM, POINT, TRUE, WPARAM, diff --git a/crates/win32-window-utils/src/process.rs b/crates/win32-window-utils/src/process.rs index 2d50fa7bc..a12cbe648 100644 --- a/crates/win32-window-utils/src/process.rs +++ b/crates/win32-window-utils/src/process.rs @@ -1,12 +1,92 @@ use std::io; -use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle}; +use std::process::Child; use std::ptr::null; -use windows_sys::Win32::Foundation::{FALSE, WAIT_FAILED}; +use std::time::Duration; +use windows_sys::Win32::Foundation::{FALSE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}; use windows_sys::Win32::System::Threading::{ CREATE_NO_WINDOW, CreateProcessW, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, WaitForSingleObject, }; +fn finite_wait_millis(timeout: Duration) -> u32 { + timeout.as_millis().min(u128::from(INFINITE - 1)) as u32 +} + +/// Wait for a child to exit, using its existing process handle. Returns `false` +/// when the timeout expires. This does not reap the child or retrieve its status. +/// +/// Timeouts are rounded down to whole milliseconds and never become infinite. +/// Callers that need a sub-millisecond delay should use their usual sleep. +pub fn wait_for_process_exit(child: &Child, timeout: Duration) -> io::Result { + let handle = child.as_handle(); + // SAFETY: Child owns this process handle with synchronization access. The + // borrow prevents its owner from being dropped until the bounded wait ends. + match unsafe { WaitForSingleObject(handle.as_raw_handle(), finite_wait_millis(timeout)) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + WAIT_FAILED => Err(io::Error::last_os_error()), + result => Err(io::Error::other(format!( + "unexpected process wait result: {result}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::windows::process::CommandExt as _; + use std::process::{Command, Stdio}; + + #[test] + fn process_wait_timeouts_are_finite_and_never_rounded_up() { + assert_eq!(finite_wait_millis(Duration::ZERO), 0); + assert_eq!(finite_wait_millis(Duration::from_micros(999)), 0); + assert_eq!(finite_wait_millis(Duration::from_millis(1)), 1); + assert_eq!(finite_wait_millis(Duration::from_micros(1999)), 1); + assert_eq!(finite_wait_millis(Duration::MAX), INFINITE - 1); + } + + #[test] + fn process_wait_observes_exit_without_consuming_status() { + let mut child = Command::new("cmd.exe") + .args(["/D", "/C", "exit 7"]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let status = child.wait().unwrap(); + assert!(wait_for_process_exit(&child, Duration::from_secs(1)).unwrap()); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(status.code(), Some(7)); + } + + #[test] + fn process_wait_times_out_while_child_is_alive() { + let mut child = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 30", + ]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let result = wait_for_process_exit(&child, Duration::from_millis(1)); + let still_alive = child.try_wait().unwrap().is_none(); + child.kill().unwrap(); + child.wait().unwrap(); + assert!(!result.unwrap()); + assert!(still_alive); + } +} + /// Run Windows' process-tree termination helper without inheriting any handles. /// In particular, asynchronous cleanup must not retain the caller's output pipes. pub fn terminate_process_tree(process_id: u32) -> io::Result<()> { From 0a60728771057d4b74ff4147ab34e6960828ace9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:48:04 +0300 Subject: [PATCH 24/67] Make activation directory-link fixtures independent of path aliases Resolve only the parent of a nested link when asserting its discovery identity. With macOS /var -> /private/var, discovery records that physical nested-link spelling after resolving the ancestor; the original fixture incorrectly expected its lexical alias. Recreate the ancestor-link topology explicitly on all platforms, including unprivileged Windows junctions. Retain checks for original leaf inputs, outer/nested link identities, target edits, equal-byte retargeting, recursive-link rejection, and exclusion of unrelated large files. Proof: the same frozen original Windows junction test passes with ordinary TEMP/TMP and fails at the CI assertion with child-only TEMP/TMP routed through an owned junction. The corrected executable passes in that exact environment and all 35 activation-related tests pass. State Clippy with warnings denied and source formatting pass. Two privilege-dependent symlink fixtures skip locally. All three macOS native CI jobs reproduced the old assertion; fresh native CI will validate the corrected fixture. Production code is byte-identical. Evidence and source hashes are preserved in target/performance-investigation/iteration-5/activation-ci-proof. --- .../src/store/repo_monitor/activation.rs | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index 5e776dcb7..621a8befd 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -735,9 +735,18 @@ mod tests { fn check_directory_link_inputs(create_link: impl Fn(&Path, &Path) -> bool) { let (dir, mut backend) = fixture(); let external = tempfile::tempdir().unwrap(); - let first = external.path().join("target-a"); - let second = external.path().join("target-b"); - let link = external.path().join("linked"); + // Recreate an ancestor alias such as macOS /var -> /private/var on + // every platform, independently of the host's temporary directory. + let external_root = normalized(&external.path().canonicalize().unwrap()); + let physical_parent = external_root.join("real-parent"); + fs::create_dir(&physical_parent).unwrap(); + let parent_link = external_root.join("parent-link"); + if !create_link(&physical_parent, &parent_link) { + return; + } + let first = physical_parent.join("target-a"); + let second = physical_parent.join("target-b"); + let link = parent_link.join("linked"); for target in [&first, &second] { fs::create_dir(target).unwrap(); fs::write(target.join("config"), "same configuration").unwrap(); @@ -768,7 +777,16 @@ mod tests { MetadataSnapshot::capture(dir.path(), &inputs).unwrap() }; let snapshot = capture(); - assert!(snapshot.discovery.contains_key(&normalized(&link))); + assert!(snapshot.discovery.contains_key(&parent_link)); + for input in [link.join("config"), link.join("missing")] { + assert!(snapshot.discovery.contains_key(&input)); + } + // Discovery resolves ancestor links before recording a nested link. + // Resolve its parent only: canonicalizing the final link would name + // target-a and lose the link identity whose retargeting we are testing. + let physical_link = normalized(&link.parent().unwrap().canonicalize().unwrap()) + .join(link.file_name().unwrap()); + assert!(snapshot.discovery.contains_key(&physical_link)); assert!( snapshot .discovery @@ -825,11 +843,15 @@ mod tests { // Fixed relative fixture names keep this shell invocation independent // of user-profile spelling and require no symlink privilege. let target_name = target.file_name().unwrap().to_str().unwrap(); - assert!(matches!(target_name, "target-a" | "target-b")); - assert_eq!(link.file_name().unwrap(), "linked"); + assert!(matches!( + target_name, + "real-parent" | "target-a" | "target-b" + )); + let link_name = link.file_name().unwrap().to_str().unwrap(); + assert!(matches!(link_name, "parent-link" | "linked")); let output = std::process::Command::new("cmd") .current_dir(link.parent().unwrap()) - .args(["/d", "/c", "mklink", "/J", "linked", target_name]) + .args(["/d", "/c", "mklink", "/J", link_name, target_name]) .output() .unwrap(); assert!(output.status.success(), "junction fixture: {output:?}"); From e0c0356718a496c1341dd89375778edc0bca97d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 13:48:28 +0300 Subject: [PATCH 25/67] Build activation file-link targets with native path separators Construct the spelling-only relative target using Path::new(".").join("second") instead of the literal "./second". Windows stores the relative target without normalizing its separators, leaving the old fixture unreadable when the kernel follows it. Preserve the intended distinct spelling and equal target. Assert the stored raw target, canonical target identity and readable chain bytes before asking activation discovery to snapshot it. Keep the production fallback strict for genuinely unreadable links. Evidence: both Windows x64 and ARM64 CI jobs passed the first target change and failed only after the old spelling-only replacement. Pinned Rust passes the original target directly to CreateSymbolicLinkW; LLVM's Windows symlink implementation explicitly normalizes target separators for this API. All 35 activation-related tests and state Clippy pass locally. Native file-symlink execution still requires CI confirmation because this computer lacks symlink privilege; the fixture explicitly skips in that case. Production code is unchanged. Source snapshots, separate diffs, primary source links and reports are in target/performance-investigation/iteration-5/activation-ci-proof. --- .../src/store/repo_monitor/activation.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index 621a8befd..5446e0425 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -901,10 +901,27 @@ mod tests { // Canonical identity and bytes stay unchanged, but the link's spelling // still changes and must invalidate discovery. fs::remove_file(&middle).unwrap(); + // Windows stores relative symlink targets without normalizing forward + // slashes. Build the alternate spelling with the native separator. + let alternate_target = Path::new(".").join("second"); assert!(gitcomet_core::test_support::symlink::file( - Path::new("./second"), + &alternate_target, &middle )); + assert_eq!( + fs::read_link(&middle).unwrap().as_os_str(), + alternate_target.as_os_str() + ); + assert_eq!( + middle + .canonicalize() + .expect("alternate link target resolves"), + second.canonicalize().unwrap() + ); + assert_eq!( + fs::read(&link).expect("the link chain still reads the same target"), + b"same" + ); let inputs = WatchInputs::load(dir.path(), &backend).unwrap(); let retargeted = ActivationRefresh::prepare(dir.path(), &inputs); assert!(retargeted.is_ok()); From ba8b31b2cf7721120dee51d0188bc9feeb417ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 14:18:30 +0300 Subject: [PATCH 26/67] Cancel obsolete large-file tool probes and reject stale results Keep request cancellation and runtime ownership through completion and window close. Superseded Settings results cannot replace a newer request, including A-to-B-to-A runtime changes. Validation: identical lifecycle cases went from 13 failures and 5 passes to 18 passes; 3 frozen-runtime core tests and scoped UI/core Clippy passed. Native child fixtures confirm cancellation and descendant cleanup. --- crates/gitcomet-core/src/large_file_tools.rs | 29 +- .../src/large_file_tools/runtime_tests.rs | 100 ++++ .../src/view/gitcomet_view.rs | 42 +- .../src/view/large_file_tools_probe.rs | 223 +++++++++ crates/gitcomet-ui-gpui/src/view/mod.rs | 1 + .../src/view/mod_helpers/mod.rs | 6 +- .../src/view/preference_change.rs | 2 +- .../src/view/preference_sync.rs | 5 +- .../src/view/runtime_probe.rs | 4 + .../src/view/settings_window/mod.rs | 15 + .../src/view/settings_window/runtime.rs | 52 +- .../src/view/settings_window/tests.rs | 1 + .../tests/large_file_tools_lifecycle.rs | 199 ++++++++ .../gitcomet-ui-gpui/src/view/state_apply.rs | 1 + crates/gitcomet-ui-gpui/src/view/tests.rs | 2 + .../src/view/tests/large_file_tools_probe.rs | 444 ++++++++++++++++++ 16 files changed, 1082 insertions(+), 44 deletions(-) create mode 100644 crates/gitcomet-core/src/large_file_tools/runtime_tests.rs create mode 100644 crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs create mode 100644 crates/gitcomet-ui-gpui/src/view/settings_window/tests/large_file_tools_lifecycle.rs create mode 100644 crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs diff --git a/crates/gitcomet-core/src/large_file_tools.rs b/crates/gitcomet-core/src/large_file_tools.rs index b224b5838..d214ef5cb 100644 --- a/crates/gitcomet-core/src/large_file_tools.rs +++ b/crates/gitcomet-core/src/large_file_tools.rs @@ -3,7 +3,8 @@ //! PATH, which is what every filter, hook and command will use. use crate::process::{ - bytes_to_text_preserving_utf8, current_git_runtime, git_command_for_preference, probe_output, + GitExecutablePreference, GitRuntimeState, bytes_to_text_preserving_utf8, current_git_runtime, + git_command_for_preference, probe_output, }; use crate::services::CancellationToken; pub use crate::signing_tools::SigningToolAvailability as ToolAvailability; @@ -33,14 +34,27 @@ impl Default for LargeFileToolsState { pub fn detect_large_file_tools_cancellable( cancellation: &CancellationToken, ) -> LargeFileToolsState { - let runtime = current_git_runtime(); + detect_large_file_tools_for_runtime_cancellable(¤t_git_runtime(), cancellation) +} + +/// Probe the runtime captured when the request was created, even if the +/// configured executable changes before its worker starts. +pub fn detect_large_file_tools_for_runtime_cancellable( + runtime: &GitRuntimeState, + cancellation: &CancellationToken, +) -> LargeFileToolsState { + detect_for_runtime_with_factory(runtime, &git_command_for_preference, cancellation) +} + +fn detect_for_runtime_with_factory( + runtime: &GitRuntimeState, + git: &(dyn Fn(&GitExecutablePreference) -> Command + Sync), + cancellation: &CancellationToken, +) -> LargeFileToolsState { if !runtime.is_available() || cancellation.is_cancelled() { return LargeFileToolsState::default(); } - detect_large_file_tools_with( - &|| git_command_for_preference(&runtime.preference), - cancellation, - ) + detect_large_file_tools_with(&|| git(&runtime.preference), cancellation) } /// Injectable factory for deterministic tests. @@ -157,3 +171,6 @@ mod tests { assert_eq!(state, LargeFileToolsState::default()); } } + +#[cfg(test)] +mod runtime_tests; diff --git a/crates/gitcomet-core/src/large_file_tools/runtime_tests.rs b/crates/gitcomet-core/src/large_file_tools/runtime_tests.rs new file mode 100644 index 000000000..827805c36 --- /dev/null +++ b/crates/gitcomet-core/src/large_file_tools/runtime_tests.rs @@ -0,0 +1,100 @@ +use super::*; +use crate::process::GitExecutableAvailability; +use std::path::PathBuf; +use std::sync::Mutex; + +fn available_runtime() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::from("captured-git-A")), + availability: GitExecutableAvailability::Available { + version_output: "git version 2.50.0".into(), + }, + } +} + +#[test] +#[ignore = "native subprocess fixture for frozen-runtime detector validation"] +fn frozen_runtime_native_child() { + let root = PathBuf::from(std::env::var_os("GITCOMET_FROZEN_TOOL_FIXTURE").unwrap()); + let args: Vec<_> = std::env::args().collect(); + let tool = args + .iter() + .find(|arg| *arg == "lfs" || *arg == "annex") + .unwrap(); + assert!(args.iter().any(|arg| arg == "version")); + if tool == "annex" { + assert!(args.iter().any(|arg| arg == "--raw")); + } + std::fs::write(root.join(tool), b"invoked").unwrap(); + println!("fixture-{tool}-version"); +} + +#[test] +fn frozen_runtime_is_passed_to_both_native_subcommands() { + let captured = available_runtime(); + let dir = tempfile::tempdir().unwrap(); + let preferences = Mutex::new(Vec::new()); + let state = detect_for_runtime_with_factory( + &captured, + &|preference| { + preferences.lock().unwrap().push(preference.clone()); + let mut command = crate::process::background_command(std::env::current_exe().unwrap()); + command + .args([ + "--ignored", + "--exact", + "large_file_tools::runtime_tests::frozen_runtime_native_child", + "--nocapture", + "--", + ]) + .env("GITCOMET_FROZEN_TOOL_FIXTURE", dir.path()); + command + }, + &CancellationToken::new(), + ); + assert_eq!( + *preferences.lock().unwrap(), + vec![captured.preference.clone(), captured.preference] + ); + assert!(dir.path().join("lfs").exists()); + assert!(dir.path().join("annex").exists()); + assert!(matches!(state.git_lfs, ToolAvailability::Available { .. })); + assert!(matches!( + state.git_annex, + ToolAvailability::Available { .. } + )); +} + +#[test] +fn unavailable_frozen_runtime_never_constructs_commands() { + let mut runtime = available_runtime(); + runtime.availability = GitExecutableAvailability::Checking; + let token = CancellationToken::new(); + assert_eq!( + detect_for_runtime_with_factory( + &runtime, + &|_| panic!("unavailable runtime spawned"), + &token + ), + LargeFileToolsState::default(), + ); + assert_eq!( + detect_large_file_tools_for_runtime_cancellable(&runtime, &token), + LargeFileToolsState::default() + ); +} + +#[test] +fn cancelled_frozen_runtime_never_constructs_commands() { + let runtime = available_runtime(); + let token = CancellationToken::new(); + token.cancel(); + assert_eq!( + detect_for_runtime_with_factory(&runtime, &|_| panic!("cancelled request spawned"), &token), + LargeFileToolsState::default(), + ); + assert_eq!( + detect_large_file_tools_for_runtime_cancellable(&runtime, &token), + LargeFileToolsState::default() + ); +} diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs index 778c3f40b..7807c6e11 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs @@ -1277,6 +1277,14 @@ impl GitCometView { .update(cx, |host, cx| host.error_notices_changed(errors, cx)); }); + let closing_view = cx.weak_entity(); + let probe_window = window.window_handle().window_id(); + let large_file_tools_window_closed = cx.on_window_closed(move |cx, closed| { + if closed == probe_window { + let _ = closing_view.update(cx, |view, _| view.close_large_file_tools_probe()); + } + }); + let mut view = Self { state: Arc::clone(&initial_state), window_handle: window.window_handle(), @@ -1358,7 +1366,10 @@ impl GitCometView { signing_tools_probe_seq: 0, signing_tools_probe_in_flight: false, signing_tools_probe_cancellation: Default::default(), - large_file_tools_probe_in_flight: false, + large_file_tools_probe: Default::default(), + _large_file_tools_window_closed: large_file_tools_window_closed, + #[cfg(test)] + large_file_tools_detector_for_test: None, date_time_format, timezone, show_timezone, @@ -2669,35 +2680,6 @@ impl GitCometView { self.signing_tools_probe_in_flight = false; } - /// Probe `git lfs` / `git annex` once per Git runtime. Not gated by any - /// preference: the answer decides whether their commands are offered. - pub(super) fn refresh_large_file_tools(&mut self, cx: &mut gpui::Context) { - if cfg!(test) - || self.large_file_tools_probe_in_flight - || !current_git_runtime().is_available() - { - return; - } - self.large_file_tools_probe_in_flight = true; - let runtime = current_git_runtime(); - let detection = - cx.background_spawn(async move { - gitcomet_core::large_file_tools::detect_large_file_tools_cancellable( - &Default::default(), - ) - }); - cx.spawn(async move |view, cx| { - let tools = detection.await; - let _ = view.update(cx, |this, _cx| { - this.large_file_tools_probe_in_flight = false; - if current_git_runtime() == runtime { - this.store.dispatch(Msg::SetLargeFileToolsState(tools)); - } - }); - }) - .detach(); - } - /// Discover signing tools after opt-in, on opted-in startup, or diagnostics. pub(super) fn refresh_signing_tools(&mut self, force: bool, cx: &mut gpui::Context) { if cfg!(test) diff --git a/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs new file mode 100644 index 000000000..434d74068 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs @@ -0,0 +1,223 @@ +//! Main-window large-file tool discovery. +use super::*; +use gitcomet_core::large_file_tools::LargeFileToolsState; +use gitcomet_core::process::GitRuntimeState; +use gitcomet_core::services::CancellationToken; + +/// Validity for deferred Settings publications, independent of worker lifetime. +#[derive(Default)] +struct SettingsToolProbeGeneration(u64); + +impl gpui::Global for SettingsToolProbeGeneration {} + +pub(super) fn settings_tool_probe_generation(cx: &App) -> u64 { + cx.try_global::() + .map_or(0, |generation| generation.0) +} + +pub(super) fn supersede_settings_tool_probe(cx: &mut App) -> u64 { + let generation = settings_tool_probe_generation(cx).wrapping_add(1); + cx.set_global(SettingsToolProbeGeneration(generation)); + generation +} + +#[derive(Clone, Debug)] +pub(super) struct LargeFileToolsPublication { + pub(super) runtime: GitRuntimeState, + pub(super) generation: u64, + pub(super) tools: LargeFileToolsState, +} + +/// One window's request lifetime; results continue to live in AppState. +#[derive(Default)] +pub(super) struct LargeFileToolsProbeOwner { + sequence: u64, + closed: bool, + runtime: Option, + cancellation: Option, +} + +impl LargeFileToolsProbeOwner { + fn cancel(&mut self) { + if let Some(cancellation) = self.cancellation.take() { + cancellation.cancel(); + } + self.sequence = self.sequence.wrapping_add(1); + self.runtime = None; + } + + fn invalidate_for_runtime(&mut self, runtime: &GitRuntimeState) { + if self + .runtime + .as_ref() + .is_some_and(|previous| previous != runtime) + { + self.cancel(); + } + } + + fn begin(&mut self, runtime: GitRuntimeState) -> Option { + if self.closed || self.runtime.as_ref() == Some(&runtime) { + return None; + } + self.cancel(); + let cancellation = CancellationToken::new(); + self.runtime = Some(runtime.clone()); + self.cancellation = Some(cancellation.clone()); + Some(LargeFileToolsProbeRequest { + runtime, + cancellation, + sequence: self.sequence, + }) + } + + fn close(&mut self) { + self.closed = true; + self.cancel(); + } + + fn settle(&mut self, runtime: GitRuntimeState) -> bool { + if self.closed { + return false; + } + self.cancel(); + // Keep the completed runtime while its store publication is pending: + // an older NotChecked snapshot must not start another request. + self.runtime = Some(runtime); + true + } + + fn finish(&mut self, request: &LargeFileToolsProbeRequest, runtime: &GitRuntimeState) -> bool { + if self.sequence != request.sequence + || self.runtime.as_ref() != Some(&request.runtime) + || runtime != &request.runtime + || self.cancellation.is_none() + || request.cancellation.is_cancelled() + { + return false; + } + self.cancellation = None; + true + } +} + +impl Drop for LargeFileToolsProbeOwner { + fn drop(&mut self) { + self.cancel(); + } +} + +#[derive(Clone)] +pub(super) struct LargeFileToolsProbeRequest { + pub(super) runtime: GitRuntimeState, + pub(super) cancellation: CancellationToken, + sequence: u64, +} + +#[cfg(test)] +pub(super) struct HeldLargeFileToolsDetector { + pub(super) runtime: GitRuntimeState, + pub(super) requests: Vec, +} + +impl GitCometView { + pub(super) fn close_large_file_tools_probe(&mut self) { + self.large_file_tools_probe.close(); + } + + pub(super) fn cancel_large_file_tools_probe(&mut self) { + self.large_file_tools_probe.cancel(); + } + + pub(super) fn invalidate_large_file_tools_probe_for_runtime( + &mut self, + runtime: &GitRuntimeState, + ) { + self.large_file_tools_probe.invalidate_for_runtime(runtime); + } + + pub(super) fn reconcile_large_file_tools_runtime(&mut self) { + let runtime = self.large_file_tools_runtime(); + // A newer request or Settings result can arrive before its store snapshot. + if self.state.git_runtime == runtime { + self.large_file_tools_probe.invalidate_for_runtime(&runtime); + } + } + + pub(super) fn accept_large_file_tools_publication( + &mut self, + publication: LargeFileToolsPublication, + cx: &App, + ) { + if publication.runtime != self.large_file_tools_runtime() + || publication.generation != settings_tool_probe_generation(cx) + { + return; + } + if self.large_file_tools_probe.settle(publication.runtime) { + self.store + .dispatch(Msg::SetLargeFileToolsState(publication.tools)); + } + } + + fn large_file_tools_runtime(&self) -> GitRuntimeState { + #[cfg(test)] + if let Some(driver) = &self.large_file_tools_detector_for_test { + return driver.runtime.clone(); + } + current_git_runtime() + } + + /// Probe `git lfs` / `git annex` once per Git runtime. Not gated by any + /// preference: the answer decides whether their commands are offered. + pub(super) fn refresh_large_file_tools(&mut self, cx: &mut gpui::Context) { + #[cfg(test)] + if self.large_file_tools_detector_for_test.is_none() { + return; + } + let runtime = self.large_file_tools_runtime(); + if self.state.git_runtime != runtime { + return; + } + if !runtime.is_available() { + self.cancel_large_file_tools_probe(); + return; + } + let Some(request) = self.large_file_tools_probe.begin(runtime) else { + return; + }; + // The test driver holds the same request and releases it through the + // production completion method. Ordinary tests never enable discovery. + #[cfg(test)] + if let Some(driver) = &mut self.large_file_tools_detector_for_test { + driver.requests.push(request); + return; + } + let cancellation = request.cancellation.clone(); + let runtime = request.runtime.clone(); + let detection = cx.background_spawn(async move { + gitcomet_core::large_file_tools::detect_large_file_tools_for_runtime_cancellable( + &runtime, + &cancellation, + ) + }); + cx.spawn(async move |view, cx| { + let tools = detection.await; + let _ = view.update(cx, |this, _cx| { + this.complete_large_file_tools_probe(&request, tools); + }); + }) + .detach(); + } + + pub(super) fn complete_large_file_tools_probe( + &mut self, + request: &LargeFileToolsProbeRequest, + tools: LargeFileToolsState, + ) { + let runtime = self.large_file_tools_runtime(); + if self.large_file_tools_probe.finish(request, &runtime) { + self.store.dispatch(Msg::SetLargeFileToolsState(tools)); + } + } +} diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index aaeff3a0b..ad3cbbdd1 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -869,6 +869,7 @@ mod gitcomet_view; mod gitcomet_view_commands; mod gitcomet_view_render; mod gitcomet_view_requests; +mod large_file_tools_probe; mod runtime_probe; #[cfg(test)] diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs index e6f46deb5..261afae33 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs @@ -1012,7 +1012,11 @@ pub struct GitCometView { pub(super) signing_tools_probe_in_flight: bool, pub(super) signing_tools_probe_cancellation: gitcomet_core::services::CancellationToken, /// Background `git lfs` / `git annex` detection; rerun after a Git change. - pub(super) large_file_tools_probe_in_flight: bool, + pub(super) large_file_tools_probe: super::large_file_tools_probe::LargeFileToolsProbeOwner, + pub(super) _large_file_tools_window_closed: gpui::Subscription, + #[cfg(test)] + pub(super) large_file_tools_detector_for_test: + Option, pub(super) date_time_format: DateTimeFormat, pub(super) timezone: Timezone, diff --git a/crates/gitcomet-ui-gpui/src/view/preference_change.rs b/crates/gitcomet-ui-gpui/src/view/preference_change.rs index db8643e6c..87c0278d0 100644 --- a/crates/gitcomet-ui-gpui/src/view/preference_change.rs +++ b/crates/gitcomet-ui-gpui/src/view/preference_change.rs @@ -58,5 +58,5 @@ pub(in crate::view) enum PreferenceChange { ResetHistoryColumnWidths, /// A new Git executable was probed; the window's signing probe restarts. GitRuntime(gitcomet_core::process::GitRuntimeState), - LargeFileTools(gitcomet_core::large_file_tools::LargeFileToolsState), + LargeFileTools(super::large_file_tools_probe::LargeFileToolsPublication), } diff --git a/crates/gitcomet-ui-gpui/src/view/preference_sync.rs b/crates/gitcomet-ui-gpui/src/view/preference_sync.rs index 594614e34..346f4ba36 100644 --- a/crates/gitcomet-ui-gpui/src/view/preference_sync.rs +++ b/crates/gitcomet-ui-gpui/src/view/preference_sync.rs @@ -1020,11 +1020,12 @@ impl GitCometView { } PreferenceChange::ResetHistoryColumnWidths => self.reset_history_column_widths(cx), PreferenceChange::GitRuntime(runtime) => { + self.cancel_large_file_tools_probe(); self.cancel_signing_tools_probe(); self.store.dispatch(Msg::SetGitRuntimeState(runtime)); } - PreferenceChange::LargeFileTools(tools) => { - self.store.dispatch(Msg::SetLargeFileToolsState(tools)); + PreferenceChange::LargeFileTools(publication) => { + self.accept_large_file_tools_publication(publication, cx); } } } diff --git a/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs b/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs index ccd1ad666..d7b90786a 100644 --- a/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/runtime_probe.rs @@ -42,6 +42,9 @@ fn reports_to_store(refresh_all: bool, store: &GitRuntimeState, runtime: &GitRun } pub(super) fn request(cx: &mut gpui::App, force: bool) { + if force { + super::large_file_tools_probe::supersede_settings_tool_probe(cx); + } if cfg!(test) { return; } @@ -94,6 +97,7 @@ pub(super) fn request(cx: &mut gpui::App, force: bool) { ) { return; } + view.invalidate_large_file_tools_probe_for_runtime(&runtime); view.store .dispatch(Msg::SetGitRuntimeState(runtime.clone())); view.refresh_signing_tools(true, cx); diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs index ff6140b0d..16bfdb74c 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs @@ -693,6 +693,8 @@ pub(crate) struct SettingsWindowView { signing_tools_probe: Option>, signing_tools_cancellation: gitcomet_core::services::CancellationToken, large_file_tools_probe: Option>, + large_file_tools_closed: bool, + _large_file_tools_window_closed: gpui::Subscription, large_file_tools_cancellation: gitcomet_core::services::CancellationToken, git_executable_mode: GitExecutableMode, git_custom_path_draft: String, @@ -1375,6 +1377,17 @@ impl SettingsWindowView { workspace_name_input.update(cx, |input, cx| { input.set_text(workspace_name_draft.clone(), cx); }); + let closing_view = cx.weak_entity(); + let probe_window = window.window_handle().window_id(); + let large_file_tools_window_closed = cx.on_window_closed(move |cx, closed| { + if closed == probe_window { + let _ = closing_view.update(cx, |view, _| { + view.large_file_tools_closed = true; + view.large_file_tools_cancellation.cancel(); + view.large_file_tools_probe = None; + }); + } + }); Self { extension_window: extension_host::SettingsExtensions::new(window, theme, cx), window_gates: super::window_gates::WindowGates::new(cx), @@ -1463,6 +1476,8 @@ impl SettingsWindowView { signing_tools_probe, signing_tools_cancellation: Default::default(), large_file_tools_probe: None, + large_file_tools_closed: false, + _large_file_tools_window_closed: large_file_tools_window_closed, large_file_tools_cancellation: Default::default(), git_executable_mode, git_custom_path_draft, diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/runtime.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/runtime.rs index bfbc9eca6..29321cf57 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/runtime.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/runtime.rs @@ -80,6 +80,7 @@ impl SettingsWindowView { runtime: GitRuntimeState, cx: &mut gpui::Context, ) { + super::super::large_file_tools_probe::supersede_settings_tool_probe(cx); self.git_executable_mode = GitExecutableMode::from_preference(&runtime.preference); if let GitExecutablePreference::Custom(path) = &runtime.preference { let next_draft = if path.as_os_str().is_empty() { @@ -99,6 +100,8 @@ impl SettingsWindowView { // A different Git resolves gpg, ssh-keygen, git-lfs and git-annex with // a different PATH. self.cancel_signing_tools_probe(); + self.large_file_tools_cancellation.cancel(); + self.large_file_tools_probe = None; self.runtime_info.large_file_tools = Some(Default::default()); self.persist_preferences(cx); self.broadcast_preference(PreferenceChange::GitRuntime(runtime), cx); @@ -138,6 +141,10 @@ impl SettingsWindowView { } pub(super) fn refresh_large_file_tools(&mut self, cx: &mut gpui::Context) { + if self.large_file_tools_closed { + return; + } + let generation = super::super::large_file_tools_probe::supersede_settings_tool_probe(cx); self.large_file_tools_cancellation.cancel(); self.large_file_tools_probe = None; self.runtime_info.large_file_tools = Some(Default::default()); @@ -147,30 +154,67 @@ impl SettingsWindowView { self.large_file_tools_cancellation = Default::default(); let cancellation = self.large_file_tools_cancellation.clone(); let runtime = current_git_runtime(); + let worker_runtime = runtime.clone(); + let worker_cancellation = cancellation.clone(); self.runtime_info.large_file_tools = None; let detection = cx.background_spawn(async move { - gitcomet_core::large_file_tools::detect_large_file_tools_cancellable(&cancellation) + gitcomet_core::large_file_tools::detect_large_file_tools_for_runtime_cancellable( + &worker_runtime, + &worker_cancellation, + ) }); self.large_file_tools_probe = Some(cx.spawn(async move |view, cx| { let tools = detection.await; let _ = view.update(cx, |this, cx| { - if current_git_runtime() != runtime { + if cancellation.is_cancelled() || current_git_runtime() != runtime { return; } - this.apply_large_file_tools_probe(tools, cx); + this.apply_large_file_tools_probe_for_request(runtime, generation, tools, cx); }); })); } /// Main windows probe once per Git runtime; a recheck here is how they /// learn a tool was installed or removed since. + #[cfg(test)] pub(super) fn apply_large_file_tools_probe( &mut self, tools: gitcomet_core::large_file_tools::LargeFileToolsState, cx: &mut gpui::Context, ) { + self.apply_large_file_tools_probe_for_request( + current_git_runtime(), + super::super::large_file_tools_probe::settings_tool_probe_generation(cx), + tools, + cx, + ); + } + + pub(super) fn apply_large_file_tools_probe_for_request( + &mut self, + runtime: GitRuntimeState, + generation: u64, + tools: gitcomet_core::large_file_tools::LargeFileToolsState, + cx: &mut gpui::Context, + ) { + if self.large_file_tools_closed + || current_git_runtime() != runtime + || generation + != super::super::large_file_tools_probe::settings_tool_probe_generation(cx) + { + return; + } self.runtime_info.large_file_tools = Some(tools.clone()); - self.broadcast_preference(PreferenceChange::LargeFileTools(tools), cx); + self.broadcast_preference( + PreferenceChange::LargeFileTools( + super::super::large_file_tools_probe::LargeFileToolsPublication { + runtime, + generation, + tools, + }, + ), + cx, + ); cx.notify(); } diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs index 66182de6d..6e0f16b39 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs @@ -32,6 +32,7 @@ mod appearance; mod chrome_and_layout; mod executables; mod extensions; +mod large_file_tools_lifecycle; mod preferences; mod restoration; mod terminal; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests/large_file_tools_lifecycle.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests/large_file_tools_lifecycle.rs new file mode 100644 index 000000000..a6f497652 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/tests/large_file_tools_lifecycle.rs @@ -0,0 +1,199 @@ +use super::*; +use crate::view::large_file_tools_probe::settings_tool_probe_generation; +use gitcomet_core::large_file_tools::{LargeFileToolsState, ToolAvailability}; + +fn tools(version: &str) -> LargeFileToolsState { + LargeFileToolsState { + git_lfs: ToolAvailability::Available { + version: Some(version.into()), + }, + git_annex: ToolAvailability::Unknown, + } +} + +fn fixture( + cx: &mut gpui::TestAppContext, +) -> ( + gpui::Entity, + gpui::WindowHandle, + &mut gpui::VisualTestContext, +) { + let (store, events) = AppStore::new_test(std::sync::Arc::new(TestBackend)); + let (main, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + crate::view::test_support::drain_store_worker(&main, cx); + cx.update(|_, app| open_settings_window(app)); + cx.run_until_parked(); + let settings = cx.update(|_, app| { + app.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + (main, settings, cx) +} + +fn stored_tools( + main: &gpui::Entity, + cx: &mut gpui::VisualTestContext, +) -> LargeFileToolsState { + cx.run_until_parked(); + crate::view::test_support::drain_store_worker(main, cx); + cx.update(|_, app| main.read(app).store.snapshot().large_file_tools.clone()) +} + +#[gpui::test] +fn large_file_probe_settings_completed_publication_survives_window_close( + cx: &mut gpui::TestAppContext, +) { + let (main, settings, cx) = fixture(cx); + let worker_token = settings + .update(cx, |settings, window, cx| { + let token = settings.large_file_tools_cancellation.clone(); + settings.apply_large_file_tools_probe(tools("completed before close"), cx); + // The detached broadcast has not run yet; this destroys its sender first. + window.remove_window(); + token + }) + .unwrap(); + assert!( + worker_token.is_cancelled(), + "Settings must actually have dropped" + ); + assert_eq!(stored_tools(&main, cx), tools("completed before close")); +} + +#[gpui::test] +fn large_file_probe_settings_forced_recheck_supersedes_queued_publication( + cx: &mut gpui::TestAppContext, +) { + let (main, settings, cx) = fixture(cx); + settings + .update(cx, |settings, _, cx| { + settings.apply_large_file_tools_probe(tools("old completed request"), cx); + crate::view::runtime_probe::request(cx, true); + }) + .unwrap(); + assert_eq!(stored_tools(&main, cx), LargeFileToolsState::default()); + settings + .update(cx, |settings, _, cx| { + settings.apply_large_file_tools_probe(tools("new completed request"), cx); + }) + .unwrap(); + assert_eq!(stored_tools(&main, cx), tools("new completed request")); +} + +#[gpui::test] +fn large_file_probe_reopened_settings_supersedes_queued_publication(cx: &mut gpui::TestAppContext) { + let (main, settings, cx) = fixture(cx); + settings + .update(cx, |settings, window, cx| { + settings.apply_large_file_tools_probe(tools("closed Settings request"), cx); + window.remove_window(); + }) + .unwrap(); + cx.update(|_, app| open_settings_window(app)); + let replacement = cx.update(|_, app| { + app.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + replacement + .update(cx, |settings, _, cx| { + settings.refresh_large_file_tools(cx); + }) + .unwrap(); + assert_eq!(stored_tools(&main, cx), LargeFileToolsState::default()); + replacement + .update(cx, |settings, _, cx| { + settings.apply_large_file_tools_probe(tools("reopened Settings request"), cx); + }) + .unwrap(); + assert_eq!(stored_tools(&main, cx), tools("reopened Settings request")); +} + +#[gpui::test] +fn large_file_probe_settings_return_to_same_runtime_rejects_old_completion( + cx: &mut gpui::TestAppContext, +) { + let (main, settings, cx) = fixture(cx); + let old_token = settings + .update(cx, |settings, _, cx| { + let runtime_a = current_git_runtime(); + let generation = settings_tool_probe_generation(cx); + let token = settings.large_file_tools_cancellation.clone(); + let mut runtime_b = runtime_a.clone(); + runtime_b.preference = GitExecutablePreference::Custom(PathBuf::from("fixture-B")); + settings.sync_git_runtime_state(runtime_b, cx); + settings.sync_git_runtime_state(runtime_a.clone(), cx); + // Release the original worker only after A -> B -> A. Runtime value + // equality cannot distinguish it from the replacement request. + settings.apply_large_file_tools_probe_for_request( + runtime_a, + generation, + tools("obsolete A worker"), + cx, + ); + token + }) + .unwrap(); + assert_eq!(stored_tools(&main, cx), LargeFileToolsState::default()); + assert!( + old_token.is_cancelled(), + "Settings left the old worker running" + ); + settings + .update(cx, |settings, _, _| { + assert_eq!( + settings.runtime_info.large_file_tools, + Some(LargeFileToolsState::default()) + ); + }) + .unwrap(); +} + +#[gpui::test] +fn large_file_probe_settings_generation_starts_fresh_for_each_app(cx: &mut gpui::TestAppContext) { + assert_eq!(cx.update(|app| settings_tool_probe_generation(app)), 0); + cx.update(|app| crate::view::runtime_probe::request(app, true)); + assert_eq!(cx.update(|app| settings_tool_probe_generation(app)), 1); +} + +#[gpui::test] +fn large_file_probe_settings_close_cancels_while_entity_is_retained(cx: &mut gpui::TestAppContext) { + let (main, settings, cx) = fixture(cx); + let (retained, token, generation) = settings + .update(cx, |settings, _, cx| { + // Ordinary Settings tests intentionally do not spawn workers. This is + // its actual cancellation token, used by the production worker. + ( + cx.entity(), + settings.large_file_tools_cancellation.clone(), + settings_tool_probe_generation(cx), + ) + }) + .unwrap(); + assert!(!token.is_cancelled()); + settings + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + assert!( + token.is_cancelled(), + "closed Settings left its worker token live" + ); + cx.update(|_, app| { + retained.update(app, |settings, cx| { + settings.refresh_large_file_tools(cx); + settings.apply_probed_runtime(current_git_runtime(), cx); + settings.apply_large_file_tools_probe(tools("closed Settings completion"), cx); + assert!(settings.large_file_tools_probe.is_none()); + }); + assert_eq!( + settings_tool_probe_generation(app), + generation, + "closed Settings restarted discovery" + ); + }); + assert_eq!(stored_tools(&main, cx), LargeFileToolsState::default()); +} diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index 85143358c..a3bb2153c 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -634,6 +634,7 @@ impl GitCometView { { self.refresh_signing_tools(false, cx); } + self.reconcile_large_file_tools_runtime(); if matches!( self.state.large_file_tools.git_lfs, gitcomet_core::signing_tools::SigningToolAvailability::NotChecked diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index c5a1b198f..07100d8e4 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -364,3 +364,5 @@ mod selection; mod sidebar_file_history_tab; mod sidebar_layout; mod window_lifecycle; + +mod large_file_tools_probe; diff --git a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs new file mode 100644 index 000000000..31c5a41c7 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs @@ -0,0 +1,444 @@ +use super::*; +use crate::view::large_file_tools_probe::{ + HeldLargeFileToolsDetector, LargeFileToolsProbeRequest, LargeFileToolsPublication, + settings_tool_probe_generation, +}; +use crate::view::preference_change::PreferenceChange; +use gitcomet_core::large_file_tools::{LargeFileToolsState, ToolAvailability}; + +fn runtime(name: &str) -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::from(name)), + availability: GitExecutableAvailability::Available { + version_output: "git version 2.50.0".into(), + }, + } +} + +fn tools(version: &str) -> LargeFileToolsState { + LargeFileToolsState { + git_lfs: ToolAvailability::Available { + version: Some(version.into()), + }, + git_annex: ToolAvailability::Unknown, + } +} + +fn fixture(cx: &mut gpui::TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { + let (store, events) = AppStore::new_test(Arc::new(TestBackend)); + store.dispatch(Msg::SetGitRuntimeState(runtime("A"))); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + test_support::drain_store_worker(&view, cx); + view.update(cx, |view, cx| { + view.large_file_tools_detector_for_test = Some(HeldLargeFileToolsDetector { + runtime: runtime("A"), + requests: Vec::new(), + }); + view.apply_state_snapshot(view.store.snapshot(), cx); + }); + assert_eq!(requests(&view, cx).len(), 1); + (view, cx) +} + +fn requests( + view: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + cx.update(|_, app| { + view.read(app) + .large_file_tools_detector_for_test + .as_ref() + .unwrap() + .requests + .clone() + }) +} + +fn change_runtime( + view: &Entity, + cx: &mut gpui::VisualTestContext, + runtime: GitRuntimeState, +) { + cx.update(|window, app| { + view.update(app, |view, cx| { + view.large_file_tools_detector_for_test + .as_mut() + .unwrap() + .runtime = runtime.clone(); + view.apply_preference_change(&PreferenceChange::GitRuntime(runtime), window, cx); + }); + }); +} + +fn publish_store(view: &Entity, cx: &mut gpui::VisualTestContext) { + test_support::drain_store_worker(view, cx); + view.update(cx, |view, cx| { + view.apply_state_snapshot(view.store.snapshot(), cx); + }); +} + +fn accept_settings_tools( + view: &Entity, + cx: &mut gpui::VisualTestContext, + tools: LargeFileToolsState, +) { + cx.update(|window, app| { + view.update(app, |view, cx| { + let publication = LargeFileToolsPublication { + runtime: view + .large_file_tools_detector_for_test + .as_ref() + .unwrap() + .runtime + .clone(), + generation: settings_tool_probe_generation(cx), + tools, + }; + view.apply_preference_change( + &PreferenceChange::LargeFileTools(publication), + window, + cx, + ); + }); + }); +} + +fn stored_tools( + view: &Entity, + cx: &mut gpui::VisualTestContext, +) -> LargeFileToolsState { + test_support::drain_store_worker(view, cx); + cx.update(|_, app| view.read(app).store.snapshot().large_file_tools.clone()) +} + +#[gpui::test] +fn large_file_probe_runtime_change_cancels_the_owned_request(cx: &mut gpui::TestAppContext) { + let (view, cx) = fixture(cx); + let old = requests(&view, cx)[0].clone(); + change_runtime(&view, cx, runtime("B")); + assert!( + old.cancellation.is_cancelled(), + "Git changed but A still runs" + ); +} + +#[gpui::test] +fn large_file_probe_new_runtime_starts_before_old_completion(cx: &mut gpui::TestAppContext) { + let (view, cx) = fixture(cx); + change_runtime(&view, cx, runtime("B")); + publish_store(&view, cx); + let requests = requests(&view, cx); + assert_eq!(requests.len(), 2, "B must not wait for A's timeout"); + assert_eq!(requests[1].runtime, runtime("B")); + assert!(!requests[1].cancellation.is_cancelled()); +} + +#[gpui::test] +fn large_file_probe_return_to_same_runtime_rejects_old_generation(cx: &mut gpui::TestAppContext) { + let (view, cx) = fixture(cx); + let old = requests(&view, cx)[0].clone(); + // Both preferences reach the view before its store snapshots are applied. + change_runtime(&view, cx, runtime("B")); + change_runtime(&view, cx, runtime("A")); + publish_store(&view, cx); + view.update(cx, |view, _| { + view.complete_large_file_tools_probe(&old, tools("obsolete A")); + }); + assert_eq!(stored_tools(&view, cx), LargeFileToolsState::default()); + let pending = requests(&view, cx); + assert_eq!(pending.len(), 2); + assert!(!pending[1].cancellation.is_cancelled()); + view.update(cx, |view, _| { + view.complete_large_file_tools_probe(&pending[1], tools("current A")); + }); + assert_eq!(stored_tools(&view, cx), tools("current A")); +} + +#[gpui::test] +fn large_file_probe_settings_result_survives_old_completion(cx: &mut gpui::TestAppContext) { + let (view, cx) = fixture(cx); + let old = requests(&view, cx)[0].clone(); + accept_settings_tools(&view, cx, tools("fresh Settings")); + view.update(cx, |view, _| { + view.complete_large_file_tools_probe(&old, tools("obsolete main window")); + }); + assert_eq!(stored_tools(&view, cx), tools("fresh Settings")); + assert!(old.cancellation.is_cancelled()); +} + +#[gpui::test] +fn large_file_probe_settings_result_blocks_queued_not_checked_snapshot( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + let queued = cx.update(|_, app| view.read(app).state.clone()); + accept_settings_tools(&view, cx, tools("fresh Settings")); + view.update(cx, |view, cx| { + view.apply_state_snapshot(queued, cx); + }); + assert_eq!( + requests(&view, cx).len(), + 1, + "stale snapshot restarted discovery" + ); + publish_store(&view, cx); + assert_eq!(stored_tools(&view, cx), tools("fresh Settings")); +} + +#[gpui::test] +fn large_file_probe_new_runtime_settings_result_survives_old_runtime_snapshot( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + let queued_a = cx.update(|_, app| view.read(app).state.clone()); + change_runtime(&view, cx, runtime("B")); + accept_settings_tools(&view, cx, tools("fresh Settings B")); + view.update(cx, |view, cx| { + view.apply_state_snapshot(queued_a, cx); + }); + publish_store(&view, cx); + assert_eq!(requests(&view, cx).len(), 1, "settled B was restarted"); + assert_eq!(stored_tools(&view, cx), tools("fresh Settings B")); +} + +#[gpui::test] +fn large_file_probe_window_close_cancels_even_while_view_is_retained( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + let request = requests(&view, cx)[0].clone(); + let store = cx.update(|_, app| view.read(app).store.clone()); + cx.update(|window, _| window.remove_window()); + assert!( + request.cancellation.is_cancelled(), + "closed window still owns a running probe" + ); + // A retained view and an already queued snapshot must not restart discovery. + cx.cx.update(|app| { + view.update(app, |view, cx| { + view.complete_large_file_tools_probe(&request, tools("closed window completion")); + view.apply_state_snapshot(view.state.clone(), cx); + assert_eq!( + view.large_file_tools_detector_for_test + .as_ref() + .unwrap() + .requests + .len(), + 1 + ); + }) + }); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + assert_eq!( + store.snapshot().large_file_tools, + LargeFileToolsState::default() + ); +} + +#[gpui::test] +fn large_file_probe_drop_cancels_the_owned_request(cx: &mut gpui::TestAppContext) { + // Keep only a WindowHandle so no test-owned Entity survives window close. + let (store, events) = AppStore::new_test(Arc::new(TestBackend)); + store.dispatch(Msg::SetGitRuntimeState(runtime("A"))); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + let window = cx.add_window(|window, cx| GitCometView::new(store, events, None, window, cx)); + let (request, weak) = window + .update(cx, |view, _, cx| { + view.large_file_tools_detector_for_test = Some(HeldLargeFileToolsDetector { + runtime: runtime("A"), + requests: Vec::new(), + }); + view.apply_state_snapshot(view.store.snapshot(), cx); + let request = view + .large_file_tools_detector_for_test + .as_ref() + .unwrap() + .requests[0] + .clone(); + (request, cx.weak_entity()) + }) + .unwrap(); + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + cx.update(|_| {}); + assert!(weak.upgrade().is_none(), "fixture still retains the view"); + assert!( + request.cancellation.is_cancelled(), + "closed window left its probe running" + ); +} + +#[gpui::test] +fn large_file_probe_delayed_settings_result_cannot_settle_another_runtime( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + let publication = cx.update(|_, app| LargeFileToolsPublication { + runtime: runtime("A"), + generation: settings_tool_probe_generation(app), + tools: tools("old Settings A"), + }); + change_runtime(&view, cx, runtime("B")); + publish_store(&view, cx); + cx.update(|window, app| { + view.update(app, |view, cx| { + view.apply_preference_change( + &PreferenceChange::LargeFileTools(publication), + window, + cx, + ); + }) + }); + assert_eq!(stored_tools(&view, cx), LargeFileToolsState::default()); + let pending = requests(&view, cx); + assert_eq!(pending.len(), 2); + assert!(!pending[1].cancellation.is_cancelled()); + view.update(cx, |view, _| { + view.complete_large_file_tools_probe(&pending[1], tools("current B")); + }); + assert_eq!(stored_tools(&view, cx), tools("current B")); +} + +#[gpui::test] +fn large_file_probe_empty_home_and_repeated_snapshots_keep_one_request( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + view.update(cx, |view, cx| { + assert!(view.state.repos.is_empty()); + for _ in 0..3 { + view.apply_state_snapshot(view.state.clone(), cx); + } + }); + assert_eq!(requests(&view, cx).len(), 1); + let request = requests(&view, cx)[0].clone(); + view.update(cx, |view, _| { + view.complete_large_file_tools_probe(&request, tools("current")); + }); + publish_store(&view, cx); + publish_store(&view, cx); + assert_eq!(requests(&view, cx).len(), 1); +} + +#[gpui::test] +fn large_file_probe_unavailable_runtime_waits_for_recovery(cx: &mut gpui::TestAppContext) { + let (view, cx) = fixture(cx); + let mut unavailable = runtime("B"); + unavailable.availability = GitExecutableAvailability::Unavailable { + detail: "fixture unavailable".into(), + }; + change_runtime(&view, cx, unavailable); + publish_store(&view, cx); + assert_eq!(requests(&view, cx).len(), 1); + change_runtime(&view, cx, runtime("B")); + publish_store(&view, cx); + assert_eq!(requests(&view, cx).len(), 2); +} + +fn native_fixture_command(root: &Path, mode: &str) -> std::process::Command { + let mut command = gitcomet_core::process::background_command(std::env::current_exe().unwrap()); + command + .args([ + "--ignored", + "--exact", + "view::tests::large_file_tools_probe::large_file_probe_native_child", + "--nocapture", + "--", + ]) + .env("GITCOMET_LARGE_FILE_PROBE_FIXTURE", mode) + .env("GITCOMET_LARGE_FILE_PROBE_ROOT", root); + command +} + +#[test] +#[ignore = "native subprocess fixture for the real large-file detector"] +#[allow(clippy::zombie_processes)] // The descendant deliberately retains the parent's pipes. +fn large_file_probe_native_child() { + let root = PathBuf::from(std::env::var_os("GITCOMET_LARGE_FILE_PROBE_ROOT").unwrap()); + let mode = std::env::var("GITCOMET_LARGE_FILE_PROBE_FIXTURE").unwrap(); + if mode == "parent" { + // libtest accepts multiple exact filters. The detector's appended Git + // arguments remain visible here without a shell or a second executable. + let tool = std::env::args() + .find(|arg| arg == "lfs" || arg == "annex") + .unwrap(); + std::fs::write(root.join(format!("{tool}.invoked")), b"invoked").unwrap(); + let _child = native_fixture_command(&root, "descendant") + .env("GITCOMET_LARGE_FILE_PROBE_TOOL", &tool) + .spawn() + .unwrap(); + return; + } + let tool = std::env::var("GITCOMET_LARGE_FILE_PROBE_TOOL").unwrap(); + std::fs::write(root.join(format!("{tool}.started")), b"started").unwrap(); + std::thread::sleep(Duration::from_secs(10)); + std::fs::write(root.join(format!("{tool}.escaped")), b"escaped").unwrap(); +} + +#[gpui::test] +fn large_file_probe_owned_token_stops_both_native_detectors_and_descendants( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = fixture(cx); + let request = requests(&view, cx)[0].clone(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let token = request.cancellation.clone(); + let worker = std::thread::spawn(move || { + gitcomet_core::large_file_tools::detect_large_file_tools_with( + &|| native_fixture_command(&root, "parent"), + &token, + ) + }); + let deadline = Instant::now() + Duration::from_secs(4); + let both_started = loop { + if ["lfs", "annex"] + .iter() + .all(|tool| dir.path().join(format!("{tool}.started")).exists()) + { + break true; + } + if Instant::now() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(5)); + }; + change_runtime(&view, cx, runtime("B")); + let owner_cancelled = request.cancellation.is_cancelled(); + // Always clean up the native fixture, including on the expected-red baseline. + request.cancellation.cancel(); + let result = worker.join().unwrap(); + assert!( + both_started, + "both detector children must reach the fixture barrier" + ); + for tool in ["lfs", "annex"] { + assert!(dir.path().join(format!("{tool}.invoked")).exists()); + assert!(!dir.path().join(format!("{tool}.escaped")).exists()); + } + assert_eq!(result.git_lfs, ToolAvailability::Unknown); + assert_eq!(result.git_annex, ToolAvailability::Unknown); + assert!( + owner_cancelled, + "the view did not cancel the token used by the real detector" + ); + let calls = std::sync::atomic::AtomicUsize::new(0); + let result = gitcomet_core::large_file_tools::detect_large_file_tools_with( + &|| { + calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + native_fixture_command(dir.path(), "parent") + }, + &request.cancellation, + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0); + assert_eq!(result, LargeFileToolsState::default()); +} From e240618d5e10c35cfd1e6bbf91fb55778a5b161b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 14:30:14 +0300 Subject: [PATCH 27/67] Restore Windows sleep polling after cancellation latency regression Revert 72ddcda4. Fixed paired Windows controls showed the handle wait added median detection latency of 3.78 ms for explicit cancellation, 3.05 ms for operation cancellation, and 1.78 ms for silence deadlines. Both comparison sessions agreed, while the same-binary control did not show a consistent difference. Restore the previous wait behavior instead of retaining the short-command gain with this regression. Validation: all four reverted files match the previous versions; 51 focused runner tests and workspace formatting passed. Raw paired measurements and source hashes are preserved separately. --- crates/gitcomet-git-gix/src/util/process.rs | 17 +---- crates/gitcomet-git-gix/src/util/tests.rs | 24 ------ crates/win32-window-utils/src/lib.rs | 2 +- crates/win32-window-utils/src/process.rs | 84 +-------------------- 4 files changed, 4 insertions(+), 123 deletions(-) diff --git a/crates/gitcomet-git-gix/src/util/process.rs b/crates/gitcomet-git-gix/src/util/process.rs index 2241c9886..75a0c74a4 100644 --- a/crates/gitcomet-git-gix/src/util/process.rs +++ b/crates/gitcomet-git-gix/src/util/process.rs @@ -376,7 +376,7 @@ pub(super) fn wait_for_child_with_timeout( if let Some(poll) = git_command_wait_poll(start.elapsed(), timeout.saturating_sub(idle)) { - wait_for_next_child_poll(child, poll); + thread::sleep(poll); } } Err(e) => return Err(Error::from(e)), @@ -389,21 +389,6 @@ pub(super) fn wait_for_child_with_timeout( }) } -fn wait_for_next_child_poll(child: &std::process::Child, poll: Duration) { - // Windows can wake when the child exits instead of waiting for the next - // poll. Keep sub-millisecond sleeps and fall back on a wait error so this - // optimization cannot add a new path that abandons the child or its pipes. - #[cfg(windows)] - if poll >= Duration::from_millis(1) - && gitcomet_win32_window_utils::wait_for_process_exit(child, poll).is_ok() - { - return; - } - #[cfg(not(windows))] - let _ = child; - thread::sleep(poll); -} - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct OutputDrainOutcome { pub(super) cancelled: bool, diff --git a/crates/gitcomet-git-gix/src/util/tests.rs b/crates/gitcomet-git-gix/src/util/tests.rs index b6d5aa4c9..a700f94e7 100644 --- a/crates/gitcomet-git-gix/src/util/tests.rs +++ b/crates/gitcomet-git-gix/src/util/tests.rs @@ -718,30 +718,6 @@ fn run_command_with_timeout_returns_structured_timeout_failure() { } } -#[cfg(windows)] -#[test] -fn windows_chatty_output_extends_silence_deadline_and_preserves_final_bytes() { - let command = shell_command( - "for ($i=0; $i -lt 8; $i++) { [Console]::Out.Write('out;'); [Console]::Error.Write('err;'); Start-Sleep -Milliseconds 500 }; [Console]::Out.Write('final-out'); [Console]::Error.Write('final-err')", - ); - let output = run_command_with_timeout( - command, - "chatty Windows command", - Duration::from_secs(3), - None, - ) - .expect("continued stdout/stderr must keep the command alive"); - assert!(output.status.success()); - assert_eq!( - output.stdout, - format!("{}final-out", "out;".repeat(8)).as_bytes() - ); - assert_eq!( - output.stderr, - format!("{}final-err", "err;".repeat(8)).as_bytes() - ); -} - #[test] fn git_command_wait_poll_is_short_for_fast_commands_and_capped_for_slow_ones() { assert_eq!( diff --git a/crates/win32-window-utils/src/lib.rs b/crates/win32-window-utils/src/lib.rs index fad02d237..c0ed5edf2 100644 --- a/crates/win32-window-utils/src/lib.rs +++ b/crates/win32-window-utils/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::OnceLock; use std::time::Duration; mod process; -pub use process::{terminate_process_tree, wait_for_process_exit}; +pub use process::terminate_process_tree; use windows_sys::Win32::Foundation::{ CloseHandle, FALSE, FILETIME, HANDLE, HWND, LPARAM, POINT, TRUE, WPARAM, diff --git a/crates/win32-window-utils/src/process.rs b/crates/win32-window-utils/src/process.rs index a12cbe648..2d50fa7bc 100644 --- a/crates/win32-window-utils/src/process.rs +++ b/crates/win32-window-utils/src/process.rs @@ -1,92 +1,12 @@ use std::io; -use std::os::windows::io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle}; -use std::process::Child; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::ptr::null; -use std::time::Duration; -use windows_sys::Win32::Foundation::{FALSE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::Foundation::{FALSE, WAIT_FAILED}; use windows_sys::Win32::System::Threading::{ CREATE_NO_WINDOW, CreateProcessW, INFINITE, PROCESS_INFORMATION, STARTUPINFOW, WaitForSingleObject, }; -fn finite_wait_millis(timeout: Duration) -> u32 { - timeout.as_millis().min(u128::from(INFINITE - 1)) as u32 -} - -/// Wait for a child to exit, using its existing process handle. Returns `false` -/// when the timeout expires. This does not reap the child or retrieve its status. -/// -/// Timeouts are rounded down to whole milliseconds and never become infinite. -/// Callers that need a sub-millisecond delay should use their usual sleep. -pub fn wait_for_process_exit(child: &Child, timeout: Duration) -> io::Result { - let handle = child.as_handle(); - // SAFETY: Child owns this process handle with synchronization access. The - // borrow prevents its owner from being dropped until the bounded wait ends. - match unsafe { WaitForSingleObject(handle.as_raw_handle(), finite_wait_millis(timeout)) } { - WAIT_OBJECT_0 => Ok(true), - WAIT_TIMEOUT => Ok(false), - WAIT_FAILED => Err(io::Error::last_os_error()), - result => Err(io::Error::other(format!( - "unexpected process wait result: {result}" - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::windows::process::CommandExt as _; - use std::process::{Command, Stdio}; - - #[test] - fn process_wait_timeouts_are_finite_and_never_rounded_up() { - assert_eq!(finite_wait_millis(Duration::ZERO), 0); - assert_eq!(finite_wait_millis(Duration::from_micros(999)), 0); - assert_eq!(finite_wait_millis(Duration::from_millis(1)), 1); - assert_eq!(finite_wait_millis(Duration::from_micros(1999)), 1); - assert_eq!(finite_wait_millis(Duration::MAX), INFINITE - 1); - } - - #[test] - fn process_wait_observes_exit_without_consuming_status() { - let mut child = Command::new("cmd.exe") - .args(["/D", "/C", "exit 7"]) - .creation_flags(CREATE_NO_WINDOW) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let status = child.wait().unwrap(); - assert!(wait_for_process_exit(&child, Duration::from_secs(1)).unwrap()); - assert_eq!(child.try_wait().unwrap(), Some(status)); - assert_eq!(status.code(), Some(7)); - } - - #[test] - fn process_wait_times_out_while_child_is_alive() { - let mut child = Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "Start-Sleep -Seconds 30", - ]) - .creation_flags(CREATE_NO_WINDOW) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let result = wait_for_process_exit(&child, Duration::from_millis(1)); - let still_alive = child.try_wait().unwrap().is_none(); - child.kill().unwrap(); - child.wait().unwrap(); - assert!(!result.unwrap()); - assert!(still_alive); - } -} - /// Run Windows' process-tree termination helper without inheriting any handles. /// In particular, asynchronous cleanup must not retain the caller's output pipes. pub fn terminate_process_tree(process_id: u32) -> io::Result<()> { From 16336337f3606a99c7fb9c39f03106b0bb7bd168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 15:13:22 +0300 Subject: [PATCH 28/67] Defer automatic large-file tool discovery until a repository opens Empty Home does not consume Git LFS or git-annex availability. Require a repository tab before automatically starting their discovery, including tabs that are still opening. Keep explicit Settings discovery and runtime reconciliation unchanged. A matched native-child test records two detector subprocesses on the baseline and zero on empty Home after the guard. First and restored repository tabs still launch one pair and publish both results; Settings results avoid a redundant pair. All 18 lifecycle cases remain green (22 cases total), and UI Clippy passes with tests and warnings denied. This proves avoided work, not an app startup latency reduction. --- .../src/view/large_file_tools_probe.rs | 27 +- .../gitcomet-ui-gpui/src/view/state_apply.rs | 10 +- crates/gitcomet-ui-gpui/src/view/tests.rs | 1 + .../src/view/tests/large_file_tools_home.rs | 278 ++++++++++++++++++ .../src/view/tests/large_file_tools_probe.rs | 14 +- 5 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs diff --git a/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs index 434d74068..32d172766 100644 --- a/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs @@ -114,6 +114,16 @@ pub(super) struct LargeFileToolsProbeRequest { sequence: u64, } +/// Opt-in native command factory for resource-count tests. Ordinary lifecycle +/// tests keep holding requests without scheduling work. +#[cfg(test)] +pub(super) struct NativeLargeFileToolsDetectorForTest( + pub(super) std::sync::Arc std::process::Command + Send + Sync>, +); + +#[cfg(test)] +impl gpui::Global for NativeLargeFileToolsDetectorForTest {} + #[cfg(test)] pub(super) struct HeldLargeFileToolsDetector { pub(super) runtime: GitRuntimeState, @@ -189,13 +199,26 @@ impl GitCometView { // The test driver holds the same request and releases it through the // production completion method. Ordinary tests never enable discovery. #[cfg(test)] + let native_factory = cx + .try_global::() + .map(|factory| std::sync::Arc::clone(&factory.0)); + #[cfg(test)] if let Some(driver) = &mut self.large_file_tools_detector_for_test { - driver.requests.push(request); - return; + driver.requests.push(request.clone()); + if native_factory.is_none() { + return; + } } let cancellation = request.cancellation.clone(); let runtime = request.runtime.clone(); let detection = cx.background_spawn(async move { + #[cfg(test)] + if let Some(factory) = native_factory { + return gitcomet_core::large_file_tools::detect_large_file_tools_with( + factory.as_ref(), + &cancellation, + ); + } gitcomet_core::large_file_tools::detect_large_file_tools_for_runtime_cancellable( &runtime, &cancellation, diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index a3bb2153c..464fa59a2 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -635,10 +635,12 @@ impl GitCometView { self.refresh_signing_tools(false, cx); } self.reconcile_large_file_tools_runtime(); - if matches!( - self.state.large_file_tools.git_lfs, - gitcomet_core::signing_tools::SigningToolAvailability::NotChecked - ) { + if !self.state.repos.is_empty() + && matches!( + self.state.large_file_tools.git_lfs, + gitcomet_core::signing_tools::SigningToolAvailability::NotChecked + ) + { self.refresh_large_file_tools(cx); } // Only an open palette shows enablement; `open` takes a fresh context. diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index 07100d8e4..715f9e85c 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -365,4 +365,5 @@ mod sidebar_file_history_tab; mod sidebar_layout; mod window_lifecycle; +mod large_file_tools_home; mod large_file_tools_probe; diff --git a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs new file mode 100644 index 000000000..e94f86a61 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs @@ -0,0 +1,278 @@ +use super::*; +use crate::view::large_file_tools_probe::{ + HeldLargeFileToolsDetector, LargeFileToolsPublication, NativeLargeFileToolsDetectorForTest, + settings_tool_probe_generation, +}; +use crate::view::preference_change::PreferenceChange; +use gitcomet_core::large_file_tools::{LargeFileToolsState, ToolAvailability}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn runtime() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::from("home-probe-fixture")), + availability: GitExecutableAvailability::Available { + version_output: "git version 2.50.0".into(), + }, + } +} + +struct NativeFixture { + root: tempfile::TempDir, + calls: Arc, + release: Arc<(Mutex, Condvar)>, +} + +impl Drop for NativeFixture { + fn drop(&mut self) { + let (released, wake) = self.release.as_ref(); + *released.lock().unwrap() = true; + wake.notify_all(); + } +} + +fn fixture( + cx: &mut gpui::TestAppContext, + initially_opening: bool, +) -> ( + NativeFixture, + Entity, + &mut gpui::VisualTestContext, +) { + let native = NativeFixture { + root: tempfile::tempdir().unwrap(), + calls: Arc::new(AtomicUsize::new(0)), + release: Arc::new((Mutex::new(false), Condvar::new())), + }; + let (store, events) = AppStore::new_test(Arc::new(BlockingFailingBackend { + release: Arc::clone(&native.release), + })); + store.dispatch(Msg::SetGitRuntimeState(runtime())); + if initially_opening { + store.dispatch(Msg::OpenRepo(native.root.path().join("repository"))); + } + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + test_support::drain_store_worker(&view, cx); + let root = native.root.path().to_path_buf(); + let calls = Arc::clone(&native.calls); + cx.update(|_, app| { + app.set_global(NativeLargeFileToolsDetectorForTest(Arc::new(move || { + let invocation = calls.fetch_add(1, Ordering::Relaxed); + let mut command = + gitcomet_core::process::background_command(std::env::current_exe().unwrap()); + command + .args([ + "--ignored", + "--exact", + "view::tests::large_file_tools_home::large_file_probe_home_native_child", + "--nocapture", + "--", + ]) + .env("GITCOMET_HOME_PROBE_ROOT", &root) + .env("GITCOMET_HOME_PROBE_INVOCATION", invocation.to_string()); + command + }))); + }); + view.update(cx, |view, _| { + view.large_file_tools_detector_for_test = Some(HeldLargeFileToolsDetector { + runtime: runtime(), + requests: Vec::new(), + }); + }); + (native, view, cx) +} + +fn apply_snapshots(view: &Entity, cx: &mut gpui::VisualTestContext) { + test_support::drain_store_worker(view, cx); + view.update(cx, |view, cx| { + for _ in 0..3 { + view.apply_state_snapshot(view.store.snapshot(), cx); + } + }); + cx.run_until_parked(); + test_support::drain_store_worker(view, cx); +} + +fn request_count(view: &Entity, cx: &mut gpui::VisualTestContext) -> usize { + cx.update(|_, app| { + view.read(app) + .large_file_tools_detector_for_test + .as_ref() + .unwrap() + .requests + .len() + }) +} + +fn witness( + scenario: &str, + native: &NativeFixture, + view: &Entity, + cx: &mut gpui::VisualTestContext, +) -> usize { + let requests = request_count(view, cx); + let mut tools = std::fs::read_dir(native.root.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "invoked") + }) + .map(|path| std::fs::read_to_string(path).unwrap()) + .collect::>(); + tools.sort(); + let calls = native.calls.load(Ordering::Relaxed); + println!( + "HOME_PROBE_WORK scenario={scenario} requests={requests} commands={calls} child_markers={}", + tools.len(), + ); + // Both native children must actually run, not merely reach the factory. + assert_eq!(calls, requests * 2); + assert_eq!(tools.len(), calls); + assert_eq!( + tools.iter().filter(|tool| tool.as_str() == "lfs").count(), + requests + ); + assert_eq!( + tools.iter().filter(|tool| tool.as_str() == "annex").count(), + requests + ); + calls +} + +fn assert_published(view: &Entity, cx: &mut gpui::VisualTestContext) { + pump_until(cx, "native large-file result publication", |cx| { + cx.update(|_, app| { + let state = view.read(app).store.snapshot(); + matches!( + state.large_file_tools.git_lfs, + ToolAvailability::Available { .. } + ) && matches!( + state.large_file_tools.git_annex, + ToolAvailability::Available { .. } + ) + }) + }); +} + +#[gpui::test] +fn large_file_probe_home_defers_automatic_native_detectors(cx: &mut gpui::TestAppContext) { + let (native, view, cx) = fixture(cx, false); + apply_snapshots(&view, cx); + assert!(cx.update(|_, app| view.read(app).state.repos.is_empty())); + if request_count(&view, cx) != 0 { + // The unchanged baseline must demonstrate both real processes before + // failing the new requirement, rather than failing a fixture precondition. + assert_published(&view, cx); + } + assert_eq!(witness("empty-home", &native, &view, cx), 0); + assert_eq!(request_count(&view, cx), 0); + assert_eq!( + cx.update(|_, app| view.read(app).store.snapshot().large_file_tools.clone()), + LargeFileToolsState::default(), + ); +} + +#[gpui::test] +fn large_file_probe_home_first_opening_repo_starts_one_native_pair(cx: &mut gpui::TestAppContext) { + let (native, view, cx) = fixture(cx, false); + apply_snapshots(&view, cx); + if request_count(&view, cx) != 0 { + assert_published(&view, cx); + } + let home_calls = witness("before-first-repository", &native, &view, cx); + view.update(cx, |view, _| { + view.store + .dispatch(Msg::OpenRepo(native.root.path().join("repository"))); + }); + apply_snapshots(&view, cx); + assert!(cx.update(|_, app| { + let state = view.read(app).store.snapshot(); + state.repos.len() == 1 && matches!(state.repos[0].open, Loadable::Loading) + })); + assert_published(&view, cx); + apply_snapshots(&view, cx); + assert_eq!(witness("first-opening-repository", &native, &view, cx), 2); + assert_eq!(request_count(&view, cx), 1); + assert_eq!( + home_calls, 0, + "discovery must wait for the first repository" + ); +} + +#[gpui::test] +fn large_file_probe_home_restored_opening_repo_starts_one_native_pair( + cx: &mut gpui::TestAppContext, +) { + // A restored repository already exists in the first snapshot, but its backend + // open remains blocked. Detection must not wait for repository loading. + let (native, view, cx) = fixture(cx, true); + apply_snapshots(&view, cx); + assert!(cx.update(|_, app| { + let state = view.read(app).store.snapshot(); + state.repos.len() == 1 && matches!(state.repos[0].open, Loadable::Loading) + })); + assert_published(&view, cx); + apply_snapshots(&view, cx); + assert_eq!(witness("initial-opening-repository", &native, &view, cx), 2); + assert_eq!(request_count(&view, cx), 1); +} + +#[gpui::test] +fn large_file_probe_home_settings_publication_prevents_redundant_detection( + cx: &mut gpui::TestAppContext, +) { + let (native, view, cx) = fixture(cx, false); + let tools = LargeFileToolsState { + git_lfs: ToolAvailability::Available { + version: Some("Settings LFS".into()), + }, + git_annex: ToolAvailability::Available { + version: Some("Settings annex".into()), + }, + }; + cx.update(|window, app| { + view.update(app, |view, cx| { + assert!(view.state.repos.is_empty()); + view.apply_preference_change( + &PreferenceChange::LargeFileTools(LargeFileToolsPublication { + runtime: runtime(), + generation: settings_tool_probe_generation(cx), + tools: tools.clone(), + }), + window, + cx, + ); + }); + }); + apply_snapshots(&view, cx); + assert_eq!( + cx.update(|_, app| view.read(app).store.snapshot().large_file_tools.clone()), + tools + ); + view.update(cx, |view, _| { + view.store + .dispatch(Msg::OpenRepo(native.root.path().join("repository"))); + }); + apply_snapshots(&view, cx); + assert_eq!( + witness("settings-then-first-repository", &native, &view, cx), + 0 + ); +} + +#[test] +#[ignore = "native subprocess fixture for automatic discovery resource counts"] +fn large_file_probe_home_native_child() { + let root = PathBuf::from(std::env::var_os("GITCOMET_HOME_PROBE_ROOT").unwrap()); + let invocation = std::env::var("GITCOMET_HOME_PROBE_INVOCATION").unwrap(); + let args = std::env::args().collect::>(); + let tool = args + .iter() + .find(|arg| *arg == "lfs" || *arg == "annex") + .unwrap(); + assert!(args.iter().any(|arg| arg == "version")); + assert_eq!(args.iter().any(|arg| arg == "--raw"), tool == "annex"); + std::fs::write(root.join(format!("{invocation}-{tool}.invoked")), tool).unwrap(); + println!("fixture {tool} 1.0"); +} diff --git a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs index 31c5a41c7..0aaa3834c 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs @@ -27,6 +27,11 @@ fn tools(version: &str) -> LargeFileToolsState { fn fixture(cx: &mut gpui::TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { let (store, events) = AppStore::new_test(Arc::new(TestBackend)); store.dispatch(Msg::SetGitRuntimeState(runtime("A"))); + // A repository keeps these request-lifecycle fixtures eligible for automatic + // discovery; the separate Home tests cover deferred startup. + store.dispatch(Msg::OpenRepo( + std::env::temp_dir().join("tool-probe-lifecycle"), + )); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); test_support::drain_store_worker(&view, cx); @@ -244,6 +249,11 @@ fn large_file_probe_drop_cancels_the_owned_request(cx: &mut gpui::TestAppContext // Keep only a WindowHandle so no test-owned Entity survives window close. let (store, events) = AppStore::new_test(Arc::new(TestBackend)); store.dispatch(Msg::SetGitRuntimeState(runtime("A"))); + // A repository keeps these request-lifecycle fixtures eligible for automatic + // discovery; the separate Home tests cover deferred startup. + store.dispatch(Msg::OpenRepo( + std::env::temp_dir().join("tool-probe-lifecycle"), + )); store .barrier_for_test() .recv_timeout(Duration::from_secs(2)) @@ -309,12 +319,12 @@ fn large_file_probe_delayed_settings_result_cannot_settle_another_runtime( } #[gpui::test] -fn large_file_probe_empty_home_and_repeated_snapshots_keep_one_request( +fn large_file_probe_repository_and_repeated_snapshots_keep_one_request( cx: &mut gpui::TestAppContext, ) { let (view, cx) = fixture(cx); view.update(cx, |view, cx| { - assert!(view.state.repos.is_empty()); + assert!(!view.state.repos.is_empty()); for _ in 0..3 { view.apply_state_snapshot(view.state.clone(), cx); } From 1289ff4f86381792c37474dc1821271e0c550e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 15:38:28 +0300 Subject: [PATCH 29/67] Preserve Git configuration errors in native worktree listings The sole-primary shortcut could return a successful row when Git rejects an invalid boolean or reads configuration the native parser cannot inspect. Use a fresh strict open for native eligibility and defer unsupported worktree conditional includes, uninspected includes, and noncanonical comparator values to the existing Git runner. Preserve the original source permissions and central path/trust/conditional-include handling. The exact original-main baseline fails five of twelve matched regression cases. The corrected minimal variant passes all twelve plus the sixteen existing worktree controls (28 total), and affected Clippy passes with tests and warnings denied. CLI error status/stdout/stderr are compared directly. --- crates/gitcomet-git-gix/src/open.rs | 13 +- crates/gitcomet-git-gix/src/repo/worktrees.rs | 6 +- .../src/repo/worktrees/native_config.rs | 70 +++++ .../src/repo/worktrees/native_config_tests.rs | 267 ++++++++++++++++++ 4 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 crates/gitcomet-git-gix/src/repo/worktrees/native_config.rs create mode 100644 crates/gitcomet-git-gix/src/repo/worktrees/native_config_tests.rs diff --git a/crates/gitcomet-git-gix/src/open.rs b/crates/gitcomet-git-gix/src/open.rs index e82ad380d..529cc89ad 100644 --- a/crates/gitcomet-git-gix/src/open.rs +++ b/crates/gitcomet-git-gix/src/open.rs @@ -27,7 +27,18 @@ pub(crate) fn open_worktree_repo_with( workdir: &Path, permissions: gix::open::Permissions, ) -> gix::Result { - let options = gix::open::Options::default().permissions(permissions); + open_worktree_repo_with_options( + workdir, + gix::open::Options::default().permissions(permissions), + ) +} + +/// As [`open_worktree_repo_with`], with caller-selected opening options. +/// Path handling and conditional include reloads remain shared by every opener. +pub(crate) fn open_worktree_repo_with_options( + workdir: &Path, + options: gix::open::Options, +) -> gix::Result { let mut repo = gix::open_opts(git_dir_for_workdir(workdir), options)?; crate::refs::reload_conditional_includes(&mut repo)?; Ok(repo) diff --git a/crates/gitcomet-git-gix/src/repo/worktrees.rs b/crates/gitcomet-git-gix/src/repo/worktrees.rs index 863aff4c7..e8ab53795 100644 --- a/crates/gitcomet-git-gix/src/repo/worktrees.rs +++ b/crates/gitcomet-git-gix/src/repo/worktrees.rs @@ -10,6 +10,10 @@ use gix::bstr::ByteSlice as _; use std::path::Path; use std::process::Command; +mod native_config; +#[cfg(test)] +mod native_config_tests; + impl GixRepo { pub(super) fn list_worktrees_impl(&self) -> Result> { self.list_worktrees_cancellable_impl(&CancellationToken::new()) @@ -26,7 +30,7 @@ impl GixRepo { // registrations and configuration can change while this handle is open. if !worktree_environment_overridden() && self.may_be_sole_primary_worktree() - && let Ok(repo) = self.reopen_repo() + && let Some(repo) = native_config::reopen(self) { cancellation.check_cancelled()?; if let Some(worktree) = sole_primary_worktree(&repo) { diff --git a/crates/gitcomet-git-gix/src/repo/worktrees/native_config.rs b/crates/gitcomet-git-gix/src/repo/worktrees/native_config.rs new file mode 100644 index 000000000..aa8c52ba4 --- /dev/null +++ b/crates/gitcomet-git-gix/src/repo/worktrees/native_config.rs @@ -0,0 +1,70 @@ +//! Conservative configuration eligibility shared by native worktree listings. +use super::GixRepo; +use std::path::Path; + +pub(super) fn reopen(owner: &GixRepo) -> Option { + open(&owner.spec.workdir, owner.repo().open_options().permissions) +} + +pub(super) fn open(workdir: &Path, permissions: gix::open::Permissions) -> Option { + // Preserve the original source permissions, but recalculate trust and read + // fresh config. Strictness is local to native-list eligibility: on failure + // the existing Git runner determines the authoritative result or error. + let repo = crate::open::open_worktree_repo_with_options( + workdir, + gix::open::Options::default() + .permissions(permissions) + .strict_config(true), + ) + .ok()?; + let config = repo.config_snapshot(); + for section in config.plumbing().sections() { + let header = section.header(); + if !permissions.config.includes + && (header.name().eq_ignore_ascii_case(b"include") + || header.name().eq_ignore_ascii_case(b"includeIf")) + { + // Preserve the owner's permission boundary. Git can read an + // include we cannot inspect, so it must remain authoritative. + return None; + } + if header.name().eq_ignore_ascii_case(b"includeIf") + && header.subsection_name().is_some_and(|condition| { + condition.starts_with(b"worktree:") || condition.starts_with(b"worktree/i:") + }) + { + // Git supports these conditions, but pinned gix ignores them. The + // expanded graph retains their sections, including nested includes. + return None; + } + if header.name().eq_ignore_ascii_case(b"core") && header.subsection_name().is_none() { + // Git validates each assignment, even one shadowed by a later + // value. Numeric parsing also differs from gix; unusual spellings + // stay with Git instead of inventing another boolean parser here. + for value in section.values("ignorecase") { + if !canonical_boolean(&value) { + return None; + } + } + } + } + // Unlike the convenience accessor, this never defaults an invalid value. + let _ = config.try_boolean("core.ignorecase").ok()?; + Some(repo) +} + +fn canonical_boolean(value: &[u8]) -> bool { + [ + b"".as_slice(), + b"0", + b"1", + b"true", + b"false", + b"yes", + b"no", + b"on", + b"off", + ] + .iter() + .any(|known| value.eq_ignore_ascii_case(known)) +} diff --git a/crates/gitcomet-git-gix/src/repo/worktrees/native_config_tests.rs b/crates/gitcomet-git-gix/src/repo/worktrees/native_config_tests.rs new file mode 100644 index 000000000..74ab1a827 --- /dev/null +++ b/crates/gitcomet-git-gix/src/repo/worktrees/native_config_tests.rs @@ -0,0 +1,267 @@ +use super::*; +use crate::test_support::{git_output, git_success, init_test_repo}; +use gitcomet_core::error::ErrorKind; +use gitcomet_core::services::GitRepository as _; + +fn open_config_repo(path: &Path) -> GixRepo { + let mut permissions = gix::open::Permissions::isolated(); + permissions.config.includes = true; + let repo = crate::open::open_worktree_repo_with(path, permissions).unwrap(); + GixRepo::new(path.to_path_buf(), repo.into_sync()) +} + +fn fixture(linked: bool) -> (tempfile::TempDir, GixRepo, Option) { + let root = tempfile::tempdir().unwrap(); + let path = canonicalize_or_original(root.path().to_path_buf()).join("main"); + std::fs::create_dir(&path).unwrap(); + init_test_repo(&path); + git_success(&path, &["commit", "--allow-empty", "-m", "initial"]); + git_success(&path, &["config", "core.ignorecase", "false"]); + let repo = open_config_repo(&path); + let caller = linked.then(|| { + let mut paths = Vec::new(); + for name in ["B", "a"] { + let checkout = path.parent().unwrap().join(name); + git_success( + &path, + &[ + "worktree", + "add", + "--detach", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + paths.push(checkout); + } + open_config_repo(&paths[0]) + }); + (root, repo, caller) +} + +fn assert_git_fallback(repo: &GixRepo) { + let expected = git_output( + &repo.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + assert!(expected.status.success(), "{expected:?}"); + let expected = parse_git_worktree_list_porcelain_z(&expected.stdout).unwrap(); + let (actual, trace) = + crate::command_trace::capture(|| repo.list_worktrees(&CancellationToken::new())); + assert_eq!(actual.unwrap(), expected, "full ordered CLI rows"); + assert_eq!(trace.len(), 1, "unsupported config must use Git: {trace:?}"); +} + +fn unsupported_include(condition: &str, per_worktree: bool, expanded: bool, linked: bool) { + let (_root, main, caller) = fixture(linked); + let caller = if per_worktree { + caller.as_ref().unwrap() + } else { + &main + }; + if per_worktree { + git_success( + &main.spec.workdir, + &["config", "extensions.worktreeConfig", "true"], + ); + } + let included = main.spec.workdir.parent().unwrap().join("included.conf"); + std::fs::write(&included, "[core]\nignoreCase = true\n").unwrap(); + let spelling = caller.spec.workdir.to_string_lossy().replace('\\', "/"); + let spelling = if condition == "worktree/i" { + spelling.to_ascii_uppercase() + } else { + spelling + }; + let key = format!("includeIf.{condition}:{spelling}.path"); + if expanded { + let outer = included.with_file_name("outer.conf"); + std::fs::write(&outer, "").unwrap(); + git_success( + &main.spec.workdir, + &[ + "config", + "--file", + outer.to_str().unwrap(), + &key, + included.to_str().unwrap(), + ], + ); + git_success( + &main.spec.workdir, + &["config", "include.path", outer.to_str().unwrap()], + ); + } else { + let mut args = vec!["config"]; + if per_worktree { + args.push("--worktree"); + } + args.extend([key.as_str(), included.to_str().unwrap()]); + git_success(&caller.spec.workdir, &args); + } + // Diagnostic witness only: older CI Git may not implement this condition. + // Correctness still requires CLI parity and conservative fallback there. + let effective = git_output( + &caller.spec.workdir, + &["config", "--type=bool", "--get", "core.ignorecase"], + ); + let rows = git_output( + &caller.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + eprintln!( + "condition={condition} per_worktree={per_worktree} expanded={expanded} effective_ignorecase={:?} ordered_rows={:?}", + String::from_utf8_lossy(&effective.stdout), + String::from_utf8_lossy(&rows.stdout) + ); + assert_git_fallback(caller); + if per_worktree { + assert_git_fallback(&main); + } +} + +#[test] +fn worktree_condition_in_shared_config_defers_to_git() { + unsupported_include("worktree", false, false, true); +} + +#[test] +fn worktree_i_condition_in_shared_config_defers_to_git() { + unsupported_include("worktree/i", false, false, true); +} + +#[test] +fn worktree_condition_in_caller_config_defers_to_git() { + unsupported_include("worktree", true, false, true); +} + +#[test] +fn worktree_i_condition_in_caller_config_defers_to_git() { + unsupported_include("worktree/i", true, false, true); +} + +#[test] +fn expanded_worktree_condition_defers_to_git() { + unsupported_include("worktree", false, true, true); +} + +#[test] +fn sole_primary_with_worktree_condition_defers_to_git() { + unsupported_include("worktree", false, false, false); +} + +fn invalid_boolean(linked: bool, per_worktree: bool) { + let (_root, main, caller) = fixture(linked); + let caller = if per_worktree { + caller.as_ref().unwrap() + } else { + &main + }; + let mut args = vec!["config"]; + if per_worktree { + git_success( + &main.spec.workdir, + &["config", "extensions.worktreeConfig", "true"], + ); + args.push("--worktree"); + } + args.extend(["core.ignorecase", "invalid"]); + git_success(&caller.spec.workdir, &args); + let expected = git_output( + &caller.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + assert!( + !expected.status.success(), + "the fixture must make Git reject configuration" + ); + let (result, trace) = + crate::command_trace::capture(|| caller.list_worktrees(&CancellationToken::new())); + let error = result.expect_err("invalid config must preserve the CLI error"); + let ErrorKind::Git(failure) = error.kind() else { + panic!("expected CLI failure, got {error:?}") + }; + assert_eq!(failure.exit_code(), expected.status.code()); + assert_eq!(failure.stdout(), expected.stdout); + assert_eq!(failure.stderr(), expected.stderr); + assert_eq!(trace.len(), 1, "{trace:?}"); +} + +#[test] +fn sole_primary_invalid_ignorecase_preserves_cli_error() { + invalid_boolean(false, false); +} + +#[test] +fn linked_shared_invalid_ignorecase_preserves_cli_error() { + invalid_boolean(true, false); +} + +#[test] +fn linked_caller_invalid_ignorecase_preserves_cli_error() { + invalid_boolean(true, true); +} + +#[test] +fn shadowed_invalid_ignorecase_preserves_cli_error() { + let (_root, repo, _) = fixture(false); + let path = repo.spec.workdir.join(".git/config"); + let mut config = std::fs::read_to_string(&path).unwrap(); + config.push_str("\n[core]\nignorecase=invalid\nignorecase=true\n"); + std::fs::write(&path, config).unwrap(); + let expected = git_output( + &repo.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + assert!(!expected.status.success()); + let (result, trace) = + crate::command_trace::capture(|| repo.list_worktrees(&CancellationToken::new())); + let error = result.unwrap_err(); + let ErrorKind::Git(failure) = error.kind() else { + panic!("expected CLI failure, got {error:?}") + }; + assert_eq!(failure.exit_code(), expected.status.code()); + assert_eq!(failure.stdout(), expected.stdout); + assert_eq!(failure.stderr(), expected.stderr); + assert_eq!(trace.len(), 1); +} + +#[test] +fn uncommon_numeric_ignorecase_keeps_git_authoritative() { + let (_root, repo, _) = fixture(false); + git_success(&repo.spec.workdir, &["config", "core.ignorecase", "2"]); + assert_git_fallback(&repo); +} + +#[test] +fn declared_include_with_disabled_permission_preserves_cli_error() { + let (_root, main, _) = fixture(false); + let limited = crate::test_support::open_repo(&main.spec.workdir); + assert!(!limited.repo().open_options().permissions.config.includes); + let included = main + .spec + .workdir + .parent() + .unwrap() + .join("disabled-include.conf"); + std::fs::write(&included, "[core]\nignorecase=invalid\n").unwrap(); + git_success( + &main.spec.workdir, + &["config", "include.path", included.to_str().unwrap()], + ); + let expected = git_output( + &main.spec.workdir, + &["worktree", "list", "--porcelain", "-z"], + ); + assert!(!expected.status.success()); + let (result, trace) = + crate::command_trace::capture(|| limited.list_worktrees(&CancellationToken::new())); + let error = result.expect_err("an uninspected include cannot justify native success"); + let ErrorKind::Git(failure) = error.kind() else { + panic!("expected CLI failure, got {error:?}") + }; + assert_eq!(failure.exit_code(), expected.status.code()); + assert_eq!(failure.stdout(), expected.stdout); + assert_eq!(failure.stderr(), expected.stderr); + assert_eq!(trace.len(), 1); +} From 4e5c2d4da50ddf1eb91ad749c1f78e9e7c371612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sat, 17 Oct 2026 17:09:30 +0300 Subject: [PATCH 30/67] Skip redundant Git metadata traversal with recursive Windows watchers Windows native watchers already register Git metadata roots recursively. Avoid breadth-first metadata enumeration during setup and later directory updates while preserving worktree ignore discovery and the legacy traversal when a Git root equals or contains the worktree. Add direct test-only discovery counters and five Windows tests covering setup, incremental discovery, overlapping roots, and native ref/log events. Validation on this Windows computer: exact candidate sources passed 147 repo-monitor tests plus 18 store integration tests and state Clippy with warnings denied. Eight existing privileged symlink cases self-skipped in both variants. Formatting, identity literals, and source-size checks pass. Two fixed matched release sessions (80 child processes / 480 setup calls) reduced heavy-fixture metadata reads from 2064 to zero while preserving 34 worktree reads and native event witnesses. First-process setup median fell 109.39 to 50.65 ms; warm setup fell 66.00 to 8.33 ms. Paired 95% CIs exclude zero in both sessions; the sparse-fixture regression guard passes. This measures monitor setup after fixture validation, not cold app startup or foreground latency. Independent raw-evidence verification passed. --- .../src/store/repo_monitor/monitor.rs | 24 +- .../src/store/repo_monitor/plan.rs | 24 ++ .../src/store/repo_monitor/tests/selective.rs | 2 + .../tests/selective/windows_metadata.rs | 298 ++++++++++++++++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 crates/gitcomet-state/src/store/repo_monitor/tests/selective/windows_metadata.rs diff --git a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs index c5d465ea1..0027460f4 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/monitor.rs @@ -5,6 +5,16 @@ use crate::msg::ChangedPaths; pub(super) const MAX_WORKTREE_WATCH_DIRS: usize = 4096; +#[cfg(windows)] +fn metadata_discovery_required(policy: &PolicySnapshot) -> bool { + // Recursive native roots already cover metadata descendants. Preserve the + // old cross-pass deduplication when a Git root also contains the worktree. + policy + .git_roots + .iter() + .any(|root| policy.workdir.starts_with(root)) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum WatchSetupOutcome { Watching { failed_dirs: usize }, @@ -215,7 +225,11 @@ impl MonitorState { .unwrap_or_else(|e| e.into_inner()) .directories = 0; } - self.plan = WatchPlan::default(); + self.plan = WatchPlan { + #[cfg(test)] + discovery_work: self.plan.discovery_work, + ..WatchPlan::default() + }; self.publish(PolicySnapshot::new(workdir, &self.inputs)); { let _phase = SetupPhase::new(config, "before-registration", attempt); @@ -268,6 +282,10 @@ impl MonitorState { { let _phase = SetupPhase::new(config, "directory-discovery", attempt); for (roots, worktree) in [(roots, false), (vec![workdir.to_path_buf()], true)] { + #[cfg(windows)] + if !worktree && !metadata_discovery_required(&snapshot) { + continue; + } let complete = self.plan.walk_while( roots, worktree, @@ -389,6 +407,10 @@ impl MonitorState { if !config.enabled.load(Ordering::Relaxed) { return; } + #[cfg(windows)] + if !worktree && !metadata_discovery_required(&snapshot) { + continue; + } if !self.plan.walk_while( [path.clone()], *worktree, diff --git a/crates/gitcomet-state/src/store/repo_monitor/plan.rs b/crates/gitcomet-state/src/store/repo_monitor/plan.rs index bac0c3286..b79193fac 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/plan.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/plan.rs @@ -3,8 +3,20 @@ use super::native_watcher::WatchMode; use super::*; use std::collections::VecDeque; +/// Actual planner work, retained across setup retries for operation deltas. +#[cfg(test)] +#[derive(Clone, Copy, Default, Debug, Eq, PartialEq)] +pub(super) struct DiscoveryWork { + pub metadata_visits: usize, + pub metadata_reads: usize, + pub worktree_visits: usize, + pub worktree_reads: usize, +} + #[derive(Default, Debug)] pub(super) struct WatchPlan { + #[cfg(test)] + pub discovery_work: DiscoveryWork, pub dirs: FxHashSet, pub worktree_dirs: FxHashSet, pub boundaries: Vec, @@ -90,6 +102,12 @@ impl WatchPlan { } // This ordering closes the creation gap: an earlier child is found // below; a later one generates an event from the registered parent. + #[cfg(test)] + if worktree { + self.discovery_work.worktree_visits += 1; + } else { + self.discovery_work.metadata_visits += 1; + } if visit(&dir).is_err() { self.failures += 1; } @@ -100,6 +118,12 @@ impl WatchPlan { if !keep_going() { return false; } + #[cfg(test)] + if worktree { + self.discovery_work.worktree_reads += 1; + } else { + self.discovery_work.metadata_reads += 1; + } match fs::read_dir(&dir) { Ok(entries) => { let mut children = Vec::new(); diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs index 8307a7917..29009494c 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs @@ -8,6 +8,8 @@ mod runtime_recovery; mod setup_recovery; mod storage_and_links; mod synchronization; +#[cfg(windows)] +mod windows_metadata; mod worktree_list; use super::super::test_sync::{DrainAck, QUIET_WINDOW, SYNC_TIMEOUT}; diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/windows_metadata.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/windows_metadata.rs new file mode 100644 index 000000000..a725ad5a0 --- /dev/null +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/windows_metadata.rs @@ -0,0 +1,298 @@ +//! Windows already owns recursive native coverage before planner discovery. +use super::super::super::plan::DiscoveryWork; +use super::*; + +fn work_since(before: DiscoveryWork, after: DiscoveryWork, operation: &str) -> DiscoveryWork { + let delta = DiscoveryWork { + metadata_visits: after.metadata_visits - before.metadata_visits, + metadata_reads: after.metadata_reads - before.metadata_reads, + worktree_visits: after.worktree_visits - before.worktree_visits, + worktree_reads: after.worktree_reads - before.worktree_reads, + }; + println!("WINDOWS_METADATA_WORK operation={operation} {delta:?}"); + delta +} + +fn setup_fixture() -> (tempfile::TempDir, PathBuf) { + let (temp, root) = repository(); + for directory in [ + "src/nested", + "generated/pkg", + ".git/refs/heads/deep/old", + ".git/logs/refs/heads/deep/old", + ] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + fs::write(root.join(".gitignore"), "generated/\n").unwrap(); + fs::write(root.join("src/nested/.gitignore"), "*.generated\n").unwrap(); + (temp, root) +} + +#[test] +fn windows_metadata_setup_keeps_ignore_discovery_without_metadata_bfs() { + let (_temp, root) = setup_fixture(); + let mut rules = load_gitignore_rules(&root); + let before = rules.state.plan.discovery_work; + let (watcher, outcome, _rx) = rules.start_watcher(&root); + assert_eq!(outcome, WatchSetupOutcome::Watching { failed_dirs: 0 }); + assert_eq!(watcher.watched, FxHashSet::from_iter([root.clone()])); + assert!( + rules + .state + .inputs + .inputs + .contains(&root.join("src/nested/.gitignore")) + ); + assert!( + rules + .state + .snapshot() + .excluded_roots + .contains(&root.join("generated")) + ); + assert!( + rules + .state + .plan + .worktree_dirs + .contains(&root.join("src/nested")) + ); + assert!( + !rules + .state + .plan + .worktree_dirs + .contains(&root.join("generated/pkg")) + ); + let work = work_since(before, rules.state.plan.discovery_work, "setup"); + assert!(work.worktree_reads >= 3 && work.worktree_visits >= 3); + assert_eq!( + work.metadata_reads, 0, + "recursive roots already cover metadata descendants" + ); + assert_eq!(work.metadata_visits, 0); +} + +#[test] +fn windows_metadata_incremental_keeps_new_ignore_inputs_without_metadata_bfs() { + let (_temp, root) = setup_fixture(); + let mut rules = load_gitignore_rules(&root); + let (mut watcher, outcome, _rx) = rules.start_watcher(&root); + assert_eq!(outcome, WatchSetupOutcome::Watching { failed_dirs: 0 }); + let before = rules.state.plan.discovery_work; + let metadata = root.join(".git/refs/tags/created"); + let source = root.join("source-added"); + for directory in [&metadata, &source] { + fs::create_dir_all(directory.join("nested")).unwrap(); + } + fs::write(source.join("nested/.gitignore"), "*.generated\n").unwrap(); + let event = notify::Event::new(EventKind::Create(CreateKind::Folder)) + .add_path(metadata.clone()) + .add_path(source.clone()); + let effect = summarize(&rules.state.snapshot(), &mut rules.state.rules, &event); + assert!( + effect + .change + .as_ref() + .is_some_and(|change| change.git_state && change.tags && change.worktree) + ); + assert!(effect.dir_added.contains(&(metadata, false))); + assert!(effect.dir_added.contains(&(source.clone(), true))); + rules + .state + .apply_directories(&effect, &mut watcher, &rules.config); + assert!( + rules + .state + .inputs + .inputs + .contains(&source.join("nested/.gitignore")) + ); + assert!( + rules + .state + .plan + .worktree_dirs + .contains(&source.join("nested")) + ); + assert_eq!(watcher.watched, FxHashSet::from_iter([root])); + let work = work_since(before, rules.state.plan.discovery_work, "incremental"); + assert_eq!(work.worktree_reads, 2); + assert_eq!(work.worktree_visits, 2); + assert_eq!( + work.metadata_reads, 0, + "new metadata subtrees remain recursively watched" + ); + assert_eq!(work.metadata_visits, 0); +} + +#[test] +fn windows_metadata_overlapping_roots_preserve_legacy_discovery() { + for equal in [false, true] { + let temp = unique_temp_dir("gitcomet-overlap-metadata"); + let base = normalized(&temp.path().canonicalize().unwrap()); + let root = base.join("project"); + init_repo_for_ignore_tests(&root); + fs::create_dir_all(root.join("src/nested")).unwrap(); + fs::write(root.join("src/nested/.gitignore"), "*.generated\n").unwrap(); + let overlap = if equal { root.clone() } else { base }; + let mut rules = load_gitignore_rules(&root); + rules.state.inputs.info.git_dirs.push(overlap.clone()); + // This generic planner deliberately models the pre-optimization ordering. + let expected = TestPlan::build(&root, None, &mut rules); + let (mut watcher, outcome, _rx) = rules.start_watcher(&root); + assert_eq!(outcome, WatchSetupOutcome::Watching { failed_dirs: 0 }); + assert_eq!(rules.state.plan.dirs, expected.dirs); + assert_eq!(rules.state.plan.worktree_dirs, expected.worktree_dirs); + assert_eq!( + rules.state.snapshot().excluded_roots, + expected.policy.excluded_roots + ); + assert_eq!(watcher.watched, FxHashSet::from_iter([overlap.clone()])); + let setup = work_since( + DiscoveryWork::default(), + rules.state.plan.discovery_work, + "overlap-setup", + ); + assert!(setup.metadata_reads > 0 && setup.metadata_visits > 0); + assert_eq!( + setup.worktree_reads, 0, + "retain the existing cross-pass deduplication" + ); + let added = overlap.join("new-metadata/nested"); + fs::create_dir_all(&added).unwrap(); + let before = rules.state.plan.discovery_work; + let event = + notify::Event::new(EventKind::Create(CreateKind::Folder)).add_path(added.clone()); + let effect = summarize(&rules.state.snapshot(), &mut rules.state.rules, &event); + assert!(effect.dir_added.contains(&(added, false))); + rules + .state + .apply_directories(&effect, &mut watcher, &rules.config); + let incremental = work_since( + before, + rules.state.plan.discovery_work, + "overlap-incremental", + ); + assert_eq!(incremental.metadata_reads, 1); + assert_eq!(incremental.metadata_visits, 1); + } +} + +#[test] +fn windows_metadata_skipping_does_not_hide_registration_or_discovery_failures() { + let (_temp, root) = repository(); + let outside = unique_temp_dir("gitcomet-missing-metadata-root"); + let missing = normalized(outside.path()).join("missing/git"); + let mut rules = load_gitignore_rules(&root); + rules.state.inputs.info.git_dirs.push(missing.clone()); + let (watcher, outcome, _rx) = rules.start_watcher(&root); + assert!(matches!(outcome, WatchSetupOutcome::Watching { failed_dirs } if failed_dirs > 0)); + assert!(watch_degraded_reason(outcome).is_some()); + assert!(!watcher.watched.contains(&missing)); + drop(watcher); + + let mut rules = load_gitignore_rules(&root); + rules.state.inputs.info.discovery_incomplete = true; + let (_watcher, outcome, _rx) = rules.start_watcher(&root); + assert_eq!(outcome, WatchSetupOutcome::Watching { failed_dirs: 1 }); + assert!(watch_degraded_reason(outcome).is_some()); +} + +#[test] +fn windows_metadata_native_deep_and_new_subtrees_keep_sustained_coverage() { + let (_temp, root) = setup_fixture(); + let head = fs::read_to_string(root.join(".git/HEAD")).unwrap(); + let oid = fs::read( + root.join(".git") + .join(head.trim().strip_prefix("ref: ").unwrap()), + ) + .unwrap(); + let existing_ref = root.join(".git/refs/heads/deep/old/branch"); + let existing_log = root.join(".git/logs/refs/heads/deep/old/branch"); + fs::write(&existing_ref, &oid).unwrap(); + fs::write(&existing_log, "initial log\n").unwrap(); + let monitor = RunningMonitor::start(&root); + let generation = monitor.drain_delivered().generation; + assert!( + monitor + .expect_change(&existing_ref, || fs::write(&existing_ref, &oid).unwrap()) + .git_state + ); + assert!( + monitor + .expect_change(&existing_log, || fs::write(&existing_log, "edited log\n") + .unwrap()) + .git_state + ); + + let created = root.join(".git/refs/tags/new-tree"); + assert!( + monitor + .expect_change(&created, || { + fs::create_dir_all(created.join("nested/deep")).unwrap(); + fs::write(created.join("nested/deep/tag"), &oid).unwrap(); + }) + .tags + ); + settle_native(&monitor); + let tag = created.join("nested/deep/tag"); + assert!( + monitor + .expect_change(&tag, || fs::write(&tag, &oid).unwrap()) + .tags + ); + settle_native(&monitor); + let moved = root.join(".git/refs/tags/moved-tree"); + assert!( + monitor + .expect_change(&moved, || fs::rename(&created, &moved).unwrap()) + .tags + ); + settle_native(&monitor); + let moved_tag = moved.join("nested/deep/tag"); + assert!( + monitor + .expect_change(&moved_tag, || fs::write(&moved_tag, &oid).unwrap()) + .tags + ); + settle_native(&monitor); + assert!( + monitor + .expect_change(&moved, || fs::remove_dir_all(&moved).unwrap()) + .tags + ); + + let late_log = root.join(".git/logs/refs/heads/new-log/nested"); + assert!( + monitor + .expect_change(&late_log, || { + fs::create_dir_all(&late_log).unwrap(); + fs::write(late_log.join("branch"), "initial\n").unwrap(); + }) + .git_state + ); + settle_native(&monitor); + let branch_log = late_log.join("branch"); + assert!( + monitor + .expect_change(&branch_log, || fs::write(&branch_log, "later edit\n") + .unwrap()) + .git_state + ); + assert_eq!( + monitor.drain_delivered().generation, + generation, + "ordinary metadata subtrees must not need a replacement watcher" + ); +} + +fn settle_native(monitor: &RunningMonitor) { + monitor.settle(); + // Before a later in-place write, drain the actual Windows queue rather than + // allowing an older create/rename event for that same path to satisfy it. + monitor + .checkpoint_native(Instant::now() + SYNC_TIMEOUT) + .unwrap(); + monitor.drain_delivered(); +} From 265840a78c4094671febc60f79add3efaaa34d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sun, 18 Oct 2026 13:44:07 +0300 Subject: [PATCH 31/67] Preallocate bounded activation control-file reads Take does not inherit File's read_to_end capacity hint, so reading small Git control files repeatedly grows their buffers. Reserve the already budget-validated metadata length after the file opens, treating reservation failure as optional. Keep reading through the bounded Take to EOF and enforce the limit against the actual bytes, including when a file grows or shrinks after metadata was read. Cache alias handling and fresh filesystem validation are unchanged. Windows release/MiMalloc proof: 24 paired full MetadataSnapshot::capture observations per workload across two balanced sessions. With 8,192 extra loose refs, marginal medians were 806.10 -> 784.19 ms; the paired median saving was 19.48 ms (2.38%), with a 95% stratified bootstrap interval of 10.94-33.50 ms. Both sessions improved. Conventional, 4 KiB control and four-linked-worktree cases passed the predeclared regression guards. All 224 fixed native invocations were retained with equal full snapshot witnesses. This measures metadata capture, not whole foreground latency. Validation: both frozen release monitor suites passed 193 tests with zero failures and two ignored tests each. Each also reported 11 Windows symlink fixture privilege skips; those bodies remain unexercised. Scoped ci-test Clippy passed with warnings denied; formatting and diff checks passed. Full source/build/raw timing and correctness evidence, including the earlier inconclusive isolated read prototype, is retained in the local performance investigation artifacts. --- crates/gitcomet-state/src/store/repo_monitor/activation.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/activation.rs b/crates/gitcomet-state/src/store/repo_monitor/activation.rs index 5446e0425..364ed8602 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/activation.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/activation.rs @@ -463,8 +463,11 @@ impl MetadataSnapshot { // preserving timestamps. Their bytes are the actual input. use std::io::Read; let mut contents = Vec::new(); - fs::File::open(&identity)? - .take(MAX_CONTROL_FILE_BYTES - self.bytes + 1) + let file = fs::File::open(&identity)?; + // Take does not inherit File's read_to_end capacity hint. Keep + // this hint optional: the actual bytes still enforce the limit. + let _ = contents.try_reserve_exact(metadata.len() as usize); + file.take(MAX_CONTROL_FILE_BYTES - self.bytes + 1) .read_to_end(&mut contents)?; self.bytes += contents.len() as u64; if self.bytes > MAX_CONTROL_FILE_BYTES { From b21ad32f2c2306910b6b168a5ab5be7d62545207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sun, 18 Oct 2026 14:47:12 +0300 Subject: [PATCH 32/67] Filter hooks directory timestamp echoes from Git refreshes Windows CI reported Modify(Any) for a nested submodule's hooks directory while checking that read-only LFS status does not queue another refresh. The event was classified as Git state and scheduled unnecessary reloads. Extend the shared directory-modification predicate to plain hooks directories directly under discovered Git roots. Require fresh metadata and reject symlinks and every Windows reparse point. Preserve hook-file changes, unknown paths, missing metadata, mixed real changes, structural events, rescan requests, and shallow watcher registration. Proof: replaying the recorded event against a real discovered submodule fails before the fix (Relevant, git_state=true) and passes afterward (Drop, no change). All 14 focused candidate cases pass, including the unchanged native Git LFS regression and actual Windows junction coverage. Directory symlink coverage is unavailable on this machine and is recorded separately. Formatting and scoped Clippy checks are recorded with the local before/after evidence. --- .../src/store/repo_monitor/policy.rs | 32 ++- .../src/store/repo_monitor/tests/selective.rs | 1 + .../tests/selective/hooks_directory_noise.rs | 249 ++++++++++++++++++ 3 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 crates/gitcomet-state/src/store/repo_monitor/tests/selective/hooks_directory_noise.rs diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index 0c0020f8e..74530007c 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -141,8 +141,36 @@ impl PolicySnapshot { } pub fn is_git_directory_modify(&self, path: &Path, event: ¬ify::Event) -> bool { - matches!(event.kind, notify::EventKind::Modify(kind) if !matches!(kind, notify::event::ModifyKind::Name(_))) - && self.git_roots.contains(path) + if !matches!(event.kind, notify::EventKind::Modify(kind) if !matches!(kind, notify::event::ModifyKind::Name(_))) + { + return false; + } + if self.git_roots.contains(path) { + return true; + } + // Git/LFS can touch the hooks directory while reading status. Match + // only the immediate hooks boundary of a discovered Git directory; + // names elsewhere and structural events must retain their coverage. + if path.file_name().is_none_or(|name| name != "hooks") + || !path + .parent() + .is_some_and(|parent| self.git_roots.contains(parent)) + { + return false; + } + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Junctions and other reparse entries can also report directories. + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return false; + } + } + metadata.is_dir() && !metadata.file_type().is_symlink() } } diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs index 29009494c..280287773 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective.rs @@ -1,6 +1,7 @@ use super::*; use crate::model::LoadKind; mod efficiency; +mod hooks_directory_noise; mod native_lifecycle; mod path_identities; mod policy_lifecycle; diff --git a/crates/gitcomet-state/src/store/repo_monitor/tests/selective/hooks_directory_noise.rs b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/hooks_directory_noise.rs new file mode 100644 index 000000000..da85c6fb6 --- /dev/null +++ b/crates/gitcomet-state/src/store/repo_monitor/tests/selective/hooks_directory_noise.rs @@ -0,0 +1,249 @@ +//! Deterministic replay of the Windows CI hooks-directory event; real Gix policy. +use super::*; + +struct Fixture { + _temp: tempfile::TempDir, + _seed: tempfile::TempDir, + root: PathBuf, + child_git: PathBuf, + rules: TestRules, +} +fn fixture() -> Fixture { + let (temp, root) = repository(); + let (seed_temp, seed) = repository(); + let subpath = "Assets/Standard Assets/CharacterBuilder"; + run_git( + &root, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + seed.to_str().unwrap(), + subpath, + ], + ); + run_git(&root, &["commit", "-am", "hooks-noise fixture submodule"]); + let child_git = normalized(&resolve_git_dir(&root.join(subpath)).unwrap()); + assert_eq!( + child_git, + root.join(".git/modules/Assets/Standard Assets/CharacterBuilder") + ); + fs::create_dir_all(child_git.join("hooks")).unwrap(); + let rules = load_gitignore_rules(&root); + assert!(!rules.failed); + assert!(rules.state.inputs.backend_discovered); + assert!(!rules.state.inputs.info.discovery_incomplete); + assert!( + rules.state.snapshot().git_roots.contains(&child_git), + "actual Gix discovery must recognize CI child Git directory" + ); + Fixture { + _temp: temp, + _seed: seed_temp, + root, + child_git, + rules, + } +} +fn evaluate(fixture: &mut Fixture, event: ¬ify::Event) -> (Triage, EventEffect) { + let policy = fixture.rules.state.snapshot(); + let triage = triage(&policy, event); + let effect = summarize(&policy, &mut fixture.rules.state.rules, event); + println!( + "HOOKS_NOISE event={event:?} parent_known={} triage={triage:?} effect={effect:?}", + event + .paths + .first() + .and_then(|p| p.parent()) + .is_some_and(|p| policy.git_roots.contains(p)) + ); + (triage, effect) +} +fn modify(path: PathBuf) -> notify::Event { + notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path) +} +fn require_git_change(fixture: &mut Fixture, path: PathBuf) { + let (triage, effect) = evaluate(fixture, &modify(path)); + assert_eq!(triage, Triage::Relevant); + assert!(effect.change.is_some_and(|change| change.git_state)); +} +#[test] +fn ci_recorded_nested_hooks_modify_is_quiet() { + let mut fixture = fixture(); + let hooks = fixture.child_git.join("hooks"); + let metadata = fs::symlink_metadata(&hooks).unwrap(); + assert!(metadata.is_dir() && !metadata.file_type().is_symlink()); + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + assert_eq!( + metadata.file_attributes() & 0x400, + 0, + "counterexample requires a plain non-reparse directory" + ); + } + let event = modify(hooks); + let (triage, effect) = evaluate(&mut fixture, &event); + assert_eq!( + triage, + Triage::Drop, + "CI-recorded hooks directory echo must be dropped" + ); + assert_eq!(effect.change, None); + assert!(!effect.policy_dirty && !effect.index_dirty && effect.dir_added.is_empty()); +} +#[test] +fn guard_missing_file_unknown_and_named_modules_stay_relevant() { + let mut fixture = fixture(); + let hooks = fixture.child_git.join("hooks"); + let saved = fixture.child_git.join("hooks-original"); + fs::rename(&hooks, &saved).unwrap(); + require_git_change(&mut fixture, hooks.clone()); // Missing metadata stays conservative. + fs::write(&hooks, "ordinary file at hooks boundary").unwrap(); + require_git_change(&mut fixture, hooks.clone()); + fs::remove_file(&hooks).unwrap(); + fs::rename(&saved, &hooks).unwrap(); + for path in [ + fixture.child_git.join("unknown-directory"), + fixture.root.join(".git/modules/unknown/hooks"), + ] { + fs::create_dir_all(&path).unwrap(); + require_git_change(&mut fixture, path); + } + let descendant = hooks.join("custom-hook"); + fs::write(&descendant, "hook file edit stays relevant").unwrap(); + require_git_change(&mut fixture, descendant); + let named_module = fixture.root.join(".git/modules/hooks"); + run_git( + &fixture.root, + &["init", "--bare", named_module.to_str().unwrap()], + ); + fixture.rules.reload(&fixture.root); + assert!( + fixture + .rules + .state + .snapshot() + .git_roots + .contains(&named_module) + ); + for path in [ + named_module.join("HEAD"), + named_module.join("refs/heads/hooks/topic"), + named_module.join("unknown-metadata"), + fixture.child_git.join("hooks-not-private"), + ] { + require_git_change(&mut fixture, path); + } +} +#[test] +fn guard_mixed_real_paths_remain_relevant() { + let mut fixture = fixture(); + let event = modify(fixture.child_git.join("hooks")) + .add_path(fixture.child_git.join("HEAD")) + .add_path(fixture.root.join(".git/index")) + .add_path(fixture.root.join("source.txt")); + let (triage, effect) = evaluate(&mut fixture, &event); + assert_eq!(triage, Triage::Relevant); + let change = effect.change.unwrap(); + assert!(change.git_state && change.index && change.worktree); + assert_eq!( + change.paths, + crate::msg::ChangedPaths::known(vec![PathBuf::from("source.txt")]) + ); +} +#[test] +fn guard_structural_and_rescan_events_remain_relevant() { + let mut fixture = fixture(); + for path in [ + fixture.root.join(".git"), + fixture.child_git.clone(), + fixture.child_git.join("hooks"), + ] { + for kind in [ + EventKind::Create(CreateKind::Folder), + EventKind::Remove(RemoveKind::Folder), + EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::Any)), + ] { + let event = notify::Event::new(kind).add_path(path.clone()); + let (triage, effect) = evaluate(&mut fixture, &event); + assert_eq!(triage, Triage::Relevant); + assert!(effect.change.is_some()); + } + let event = modify(path).set_flag(notify::event::Flag::Rescan); + let (triage, effect) = evaluate(&mut fixture, &event); + assert_eq!(triage, Triage::Rescan); + assert_eq!(effect.change, Some(RepoExternalChange::all())); + } + for path in [ + fixture.root.join(".git/HEAD"), + fixture.root.join(".git/refs/heads/main"), + fixture.root.join(".git/unknown-metadata"), + ] { + require_git_change(&mut fixture, path); + } +} +#[test] +fn guard_shallow_planner_still_discovers_hooks() { + let mut fixture = fixture(); + let nested = fixture.child_git.join("hooks/nested"); + fs::create_dir_all(&nested).unwrap(); + let policy = fixture.rules.state.snapshot(); + let mut plan = WatchPlan::default(); + let mut registered = Vec::new(); + plan.walk( + [fixture.child_git.clone()], + false, + &policy, + &mut fixture.rules.state.rules, + &mut fixture.rules.state.inputs, + MAX_WORKTREE_WATCH_DIRS, + |path| { + registered.push(path.to_path_buf()); + Ok(()) + }, + ); + assert!(registered.contains(&fixture.child_git.join("hooks"))); + assert!(registered.contains(&nested)); + assert!(plan.dirs.contains(&nested)); + println!("HOOKS_SHALLOW_PLANNER positive_hooks_and_nested_registration=true"); +} +#[test] +fn guard_linked_and_reparse_hooks_stay_relevant() { + let mut fixture = fixture(); + let hooks = fixture.child_git.join("hooks"); + let target = fixture.child_git.join("hooks-target"); + fs::rename(&hooks, &target).unwrap(); + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + let result = std::process::Command::new("cmd") + .current_dir(&fixture.child_git) + .args(["/d", "/c", "mklink", "/J", "hooks", "hooks-target"]) + .output() + .unwrap(); + assert!( + result.status.success(), + "actual junction prerequisite failed: {result:?}" + ); + let metadata = fs::symlink_metadata(&hooks).unwrap(); + assert_ne!(metadata.file_attributes() & 0x400, 0); + require_git_change(&mut fixture, hooks.clone()); + fs::remove_dir(&hooks).unwrap(); + println!("HOOKS_JUNCTION_REPARSE exercised=true remains_relevant=true"); + } + if gitcomet_core::test_support::symlink::directory(&target, &hooks) { + require_git_change(&mut fixture, hooks.clone()); + #[cfg(windows)] + fs::remove_dir(&hooks).unwrap(); + #[cfg(unix)] + fs::remove_file(&hooks).unwrap(); + println!("HOOKS_DIRECTORY_SYMLINK exercised=true remains_relevant=true"); + } else { + eprintln!( + "HOOKS_DIRECTORY_SYMLINK unsupported-unexercised; actual junction proof remains separate" + ); + } + fs::rename(&target, &hooks).unwrap(); +} From af50f653c0a5b68c5e398a683299791a07c9b791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sun, 18 Oct 2026 19:21:12 +0300 Subject: [PATCH 33/67] perf(gix): reuse attribute source descriptors for unchanged indexes Repeated support refreshes walked every index entry to rediscover tracked .gitattributes files, including repositories without LFS patterns. Cache only the ordered path/blob descriptors against the identical parsed-index Arc. Read contents on every call, use a weak key, and keep cold row scans lazy. On Windows 11 / Ryzen AI 9 HX 370 with MiMalloc 0.7, the real support endpoint on a 262,144-entry fixture improved from 1.4784 to 0.58065 ms per repeated call: paired median saving 0.895475 ms (60.83%), 95% paired-bootstrap interval [0.88145, 0.92275] ms. The large plain fixture saved 0.8003 ms. All per-fixture first/repeated regression gates passed. These are endpoint measurements, not application startup or foreground timings. Evidence: 64 fixed acceptance processes plus 8 separate diagnostics, no replacement observations; independent raw-data audit passed. All 438 backend unit tests and 42 LFS integration tests passed, including four freshness, fallback, cancellation and read-only guards. Formatting and scoped Clippy with warnings denied passed. --- .../gitcomet-git-gix/src/repo/large_files.rs | 123 ++++++++-- .../large_files_attribute_source_tests.rs | 231 ++++++++++++++++++ crates/gitcomet-git-gix/src/repo/mod.rs | 3 + 3 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 crates/gitcomet-git-gix/src/repo/large_files_attribute_source_tests.rs diff --git a/crates/gitcomet-git-gix/src/repo/large_files.rs b/crates/gitcomet-git-gix/src/repo/large_files.rs index 4eb904581..602b5388b 100644 --- a/crates/gitcomet-git-gix/src/repo/large_files.rs +++ b/crates/gitcomet-git-gix/src/repo/large_files.rs @@ -15,6 +15,7 @@ use gitcomet_core::services::{CancellationToken, Result}; use gix::bstr::ByteSlice; use std::io::Read as _; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Weak}; /// Larger status snapshots are not classified: each row costs an attribute /// lookup and a small read, and such trees are rarely all large files. @@ -247,7 +248,7 @@ impl CommittedPointerScan { // Global filter config only means LFS is installed. Use the same // attribute sources as the support summary, including nested ones. let lfs = stores.lfs.join("objects").is_dir() - || scan_lfs_attributes(repo, &CancellationToken::new(), |pattern, _| { + || scan_lfs_attributes(repo, &CancellationToken::new(), None, |pattern, _| { pattern.is_some() }) .unwrap_or(false); @@ -328,6 +329,68 @@ impl LockableLookup<'_> { } } +/// Only descriptors are cached: attribute contents are read afresh each scan. +/// A weak key cannot retain the parsed index, and cannot match another snapshot. +pub(super) struct AttributeSources { + index: Weak>, + sources: Arc<[(PathBuf, gix::ObjectId)]>, +} + +fn tracked_attribute_sources( + index: &gix::worktree::Index, +) -> impl Iterator + '_ { + index.entries().iter().filter_map(move |entry| { + let path = entry.path(index); + (path == ".gitattributes" || path.ends_with(b"/.gitattributes")) + .then(|| { + gix::path::from_bstr(path) + .ok() + .map(|path| (path.into_owned(), entry.id)) + }) + .flatten() + }) +} + +fn cached_attribute_sources( + index: &gix::worktree::Index, + cache: &Mutex>, + cancellation: &CancellationToken, +) -> Result> { + cancellation.check_cancelled()?; + let index_key = Arc::downgrade(index); + { + let cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cached) = cache + .as_ref() + .filter(|cached| cached.index.ptr_eq(&index_key)) + { + return Ok(Arc::clone(&cached.sources)); + } + } + + // The index walk does not hold the cache lock. Only full support scans use + // this path; cold row classification must retain its lazy early exit. + let mut sources = Vec::new(); + for source in tracked_attribute_sources(index) { + cancellation.check_cancelled()?; + sources.push(source); + } + let sources: Arc<[(PathBuf, gix::ObjectId)]> = sources.into(); + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + cancellation.check_cancelled()?; + if let Some(cached) = cache + .as_ref() + .filter(|cached| cached.index.ptr_eq(&index_key)) + { + return Ok(Arc::clone(&cached.sources)); + } + *cache = Some(AttributeSources { + index: index_key, + sources: Arc::clone(&sources), + }); + Ok(sources) +} + /// Visit `filter=lfs` patterns and independent `lockable` rules in tracked /// `.gitattributes`, an untracked root one, and `info/attributes`. Macros are /// not expanded here; per-file state uses the real attribute stack. @@ -336,22 +399,37 @@ impl LockableLookup<'_> { fn scan_lfs_attributes( repo: &gix::Repository, cancellation: &CancellationToken, - mut found: impl FnMut(Option, bool) -> bool, + cache: Option<&Mutex>>, + found: impl FnMut(Option, bool) -> bool, ) -> Result { - let workdir = repo.workdir().unwrap_or(repo.common_dir()); let index = repo .index_or_empty() .map_err(|e| backend_error("read index for LFS patterns", e))?; - let sources = index.entries().iter().filter_map(|entry| { - let path = entry.path(&index); - (path == ".gitattributes" || path.ends_with(b"/.gitattributes")) - .then(|| { - gix::path::from_bstr(path) - .ok() - .map(|path| (path.into_owned(), Some(entry.id))) - }) - .flatten() - }); + if let Some(cache) = cache { + let sources = cached_attribute_sources(&index, cache, cancellation)?; + scan_lfs_attribute_sources( + repo, + cancellation, + sources.iter().map(|(path, id)| (path.clone(), Some(*id))), + found, + ) + } else { + scan_lfs_attribute_sources( + repo, + cancellation, + tracked_attribute_sources(&index).map(|(path, id)| (path, Some(id))), + found, + ) + } +} + +fn scan_lfs_attribute_sources( + repo: &gix::Repository, + cancellation: &CancellationToken, + sources: impl Iterator)>, + mut found: impl FnMut(Option, bool) -> bool, +) -> Result { + let workdir = repo.workdir().unwrap_or(repo.common_dir()); // Include the untracked root and the local attribute overrides too. let sources = sources.chain([ (PathBuf::from(".gitattributes"), None), @@ -641,11 +719,16 @@ impl super::GixRepo { storage_dir, ..LfsRepoInfo::default() }; - scan_lfs_attributes(&repo, cancellation, |pattern, lockable| { - lfs.tracked_patterns.extend(pattern); - lfs.has_lockable_patterns |= lockable; - false - })?; + scan_lfs_attributes( + &repo, + cancellation, + Some(&self.attribute_sources), + |pattern, lockable| { + lfs.tracked_patterns.extend(pattern); + lfs.has_lockable_patterns |= lockable; + false + }, + )?; let annex = AnnexRepoInfo { has_annex_dir: repo.common_dir().join("annex").is_dir(), @@ -781,3 +864,7 @@ impl super::GixRepo { .map(|classified| (classified, LargeFileWorktree::Content)) } } + +#[cfg(test)] +#[path = "large_files_attribute_source_tests.rs"] +mod attribute_source_tests; diff --git a/crates/gitcomet-git-gix/src/repo/large_files_attribute_source_tests.rs b/crates/gitcomet-git-gix/src/repo/large_files_attribute_source_tests.rs new file mode 100644 index 000000000..51aea3ef4 --- /dev/null +++ b/crates/gitcomet-git-gix/src/repo/large_files_attribute_source_tests.rs @@ -0,0 +1,231 @@ +use super::*; +use crate::test_support::{git_stdout, git_success, init_test_repo, open_repo, write_file}; +use std::fs::{self, File, FileTimes}; +use std::time::{Duration, SystemTime}; + +fn pattern(source: &str, pattern: &str) -> LfsTrackedPattern { + LfsTrackedPattern { + pattern: pattern.to_owned(), + source: PathBuf::from(source), + } +} + +fn support_without_index_write(repo: &super::super::GixRepo, index: &Path) -> LargeFileSupport { + let before = fs::read(index).unwrap(); + let modified = fs::metadata(index).unwrap().modified().unwrap(); + let support = repo + .large_file_support_impl(&CancellationToken::new()) + .unwrap(); + assert_eq!(fs::read(index).unwrap(), before); + assert_eq!(fs::metadata(index).unwrap().modified().unwrap(), modified); + support +} + +fn replace_preserving_size_and_mtime(path: &Path, contents: &str) { + let metadata = fs::metadata(path).unwrap(); + let modified = metadata.modified().unwrap(); + assert_eq!(metadata.len(), contents.len() as u64); + fs::write(path, contents).unwrap(); + File::options() + .write(true) + .open(path) + .unwrap() + .set_times(FileTimes::new().set_modified(modified)) + .unwrap(); + let after = fs::metadata(path).unwrap(); + assert_eq!(after.len(), metadata.len()); + assert_eq!(after.modified().unwrap(), modified); + assert_eq!(fs::read_to_string(path).unwrap(), contents); +} + +// These cases exercise a genuinely new parsed-index snapshot, not the separate +// same/backdated-index-mtime freshness limitation inherited from gix. +fn advance_index_mtime(index: &Path, previous: SystemTime) -> SystemTime { + let current = fs::metadata(index).unwrap().modified().unwrap(); + let next = current.max(previous) + Duration::from_secs(2); + File::options() + .write(true) + .open(index) + .unwrap() + .set_times(FileTimes::new().set_modified(next)) + .unwrap(); + let actual = fs::metadata(index).unwrap().modified().unwrap(); + assert_eq!(actual, next); + assert!(actual > previous); + actual +} + +#[test] +fn repeated_support_reads_same_size_same_mtime_tracked_attribute_edits() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file( + workdir, + ".gitattributes", + "*.old filter=lfs\n*.bin lockable\n", + ); + write_file(workdir, "nested/.gitattributes", "*.child filter=lfs\n"); + git_success(workdir, &["add", ".gitattributes", "nested/.gitattributes"]); + git_success(workdir, &["commit", "-m", "attributes"]); + let repo = open_repo(workdir); + let index = workdir.join(".git/index"); + let initial = support_without_index_write(&repo, &index); + assert_eq!( + initial.lfs.tracked_patterns, + vec![ + pattern(".gitattributes", "*.old"), + pattern("nested/.gitattributes", "*.child"), + ] + ); + assert!(initial.lfs.has_lockable_patterns); + + replace_preserving_size_and_mtime( + &workdir.join(".gitattributes"), + "*.new filter=lfs\n*.bin lockable\n", + ); + let updated = support_without_index_write(&repo, &index); + assert_eq!( + updated.lfs.tracked_patterns, + vec![ + pattern(".gitattributes", "*.new"), + pattern("nested/.gitattributes", "*.child"), + ] + ); + assert!(updated.lfs.has_lockable_patterns); +} + +#[test] +fn support_tracks_added_removed_sources_and_changed_index_blob_fallback() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file(workdir, "seed.txt", "seed\n"); + git_success(workdir, &["add", "seed.txt"]); + git_success(workdir, &["commit", "-m", "seed"]); + let repo = open_repo(workdir); + let index = workdir.join(".git/index"); + let mut modified = fs::metadata(&index).unwrap().modified().unwrap(); + assert!( + support_without_index_write(&repo, &index) + .lfs + .tracked_patterns + .is_empty() + ); + + write_file(workdir, "nested/.gitattributes", "*.old filter=lfs\n"); + git_success(workdir, &["add", "nested/.gitattributes"]); + assert_eq!( + git_stdout(workdir, &["show", ":nested/.gitattributes"]), + "*.old filter=lfs" + ); + modified = advance_index_mtime(&index, modified); + fs::remove_file(workdir.join("nested/.gitattributes")).unwrap(); + assert_eq!( + support_without_index_write(&repo, &index) + .lfs + .tracked_patterns, + vec![pattern("nested/.gitattributes", "*.old")] + ); + + // A different worktree size makes Git observe the content replacement even + // on a filesystem whose timestamps have coarse precision. + write_file(workdir, "nested/.gitattributes", "*.changed filter=lfs\n"); + git_success(workdir, &["add", "nested/.gitattributes"]); + assert_eq!( + git_stdout(workdir, &["show", ":nested/.gitattributes"]), + "*.changed filter=lfs" + ); + modified = advance_index_mtime(&index, modified); + fs::remove_file(workdir.join("nested/.gitattributes")).unwrap(); + assert_eq!( + support_without_index_write(&repo, &index) + .lfs + .tracked_patterns, + vec![pattern("nested/.gitattributes", "*.changed")] + ); + + git_success(workdir, &["rm", "--cached", "--", "nested/.gitattributes"]); + advance_index_mtime(&index, modified); + assert!( + support_without_index_write(&repo, &index) + .lfs + .tracked_patterns + .is_empty() + ); +} + +#[test] +fn support_reads_linked_worktree_untracked_root_and_common_info_each_time() { + let dir = tempfile::tempdir().unwrap(); + let main = dir.path().join("main"); + let linked = dir.path().join("linked"); + fs::create_dir(&main).unwrap(); + init_test_repo(&main); + write_file(&main, "seed.txt", "seed\n"); + git_success(&main, &["add", "seed.txt"]); + git_success(&main, &["commit", "-m", "seed"]); + git_success( + &main, + &["worktree", "add", "-b", "linked", linked.to_str().unwrap()], + ); + assert!(linked.join(".git").is_file()); + write_file(&main, ".gitattributes", "*.main filter=lfs\n"); + write_file(&linked, ".gitattributes", "*.root filter=lfs\n"); + write_file( + &main, + ".git/info/attributes", + "*.info filter=lfs\n*.bin lockable\n", + ); + let repo = open_repo(&linked); + let index = repo.repo().index_path(); + let initial = support_without_index_write(&repo, &index); + assert_eq!( + initial.lfs.tracked_patterns, + vec![ + pattern(".gitattributes", "*.root"), + pattern(".git/info/attributes", "*.info"), + ] + ); + assert!(initial.lfs.has_lockable_patterns); + + replace_preserving_size_and_mtime(&linked.join(".gitattributes"), "*.next filter=lfs\n"); + replace_preserving_size_and_mtime( + &main.join(".git/info/attributes"), + "*.more filter=lfs\n*.bin lockable\n", + ); + let updated = support_without_index_write(&repo, &index); + assert_eq!( + updated.lfs.tracked_patterns, + vec![ + pattern(".gitattributes", "*.next"), + pattern(".git/info/attributes", "*.more"), + ] + ); + assert!(updated.lfs.has_lockable_patterns); +} + +#[test] +fn support_pre_cancellation_is_an_error_before_and_after_a_successful_read() { + let dir = tempfile::tempdir().unwrap(); + let workdir = dir.path(); + init_test_repo(workdir); + write_file(workdir, ".gitattributes", "*.bin filter=lfs\n"); + git_success(workdir, &["add", ".gitattributes"]); + git_success(workdir, &["commit", "-m", "attributes"]); + let repo = open_repo(workdir); + let index = workdir.join(".git/index"); + let before = fs::read(&index).unwrap(); + let modified = fs::metadata(&index).unwrap().modified().unwrap(); + for warm in [false, true] { + if warm { + support_without_index_write(&repo, &index); + } + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let error = repo.large_file_support_impl(&cancellation).unwrap_err(); + assert!(matches!(error.kind(), ErrorKind::Cancelled)); + assert_eq!(fs::read(&index).unwrap(), before); + assert_eq!(fs::metadata(&index).unwrap().modified().unwrap(), modified); + } +} diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index 8f1467d7c..9a018d10a 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -413,6 +413,8 @@ pub(crate) struct GixRepo { branch_tracking_config: std::sync::Mutex>, /// Shared by row, diff and preview readers; refreshed with support metadata. large_file_scan: std::sync::Mutex>>, + /// Ordered tracked attribute descriptors for full support scans, not contents. + attribute_sources: std::sync::Mutex>, tree_index_cache: std::sync::Mutex>, history_cache: log::cache::HistoryCache, divergence_cache: DivergenceCache, @@ -467,6 +469,7 @@ impl GixRepo { gitlink_status_capability: std::sync::Mutex::new(None), branch_tracking_config: std::sync::Mutex::new(None), large_file_scan: Default::default(), + attribute_sources: Default::default(), tree_index_cache: std::sync::Mutex::new(None), history_cache: Default::default(), divergence_cache: DivergenceCache::default(), From 7da5d96147bcc88f364baf4d359546e7763d5d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Sun, 18 Oct 2026 19:22:25 +0300 Subject: [PATCH 34/67] perf(theme): skip custom catalog scans for bundled key checks Checking a theme key built and sorted the entire merged options list, reading the custom theme directory even for bundled keys. Query the embedded cache first and use the runtime map only for custom or missing keys. Extend the registry guard to verify the short circuit and unknown-key fallback. On Windows 11 / Ryzen AI 9 HX 370 with MiMalloc 0.7, first bundled-key lookup with 8 custom themes improved from 2.49925 to 1.754 ms: paired median saving 0.75045 ms (29.93%), 95% paired-bootstrap interval [0.729, 0.7969] ms. With 128 custom themes the paired saving was 8.79855 ms. Bundled warm medians fell below timer resolution; this does not mean literal zero cost. All 72 fixed processes and per-profile first/warm gates passed independent raw-data audit. Custom/missing first calls were 0.0948/0.1053 ms slower (3.78/4.35%), below the declared regression gate; warm fallback improved. These are membership endpoint measurements, not whole-app startup timings. Validation: old code fails the new lookup-count guard (35 versus 1); all 60 candidate theme tests pass, including custom edits, collisions and missing keys. Formatting and scoped Clippy with warnings denied pass. --- crates/gitcomet-ui-kit/src/theme.rs | 4 +--- crates/gitcomet-ui-kit/src/theme/tests.rs | 14 +++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/gitcomet-ui-kit/src/theme.rs b/crates/gitcomet-ui-kit/src/theme.rs index 40d941939..8be65faf4 100644 --- a/crates/gitcomet-ui-kit/src/theme.rs +++ b/crates/gitcomet-ui-kit/src/theme.rs @@ -597,9 +597,7 @@ impl ThemeCatalog { } pub fn has_theme_key(key: &str) -> bool { - merged_theme_options(None) - .iter() - .any(|option| option.key == key) + embedded_theme_cache().contains_key(key) || runtime_themes().contains_key(key) } pub fn theme_label(key: &str) -> Option { diff --git a/crates/gitcomet-ui-kit/src/theme/tests.rs b/crates/gitcomet-ui-kit/src/theme/tests.rs index 3fb3c4260..3c31e42ac 100644 --- a/crates/gitcomet-ui-kit/src/theme/tests.rs +++ b/crates/gitcomet-ui-kit/src/theme/tests.rs @@ -5,8 +5,8 @@ use super::{ composite_over, content_header_bg, derived_syntax_color, fill_missing_color_tokens, has_theme_key, hsla_from_hue_fraction, layer_over, load_theme_specs_from_json, merged_theme_options, resolved_runtime_themes_dir, runtime_theme_issues_with_dir, - runtime_themes_with_dir, test_theme_bundle_value, test_theme_json_with_syntax, theme_label, - theme_preview_colors, with_alpha, + runtime_theme_lookups_for_test, runtime_themes_with_dir, test_theme_bundle_value, + test_theme_json_with_syntax, theme_label, theme_preview_colors, with_alpha, }; use palette::IntoColor; use std::{fs, path::PathBuf}; @@ -1996,9 +1996,13 @@ fn embedded_theme_registry_exposes_default_keys() { let themes = available_themes(); assert!(!themes.is_empty()); - assert!(has_theme_key(DEFAULT_DARK_THEME_KEY)); - assert!(has_theme_key(DEFAULT_LIGHT_THEME_KEY)); - assert!(has_theme_key(AMBER_DARK_THEME_KEY)); + let runtime_lookups = runtime_theme_lookups_for_test(); + for (key, _) in bundled_theme_keys() { + assert!(has_theme_key(&key), "{key}"); + } + assert_eq!(runtime_theme_lookups_for_test(), runtime_lookups); + assert!(!has_theme_key("no_such_theme")); + assert_eq!(runtime_theme_lookups_for_test(), runtime_lookups + 1); assert_eq!( theme_label(DEFAULT_DARK_THEME_KEY), Some("GitComet Dark".to_string()) From 6b1f187b086f19060d7d4238d48a5ae5cd85b828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 11:00:50 +0300 Subject: [PATCH 35/67] perf(windows): add startup capture helpers and measured baseline Add PowerShell helpers for detailed samply capture, matching-symbol viewing, startup milestones, and separately instrumented allocation totals. Freeze the executable/PDB together and print a launch command using the frozen binary. Record the DX12 investigation on Ryzen AI 9 HX 370 / Radeon 890M. Five alternating release-with-debug pairs show lazy smoothing reducing median first draw from 2361.6 to 656.9 ms and repository ready from 2802.4 to 1100.6 ms. Actual-profile interaction was observed by 949.5 ms; fully loaded repository startup remains above one second. Explain sampling and memory limits, first-launch variance, and why DX11 precompilation is unrelated. Validation: all five PowerShell files parse; recorded startup, history and settings scenarios pass; four focused GPUI smoothing tests pass on DX12. Large ETL, PDB and binary artifacts stay outside version control. --- docs/windows-startup-performance.md | 140 +++++++++++++++++++++++ scripts/build-gitcomet-allocations.ps1 | 36 ++++++ scripts/capture-gitcomet-allocations.ps1 | 54 +++++++++ scripts/capture-gitcomet-startup.ps1 | 46 ++++++++ scripts/record-gitcomet-startup.ps1 | 89 ++++++++++++++ scripts/view-gitcomet-startup.ps1 | 60 ++++++++++ 6 files changed, 425 insertions(+) create mode 100644 docs/windows-startup-performance.md create mode 100644 scripts/build-gitcomet-allocations.ps1 create mode 100644 scripts/capture-gitcomet-allocations.ps1 create mode 100644 scripts/capture-gitcomet-startup.ps1 create mode 100644 scripts/record-gitcomet-startup.ps1 create mode 100644 scripts/view-gitcomet-startup.ps1 diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md new file mode 100644 index 000000000..ebae70475 --- /dev/null +++ b/docs/windows-startup-performance.md @@ -0,0 +1,140 @@ +# Windows startup investigation + +Measured on October 20, 2026 with a Ryzen AI 9 HX 370 and Radeon 890M. +DX12 remains the default; DX11 was used only as a diagnostic comparison. + +## Baseline and bottleneck + +GitComet baseline: `848514d8b4b9e3d11e37565ebc6399ded0a707fd`. +GPUI baseline: `0665fb07d59cf7af185e58f44b9f890590abe204`. +Lazy-smoothing GPUI change: `9362a02d98324c6d91a1bec58b90ab22ab667252`. + +The original samply capture attributed 2,264 ms of sampled main-thread CPU +to `D3DCompiler_47.dll`: 85.6% of main-thread CPU sampled during the first +three seconds. Four optional corner-smoothing pipelines accounted for 1,878 ms: + +| Pipeline | Inclusive sampled compiler CPU | +| --- | ---: | +| Smoothed quads | 789 ms | +| Smoothed shadows | 687 ms | +| Smoothed polychrome sprites | 213 ms | +| Smoothed blur composite | 188 ms | + +GitComet does not request positive corner smoothing. Its controls use ordinary +circular rounded corners; direct image painting passes zero smoothing. This +optional GPUI feature produces continuous corners similar to iOS/Figma, and is +independent of normal antialiasing, text smoothing, and image filtering. + +The GPUI change creates these four pipelines on first use. It retains their +original shaders and compiles before encoding the first frame that needs them. +It supports reuse, resource rebuilds, failure rollback, and retries. A future +feature requesting smoothing can still incur a one-time compilation delay. + +## Controlled measurements + +Five alternating baseline/candidate pairs used the same `release-with-debug` +profile, DX12/FXC, real GitComet repository, isolated application profiles, and +visible desktop windows. No build ran during these measurements. + +| Milestone | Eager smoothing | Lazy smoothing | +| --- | ---: | ---: | +| Launch to first draw, median | 2,361.6 ms | 656.9 ms | +| Launch to repository ready, median | 2,802.4 ms | 1,100.6 ms | +| Repository-ready range | 2,782.9–2,831.1 ms | 1,067.5–1,117.3 ms | +| Sampled peak working set, median | 855.9 MiB | 784.2 MiB | + +The first observed candidate launch was slower: first draw 836.7 ms and +repository ready 1,280.1 ms. These are not reboot-cold measurements. + +With the actual saved application profile, the parent launcher observed the +first interactive marker by 949.5 ms. App stage tracing recorded first paint +at 665.0 ms, first interactive at 837.3 ms, and repository ready at 1,104.1 ms. +App tracing starts after process creation; the parent measurement also includes +launch and polling overhead. Subsecond interaction was observed on this machine, +but strict subsecond fully loaded repository startup was not achieved. + +The candidate's new 1 kHz samply capture attributed about 360 ms of compiler CPU +to ordinary pipelines and none to the four smoothed initialization callsites. +The original capture used 8 kHz, so the sampled reduction is approximate. +Unprofiled paired measurements establish the latency improvement. + +Additional diagnostic comparisons: + +| Experiment | First draw, median | Repository ready, median | +| --- | ---: | ---: | +| Existing release, eager DX12/FXC, 3 pairs | 2,529.6 ms | 2,875.9 ms | +| Existing release, native DX11, same 3 pairs | 178.7 ms | 530.5 ms | +| Existing release, eager DX12/FXC, separate 3 pairs | 2,616.4 ms | 2,999.7 ms | +| Same release executable, SDK DXC | 1,453.6 ms | 1,840.2 ms | +| Lazy smoothing, FXC, separate 5 pairs | 661.0 ms | 1,110.5 ms | +| Lazy smoothing, SDK DXC, same 5 pairs | 626.8 ms | 1,052.5 ms | + +DXC was Windows SDK 1.8.2502.11, staged next to isolated executables; loaded +modules confirmed the DLLs used. Once smoothing is deferred, DXC adds only a +small improvement. Production packaging does not bundle these SDK DLLs. + +## Remaining work + +The candidate still compiles ordinary shaders, including quads (~121 ms +sampled compiler CPU), shadows (~67 ms), and path rasterization (~39 ms). + +After window creation, repository `LoadStatus` took about 376 ms wall time +in the diagnostic trace. It includes a 149 ms child-command wait while +supplementing status for the populated `fixtures/syntax_test` gitlink. +That supplement preserves submodule correctness; the implementation already +limits its query to gitlinks and skips positively empty, unchanged checkouts. + +Watcher initialization (~142 ms sampled CPU) and directory traversal run +concurrently. A worktree-dirty scan also overlaps the main status scan. +These inclusive and parallel costs must not be added as elapsed-time savings. + +Starting repository bootstrap before window/GPU creation is the next candidate +for overlapping useful work. It must preserve settings-before-restore ordering, +entry gates, Git-runtime readiness, focused modes, and single ownership of +bootstrap dispatch. Any saving requires measurement. + +## Validation and reproduction + +Four smoothing regression tests passed on the candidate: first-use pixel +equivalence, backdrop/isolated-filter planning, creation rollback, and DX12 +retry behavior. Three GPU tests explicitly selected the Radeon 890M/DX12; +the fourth tests planning. History navigation and opening Settings also passed +live UI smoke checks. + +Use `scripts/profiling/live-ui.py measure` for alternating startup measurements. +The startup scenario disables updates, network tag fetching, and signature +checks through its isolated profile. First draw measures completed UI drawing, +not physical display presentation. Memory is sampled every 250 ms and can miss +short peaks; it is not an allocation stack trace or exact retained Rust heap. + +The PowerShell helpers provide: + +- `scripts/record-gitcomet-startup.ps1`: samply capture, frozen executable/PDB, + scheduling/graphics events, and retained ETLs. Default 8 kHz is intentionally + expensive; use `-Rate 1000` for a lighter diagnostic capture. +- `scripts/capture-gitcomet-startup.ps1`: startup stage tracing, with optional + empty profile and graceful automatic exit. +- `scripts/view-gitcomet-startup.ps1`: matching symbols and Windows symbol server. +- `scripts/build-gitcomet-allocations.ps1` and + `scripts/capture-gitcomet-allocations.ps1`: separate instrumented build and + milestone allocation totals. These exclude some native compiler/driver + allocations and do not provide allocation call stacks. + +For sampled CPU analysis, use `threadCPUDelta`. Synthetic off-CPU sample +weights must not be counted as CPU. Keep profiling runs separate from ordinary +latency measurements and avoid compilation during either. + +Large local artifacts remain under ignored `target/startup-investigation/`: +`repo-lazy-pairs/session.json`, `experiment-summary.json`, the frozen +`baseline-frozen/` and `lazy-runtime/` binaries/PDBs, and +`samply-lazy-candidate/` with the matching symbols, ETL, and analysis. +Their availability is local; this document records the durable measurements. + +## Upstream precompilation + +Official GPUI-CE revision `9a82fba4e5f1f9194a5489b7a417d164083dd67a` +precompiles **DX11** shaders in +[gpui_render/build.rs](https://github.com/gpui-ce/gpui-ce/blob/9a82fba4e5f1f9194a5489b7a417d164083dd67a/crates/gpui_render/build.rs). +Its [WGPU pipeline implementation](https://github.com/gpui-ce/gpui-ce/blob/9a82fba4e5f1f9194a5489b7a417d164083dd67a/crates/gpui_wgpu/src/wgpu_renderer/pipelines.rs) +still creates pipelines from WGSL at runtime, including all four smoothing +variants eagerly. DX11 precompilation does not replace the lazy DX12 change. diff --git a/scripts/build-gitcomet-allocations.ps1 b/scripts/build-gitcomet-allocations.ps1 new file mode 100644 index 000000000..cae579614 --- /dev/null +++ b/scripts/build-gitcomet-allocations.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS +Build GitComet with startup allocation counters and debug symbols. +.DESCRIPTION +Uses the release-with-debug profile and perf-alloc feature. The separate +target/alloc-profile directory preserves the ordinary CPU-profiling build. +Allocation instrumentation adds overhead; use this build for allocation counts. +.PARAMETER TargetDirectory +Cargo output directory. Relative paths are resolved from the repository root. +.EXAMPLE +.\scripts\build-gitcomet-allocations.ps1 +#> +[CmdletBinding()] +param( + [string]$TargetDirectory = 'target/alloc-profile' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +if (-not [System.IO.Path]::IsPathRooted($TargetDirectory)) { + $TargetDirectory = Join-Path $repoRoot $TargetDirectory +} +$targetPath = [System.IO.Path]::GetFullPath($TargetDirectory) + +Push-Location -LiteralPath $repoRoot +try { + & cargo build -p gitcomet --bin gitcomet --profile release-with-debug ` + --features perf-alloc --target-dir $targetPath + if ($LASTEXITCODE -ne 0) { + throw "GitComet allocation build failed with exit code $LASTEXITCODE." + } +} finally { + Pop-Location +} + +Write-Output ("Allocation build: " + (Join-Path $targetPath 'release-with-debug/gitcomet.exe')) diff --git a/scripts/capture-gitcomet-allocations.ps1 b/scripts/capture-gitcomet-allocations.ps1 new file mode 100644 index 000000000..eeb5a91a3 --- /dev/null +++ b/scripts/capture-gitcomet-allocations.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS +Capture GitComet startup timings and allocation totals. +.DESCRIPTION +Build first with build-gitcomet-allocations.ps1 and close existing GitComet +windows. The default capture uses saved settings and leaves the app open. +Allow repository loading to finish, then close the app normally. + +Allocation milestone totals are written to stderr.log with the prefix +GITCOMET_PERF_STARTUP_EVENT. Stage timings are in startup.jsonl. Allocation +totals do not include allocation call stacks or all native/GPU allocations. +.PARAMETER Binary +Allocation-enabled executable. Relative paths use the current directory. +.PARAMETER OutputDirectory +Capture directory. Omit to use a fresh directory under target/startup-captures. +.PARAMETER EmptyProfile +Use an isolated application profile to measure the empty Home picker. +.PARAMETER AutoExit +Request normal closure one second after the first interactive frame, or ready +Home picker with EmptyProfile. This does not wait for repository loading. +.PARAMETER TimeoutSeconds +Maximum time to observe the startup milestone; timeout leaves the app running. +.EXAMPLE +.\scripts\capture-gitcomet-allocations.ps1 +.EXAMPLE +.\scripts\capture-gitcomet-allocations.ps1 -EmptyProfile -AutoExit +#> +[CmdletBinding()] +param( + [string]$Binary = '', + [string]$OutputDirectory = '', + [switch]$EmptyProfile, + [switch]$AutoExit, + [ValidateRange(1, 600)][int]$TimeoutSeconds = 90 +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($Binary)) { + $Binary = Join-Path $repoRoot 'target/alloc-profile/release-with-debug/gitcomet.exe' +} +if (-not (Test-Path -LiteralPath $Binary -PathType Leaf)) { + throw "Missing $Binary. Run build-gitcomet-allocations.ps1 first." +} +if (-not $EmptyProfile -and (Get-Process -Name gitcomet -ErrorAction SilentlyContinue)) { + throw 'Close existing GitComet windows before capturing a new startup.' +} +if (-not [string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) +} + +$captureScript = Join-Path $PSScriptRoot 'profiling/capture-startup.ps1' +& $captureScript -Binary $Binary -OutputDirectory $OutputDirectory ` + -EmptyProfile:$EmptyProfile -AutoExit:$AutoExit -TimeoutSeconds $TimeoutSeconds diff --git a/scripts/capture-gitcomet-startup.ps1 b/scripts/capture-gitcomet-startup.ps1 new file mode 100644 index 000000000..72fd124b6 --- /dev/null +++ b/scripts/capture-gitcomet-startup.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS +Launch the release-with-debug GitComet build with startup-stage tracing. +.DESCRIPTION +For CPU profiling, first run record-gitcomet-startup.ps1 in another window and +wait for "Profiling all processes...". This script records startup stages using +the existing capture helper. It returns after the first interactive milestone, +which can precede repository readiness, and leaves GitComet running by default. +.EXAMPLE +.\scripts\capture-gitcomet-startup.ps1 +.EXAMPLE +.\scripts\capture-gitcomet-startup.ps1 -EmptyProfile -AutoExit +#> +[CmdletBinding()] +param( + [string]$Binary = '', + [string]$OutputDirectory = '', + [switch]$EmptyProfile, + [switch]$AutoExit, + [ValidateRange(1, 600)][int]$TimeoutSeconds = 90 +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($Binary)) { + $Binary = Join-Path $repoRoot 'target/release-with-debug/gitcomet.exe' +} +if (-not (Test-Path -LiteralPath $Binary -PathType Leaf)) { + throw "Missing $Binary. Finish building GitComet with --profile release-with-debug." +} +if (-not $EmptyProfile -and (Get-Process -Name gitcomet -ErrorAction SilentlyContinue)) { + throw 'Close existing GitComet windows before capturing a new startup.' +} +$captureArguments = @{ + Binary = $Binary + EmptyProfile = $EmptyProfile + AutoExit = $AutoExit + TimeoutSeconds = $TimeoutSeconds +} +if (-not [string]::IsNullOrWhiteSpace($OutputDirectory)) { + $captureArguments.OutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) +} +& (Join-Path $PSScriptRoot 'profiling/capture-startup.ps1') @captureArguments +if (-not $AutoExit) { + Write-Host 'Wait for repository status/history to finish loading before stopping samply in the recording window.' +} diff --git a/scripts/record-gitcomet-startup.ps1 b/scripts/record-gitcomet-startup.ps1 new file mode 100644 index 000000000..2ea426d75 --- /dev/null +++ b/scripts/record-gitcomet-startup.ps1 @@ -0,0 +1,89 @@ +<# +.SYNOPSIS +Record a detailed Windows samply profile of GitComet startup. +.DESCRIPTION +Finish building release-with-debug and close GitComet first. When samply prints +"Profiling all processes...", run capture-gitcomet-startup.ps1 in a second +PowerShell window. After repository status and history are usable, press Ctrl+C +once in the recording window. The app stays open. + +The default 8000 Hz capture includes scheduling and graphics events and keeps +the raw ETL files. This is a diagnostic capture with significant overhead. +Samply 0.13.1 ignores --duration on Windows. +.EXAMPLE +.\scripts\record-gitcomet-startup.ps1 +.EXAMPLE +.\scripts\record-gitcomet-startup.ps1 -Rate 1000 +#> +[CmdletBinding()] +param( + [ValidateRange(1, 8192)][int]$Rate = 8000, + [string]$OutputDirectory = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$binaryDirectory = Join-Path $repoRoot 'target/release-with-debug' +$binary = Join-Path $binaryDirectory 'gitcomet.exe' +$symbols = Join-Path $binaryDirectory 'gitcomet.pdb' +$samply = Get-Command samply -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $samply) { + throw 'samply was not found on PATH. Install it with: cargo install --locked samply' +} +$xperf = Get-Command xperf -CommandType Application -ErrorAction SilentlyContinue +$xperfDefault = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/Windows Performance Toolkit/xperf.exe' +if ($null -eq $xperf -and -not (Test-Path -LiteralPath $xperfDefault -PathType Leaf)) { + throw 'Install the Windows Performance Toolkit component of the Windows ADK; samply needs xperf.exe.' +} +foreach ($file in @($binary, $symbols)) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + throw "Missing $file. Finish: cargo build -p gitcomet --bin gitcomet --profile release-with-debug" + } +} +if (Get-Process -Name gitcomet -ErrorAction SilentlyContinue) { + throw 'Close existing GitComet windows before recording a new startup.' +} +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $repoRoot ("target/samply-startup-" + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) +} +$captureRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) +if (Test-Path -LiteralPath $captureRoot) { + throw "Choose a new output directory; $captureRoot already exists." +} +$symbolDirectory = Join-Path $captureRoot 'symbols' +New-Item -ItemType Directory -Path $symbolDirectory | Out-Null +# Preserve the matching executable/PDB for viewing after a later rebuild. +Copy-Item -LiteralPath $binary, $symbols -Destination $symbolDirectory +$profilePath = Join-Path $captureRoot 'startup.json.gz' + +Write-Host "Capture directory: $captureRoot" +Write-Host 'Allow the administrator prompt. Wait for "Profiling all processes...".' +Write-Host 'Then run this in a second PowerShell window:' +$launchScript = Join-Path $PSScriptRoot 'capture-gitcomet-startup.ps1' +$stageDirectory = Join-Path $captureRoot 'stages' +Write-Host ("& '{0}' -Binary '{1}' -OutputDirectory '{2}'" -f $launchScript.Replace("'", "''"), (Join-Path $symbolDirectory 'gitcomet.exe').Replace("'", "''"), $stageDirectory.Replace("'", "''")) +Write-Host 'Wait for repository status and history to be usable, then press Ctrl+C ONCE here.' +Write-Host "Avoid builds during capture. This $Rate Hz diagnostic capture changes measured startup timing." + +$recordArguments = @( + 'record', '-a', + '--rate', "$Rate", + '--per-cpu-threads', + '--cswitch-markers', + '--gfx', + '--unknown-event-markers', + '--keep-etl', + '--save-only', + '--profile-name', 'GitComet startup', + '-o', $profilePath +) +& $samply.Source @recordArguments +if ($LASTEXITCODE -ne 0) { + throw "samply exited with code $LASTEXITCODE. Check its output for capture errors." +} +if (-not (Test-Path -LiteralPath $profilePath -PathType Leaf)) { + throw "samply did not create $profilePath." +} +Write-Host "Saved profile: $profilePath" +Write-Host 'Run view-gitcomet-startup.ps1 to open the latest completed capture.' diff --git a/scripts/view-gitcomet-startup.ps1 b/scripts/view-gitcomet-startup.ps1 new file mode 100644 index 000000000..69a5ae747 --- /dev/null +++ b/scripts/view-gitcomet-startup.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS +Open a recorded GitComet startup profile in samply. +.DESCRIPTION +With no arguments, opens the newest completed target/samply-startup-* capture. +Uses its saved executable/PDB when available and Microsoft's symbol server for +Windows libraries. Keep this terminal open while using the profiler viewer. +.EXAMPLE +.\scripts\view-gitcomet-startup.ps1 +.EXAMPLE +.\scripts\view-gitcomet-startup.ps1 -Profile C:\captures\startup.json.gz +#> +[CmdletBinding()] +param( + [string]$Profile = '', + [string]$SymbolDirectory = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$samply = Get-Command samply -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($null -eq $samply) { + throw 'samply was not found on PATH.' +} +if ([string]::IsNullOrWhiteSpace($Profile)) { + $targetDirectory = Join-Path $repoRoot 'target' + if (Test-Path -LiteralPath $targetDirectory -PathType Container) { + $captures = Get-ChildItem -LiteralPath $targetDirectory -Directory -Filter 'samply-startup-*' | + Sort-Object Name -Descending + foreach ($capture in $captures) { + $candidate = Join-Path $capture.FullName 'startup.json.gz' + if ((Test-Path -LiteralPath $candidate -PathType Leaf) -and + (Get-Item -LiteralPath $candidate).Length -gt 0) { + $Profile = $candidate + break + } + } + } + if ([string]::IsNullOrWhiteSpace($Profile)) { + throw 'No completed startup capture found. Run record-gitcomet-startup.ps1, or supply -Profile.' + } +} +$profilePath = (Resolve-Path -LiteralPath $Profile).Path +if ([string]::IsNullOrWhiteSpace($SymbolDirectory)) { + $savedSymbols = Join-Path (Split-Path -Parent $profilePath) 'symbols' + $SymbolDirectory = if (Test-Path -LiteralPath $savedSymbols -PathType Container) { + $savedSymbols + } else { + Join-Path $repoRoot 'target/release-with-debug' + } +} +$symbolPath = (Resolve-Path -LiteralPath $SymbolDirectory).Path +Write-Host "Opening $profilePath" +Write-Host 'Select GitComet and the startup interval, then inspect Call Tree, Flame Graph, and Stack Chart.' +& $samply.Source load --verbose --symbol-dir $symbolPath ` + --windows-symbol-server https://msdl.microsoft.com/download/symbols $profilePath +if ($LASTEXITCODE -ne 0) { + throw "samply exited with code $LASTEXITCODE." +} From 23c4ec0cc0f40535ae0186062f16c5cd061898ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 11:06:42 +0300 Subject: [PATCH 36/67] perf(windows): defer unused DX12 smoothing pipelines Integrate the tested GPUI lazy-smoothing renderer through a focused vendor patch of gpui_ce_wgpu. The four optional corner-smoothing pipelines compile only before a frame first needs them. Preserve shader quality, reuse, resource reset, failure rollback, and DX12 retry handling. GitComet currently requests no positive corner smoothing. On Ryzen AI 9 HX 370 / Radeon 890M, five alternating release-with-debug DX12/FXC pairs reduce median launch-to-first-draw from 2361.6 to 656.9 ms and repository-ready from 2802.4 to 1100.6 ms. Sampled peak working set falls from 855.9 to 784.2 MiB. A fresh trace has about 360 ms compiler CPU versus 2264 ms originally, with no compiler samples at the four smoothed initialization callsites. Sampling rates differ; paired unprofiled runs establish the latency improvement. Vendor the exact renderer from local GPUI commit 9362a02d9832 because it is not published remotely. Keep all other GPUI packages at 0665fb07d59c and retain DX12 selection. Include upstream license, tests and upgrade notes. Only the renderer source entry changes in Cargo.lock; no version churn. Validation: exact renderer passes four smoothing tests on Radeon 890M/DX12, plus startup, history and settings live UI scenarios. Locked offline Cargo metadata resolves one local renderer and all sibling GPUI crates at the original pin. Root application integration will be rebuilt in the follow-up startup-bootstrap investigation. Full repository readiness remains above 1s. --- Cargo.lock | 1 - Cargo.toml | 5 + docs/windows-startup-performance.md | 5 + vendor/README.md | 18 + vendor/gpui-wgpu/Cargo.toml | 98 + vendor/gpui-wgpu/LICENSE-APACHE | 222 ++ vendor/gpui-wgpu/UPSTREAM.md | 57 + vendor/gpui-wgpu/benches/renderer.rs | 209 ++ vendor/gpui-wgpu/examples/custom_gpu.rs | 369 +++ vendor/gpui-wgpu/src/gpui_wgpu.rs | 17 + vendor/gpui-wgpu/src/test_gpu.rs | 15 + vendor/gpui-wgpu/src/wgpu_atlas.rs | 622 +++++ vendor/gpui-wgpu/src/wgpu_context.rs | 1123 +++++++++ vendor/gpui-wgpu/src/wgpu_renderer.rs | 1181 +++++++++ vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs | 766 ++++++ vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs | 298 +++ vendor/gpui-wgpu/src/wgpu_renderer/filters.rs | 284 +++ vendor/gpui-wgpu/src/wgpu_renderer/frame.rs | 1104 +++++++++ .../gpui-wgpu/src/wgpu_renderer/headless.rs | 2203 +++++++++++++++++ .../src/wgpu_renderer/lazy_smoothing_tests.rs | 227 ++ .../gpui-wgpu/src/wgpu_renderer/path_types.rs | 6 + .../gpui-wgpu/src/wgpu_renderer/pipelines.rs | 1077 ++++++++ .../gpui-wgpu/src/wgpu_renderer/platform.rs | 715 ++++++ .../gpui-wgpu/src/wgpu_renderer/resources.rs | 433 ++++ .../gpui-wgpu/src/wgpu_renderer/retained.rs | 318 +++ .../gpui-wgpu/src/wgpu_renderer/settings.rs | 97 + .../src/wgpu_renderer/surface_tests.rs | 153 ++ .../gpui-wgpu/src/wgpu_renderer/surfaces.rs | 163 ++ .../src/wgpu_renderer/surfaces/macos.rs | 405 +++ .../src/wgpu_renderer/surfaces/texture.rs | 70 + .../src/wgpu_renderer/surfaces/unsupported.rs | 24 + .../src/wgpu_renderer/surfaces/windows.rs | 300 +++ .../surfaces/windows/bindings.rs | 1180 +++++++++ .../wgpu_renderer/surfaces/windows/shared.rs | 188 ++ .../wgpu_renderer/surfaces/windows/upload.rs | 121 + vendor/gpui-wgpu/src/wgpu_renderer/target.rs | 519 ++++ vendor/gpui-wgpu/tests/headless_primitives.rs | 642 +++++ 37 files changed, 15234 insertions(+), 1 deletion(-) create mode 100644 vendor/gpui-wgpu/Cargo.toml create mode 100644 vendor/gpui-wgpu/LICENSE-APACHE create mode 100644 vendor/gpui-wgpu/UPSTREAM.md create mode 100644 vendor/gpui-wgpu/benches/renderer.rs create mode 100644 vendor/gpui-wgpu/examples/custom_gpu.rs create mode 100644 vendor/gpui-wgpu/src/gpui_wgpu.rs create mode 100644 vendor/gpui-wgpu/src/test_gpu.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_atlas.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_context.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/filters.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/frame.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/headless.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/platform.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/resources.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/retained.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/settings.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs create mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/target.rs create mode 100644 vendor/gpui-wgpu/tests/headless_primitives.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1f75cf4..13b395e72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4244,7 +4244,6 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" dependencies = [ "anyhow", "core-foundation", diff --git a/Cargo.toml b/Cargo.toml index 84fa7055a..7d1da2618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ exclude = [ "vendor/block", "vendor/block/test_utils", + "vendor/gpui-wgpu", "vendor/mermaid-rs-renderer", "crates/gitcomet-filesystem-native", "crates/win32-window-utils", @@ -109,6 +110,10 @@ rust-version = "1.99.1" # block 0.1.6 has no released fix for its uninhabited extern class static. block = { path = "vendor/block" } +[patch."https://github.com/Havunen/gpui-ce.git"] +# Defer unused smoothing shaders while retaining DX12; see vendor/gpui-wgpu/UPSTREAM.md. +gpui_ce_wgpu = { path = "vendor/gpui-wgpu" } + [workspace.dependencies] # Internal crates gitcomet-app = { path = "crates/gitcomet-app", default-features = false } diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index ebae70475..f1f032549 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -30,6 +30,11 @@ original shaders and compiles before encoding the first frame that needs them. It supports reuse, resource rebuilds, failure rollback, and retries. A future feature requesting smoothing can still incur a one-time compilation delay. +The tested renderer is integrated through `vendor/gpui-wgpu`, with a Cargo +git-source patch affecting only `gpui_ce_wgpu`. Other GPUI crates retain the +baseline revision. This avoids depending on an unpublished local commit. +See `vendor/gpui-wgpu/UPSTREAM.md` for its provenance and removal procedure. + ## Controlled measurements Five alternating baseline/candidate pairs used the same `release-with-debug` diff --git a/vendor/README.md b/vendor/README.md index 1ac29d1d3..c310a5df2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,24 @@ text-storage patch previously vendored in `gpui/`, so that crate and the Cargo patch are no longer needed. Platform, renderer and test text-backend crates use the same upstream revision. +## GPUI WGPU renderer + +`gpui-wgpu/` contains the Apache-2.0-licensed `gpui_ce_wgpu` crate from GPUI +revision `0665fb07d59cf7af185e58f44b9f890590abe204`, with lazy creation of the +four smoothing pipelines from local GPUI commit +`9362a02d98324c6d91a1bec58b90ab22ab667252`. Normal frames no longer compile +those shaders at renderer initialization. A frame that uses smoothing creates +the pipelines before drawing, with the same shaders and error recovery. + +Only this renderer crate is patched. All other GPUI crates retain the existing +git revision, and Windows continues to use DX12. Its manifest expands upstream +workspace dependencies so the vendor directory is portable; the source, +examples, tests, and license match the tested GPUI commit. The crate is excluded +from this workspace's formatting and lint configuration. + +See [`gpui-wgpu/UPSTREAM.md`](gpui-wgpu/UPSTREAM.md) for provenance, validation, +and the procedure for updating or removing the patch. + ## Difftastic `difftastic/` is Difftastic 0.71.0 (MIT) as a library, for diff --git a/vendor/gpui-wgpu/Cargo.toml b/vendor/gpui-wgpu/Cargo.toml new file mode 100644 index 000000000..c93ea6fbf --- /dev/null +++ b/vendor/gpui-wgpu/Cargo.toml @@ -0,0 +1,98 @@ +# Vendored from Havunen/gpui-ce 0665fb07d59cf7af185e58f44b9f890590abe204 +# with lazy smoothing pipeline creation from 9362a02d98324c6d91a1bec58b90ab22ab667252. +# Workspace inheritance is expanded here; GPUI siblings retain the pinned base. +# See UPSTREAM.md for provenance, validation, and the upgrade procedure. +[package] +name = "gpui_ce_wgpu" +version = "0.1.0" +description = "A community fork of Zed's GPU-accelerated UI framework" +edition = "2024" +publish = true +license = "Apache-2.0" + +[lints.rust] +unexpected_cfgs = { level = "allow" } + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +declare_interior_mutable_const = "deny" +redundant_clone = "deny" +disallowed_methods = "deny" +style = { level = "allow", priority = -1 } +type_complexity = "allow" +let_underscore_future = "allow" +single_range_in_vec_init = "allow" +too_many_arguments = "allow" +large_enum_variant = "allow" +nonminimal_bool = "allow" + +[lib] +path = "src/gpui_wgpu.rs" + +[features] +default = [] +test-support = ["gpui/test-support", "dep:image"] +custom-gpu = ["gpui/custom-gpu"] +wgpu-surfaces = ["gpui/wgpu-surfaces"] + +[dependencies] +gpui = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.2", package = "gpui-ce", default-features = false } +gpui_render = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.1.0", package = "gpui_ce_render" } +anyhow = "1" +collections = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.2", package = "gpui_ce_collections" } +etagere = "0.3" +image = { version = "0.25", default-features = false, features = ["gif", "webp"], optional = true } +log = { version = "0.4", features = ["kv_unstable_serde", "serde"] } +parking_lot = "0.12" +profiling = "1" +raw-window-handle = "0.6" +smallvec = { version = "1", features = ["union", "const_new"] } +gpui_util = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.6", package = "gpui_ce_util" } +wgpu = "=29.0.4" +wgsl-rs = { git = "https://github.com/schell/wgsl-rs", rev = "b2de16a1b81e67d76bc265c296184f882864badb", default-features = false, features = ["validation"] } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +pollster = "1" + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +core-video = { version = "0.6", features = ["metal"] } +metal = "0.33" +objc2 = "0.6" +objc2-metal = "0.3" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-core = "0.100" + +[target.'cfg(target_family = "wasm")'.dependencies] +web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } +wgpu = { version = "=29.0.4", features = ["webgl"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" + +[dev-dependencies] +bytemuck = { version = "1", features = ["derive"] } +criterion = { version = "0.8", features = ["html_reports"] } +env_logger = "0.11" +gpui_platform = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.1.0", package = "gpui_ce_platform", features = ["wayland", "x11", "test-support"] } +naga = { version = "=29.0.4", features = ["wgsl-in"] } + +[target.'cfg(target_os = "linux")'.dev-dependencies] +wayland-backend = { version = "0.3.17", features = [ + "client_system", + "dlopen", + "rwh_06", +] } +wayland-client = "0.31.15" +wayland-protocols = { version = "0.32.13", features = ["client"] } + +[[example]] +name = "custom_gpu" +required-features = ["custom-gpu"] + +[[bench]] +name = "renderer" +harness = false +required-features = ["test-support"] diff --git a/vendor/gpui-wgpu/LICENSE-APACHE b/vendor/gpui-wgpu/LICENSE-APACHE new file mode 100644 index 000000000..461a0fe5b --- /dev/null +++ b/vendor/gpui-wgpu/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-wgpu/UPSTREAM.md b/vendor/gpui-wgpu/UPSTREAM.md new file mode 100644 index 000000000..8846e8397 --- /dev/null +++ b/vendor/gpui-wgpu/UPSTREAM.md @@ -0,0 +1,57 @@ +# GPUI WGPU renderer + +This directory vendors the `gpui_ce_wgpu` crate from +[`Havunen/gpui-ce`](https://github.com/Havunen/gpui-ce), licensed under Apache 2.0 +(see `LICENSE-APACHE`). + +The base is the same revision used by GitComet's other GPUI crates: +`0665fb07d59cf7af185e58f44b9f890590abe204`. The renderer source includes local +GPUI commit `9362a02d98324c6d91a1bec58b90ab22ab667252`, +`perf(wgpu): create smoothing pipelines on first use`. That commit is not +required to be available from a remote: all of the renderer source is included +here. + +## Changes + +The four smoothing pipelines are created only when a frame needs them. They are +prepared before that frame is encoded, preserving the existing shaders and +rendering quality. Failed creation removes only newly created pipelines and +uses the existing DX12 frame retry policy. Device/renderer reconstruction +starts with no smoothing pipelines. + +The change affects these files relative to the base revision: + +- `src/wgpu_renderer.rs` +- `src/wgpu_renderer/drawing.rs` +- `src/wgpu_renderer/filters.rs` +- `src/wgpu_renderer/frame.rs` +- `src/wgpu_renderer/lazy_smoothing_tests.rs` +- `src/wgpu_renderer/pipelines.rs` + +All other upstream source files, tests, examples, and the license are retained +unchanged. `Cargo.toml` expands the upstream workspace dependencies and lints, +and resolves GPUI sibling crates from the pinned base revision. GitComet's root +manifest patches only `gpui_ce_wgpu`; it does not change the selected renderer +or the other GPUI crates. + +## Validation + +The exact renderer source was built in GitComet's `release-with-debug` profile +and tested on Windows with the AMD Radeon 890M using DX12. Four focused tests +passed: first-use rendering matched prepared-pipeline pixels, backdrop and +isolated-filter planning worked, failed creation rolled back correctly, and +the existing DX12 frame retry behavior was preserved. History navigation and +settings were also exercised in the application. + +## Updating + +When changing GitComet's GPUI revision, compare this crate with +`crates/gpui_wgpu` at the new revision. If upstream includes the same behavior, +remove this directory, its workspace exclusion, and the root git-source patch. +Otherwise, apply the smoothing changes to the new crate, expand its workspace +manifest values again, and update every sibling dependency revision here to +match the root GPUI revision. + +Keep the upstream tests and run the smoothing tests on DX12 after an update. +Do not import temporary measurement instrumentation or absolute paths from an +experiment checkout. diff --git a/vendor/gpui-wgpu/benches/renderer.rs b/vendor/gpui-wgpu/benches/renderer.rs new file mode 100644 index 000000000..b6db76fc9 --- /dev/null +++ b/vendor/gpui-wgpu/benches/renderer.rs @@ -0,0 +1,209 @@ +use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; +use gpui::{ + Bounds, ContentMask, Corners, DevicePixels, PlatformHeadlessRenderer, Point, Quad, + ScaledPixels, Scene, ShaderBool, Size, Underline, solid_background, white, +}; +use gpui_ce_wgpu::WgpuHeadlessRenderer; +use std::hint::black_box; + +const TARGET_SIZE: Size = Size { + width: DevicePixels(1280), + height: DevicePixels(720), +}; + +fn quad_scene(count: usize) -> Scene { + let mut scene = unplanned_quad_scene(count); + scene.finish(); + scene +} + +fn smoothed_quad_scene(count: usize, corner_radii: Corners) -> Scene { + let mut scene = unplanned_quad_scene(count); + for quad in &mut scene.quads { + quad.corner_radii = corner_radii; + quad.corner_smoothing = 1.0; + } + scene.finish(); + scene +} + +fn alternating_corner_modes_scene(count: usize) -> Scene { + let mut scene = unplanned_quad_scene(count); + for (index, quad) in scene.quads.iter_mut().enumerate() { + quad.corner_radii = Corners::all(ScaledPixels(8.0)); + quad.corner_smoothing = if index % 2 == 0 { 0.0 } else { 1.0 }; + } + scene.finish(); + scene +} + +fn unplanned_quad_scene(count: usize) -> Scene { + let mut scene = Scene::default(); + for index in 0..count { + let column = index % 32; + let row = index / 32; + let bounds = Bounds { + origin: Point { + x: ScaledPixels((column * 40) as f32), + y: ScaledPixels((row * 40) as f32), + }, + size: Size { + width: ScaledPixels(36.0), + height: ScaledPixels(36.0), + }, + }; + scene.insert_primitive(Quad { + order: index as u32, + bounds, + content_mask: ContentMask { + bounds, + ..Default::default() + }, + background: solid_background(gpui::rgb(0x336699)), + ..Default::default() + }); + } + scene +} + +fn unplanned_mixed_scene(count: usize) -> Scene { + let mut scene = Scene::default(); + let bounds = Bounds { + origin: Point { + x: ScaledPixels(0.0), + y: ScaledPixels(0.0), + }, + size: Size { + width: ScaledPixels(1.0), + height: ScaledPixels(1.0), + }, + }; + for index in 0..count { + scene.insert_primitive(Quad { + order: (index * 2) as u32, + bounds, + content_mask: ContentMask { + bounds, + ..Default::default() + }, + background: solid_background(gpui::rgb(0x336699)), + ..Default::default() + }); + scene.insert_primitive(Underline { + order: (index * 2 + 1) as u32, + padding: 0, + bounds, + content_mask: ContentMask { + bounds, + ..Default::default() + }, + color: white().into(), + thickness: ScaledPixels(1.0), + wavy: ShaderBool::Disabled, + }); + } + scene +} + +fn bench_renderer(criterion: &mut Criterion) { + let mut renderer = WgpuHeadlessRenderer::new().expect("headless WGPU renderer must initialize"); + let single_quad = quad_scene(1); + let dense_quads = quad_scene(512); + let compact_smoothed_quads = smoothed_quad_scene(512, Corners::all(ScaledPixels(8.0))); + let reach_aware_smoothed_quads = smoothed_quad_scene( + 512, + Corners { + top_left: ScaledPixels(14.0), + top_right: ScaledPixels(0.0), + bottom_right: ScaledPixels(0.0), + bottom_left: ScaledPixels(0.0), + }, + ); + let alternating_corner_modes = alternating_corner_modes_scene(512); + let mut mixed_batches = unplanned_mixed_scene(512); + mixed_batches.finish(); + + let mut group = criterion.benchmark_group("renderer_end_to_end"); + group.bench_function("single_quad", |bencher| { + bencher.iter(|| { + renderer + .render_scene_to_image(&single_quad, TARGET_SIZE) + .expect("single quad render must succeed") + }) + }); + group.bench_function("512_quads", |bencher| { + bencher.iter(|| { + renderer + .render_scene_to_image(&dense_quads, TARGET_SIZE) + .expect("dense quad render must succeed") + }) + }); + group.bench_function("single_quad_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&single_quad, TARGET_SIZE) + .expect("single quad render must succeed") + }) + }); + group.bench_function("512_quads_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&dense_quads, TARGET_SIZE) + .expect("dense quad render must succeed") + }) + }); + group.bench_function("512_compact_smoothed_quads_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&compact_smoothed_quads, TARGET_SIZE) + .expect("compact smoothed quad render must succeed") + }) + }); + group.bench_function("512_reach_aware_smoothed_quads_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&reach_aware_smoothed_quads, TARGET_SIZE) + .expect("reach-aware smoothed quad render must succeed") + }) + }); + group.bench_function("512_alternating_corner_modes_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&alternating_corner_modes, TARGET_SIZE) + .expect("alternating corner modes must render successfully") + }) + }); + group.bench_function("1024_mixed_batches_wait", |bencher| { + bencher.iter(|| { + renderer + .render_scene_and_wait(&mixed_batches, TARGET_SIZE) + .expect("mixed batch render must succeed") + }) + }); + group.finish(); + + let mut group = criterion.benchmark_group("scene_plan"); + group.bench_function("compile_1024_mixed_batches", |bencher| { + bencher.iter_batched( + || unplanned_mixed_scene(512), + |mut scene| { + scene.finish(); + black_box(*scene.render_plan().requirements()) + }, + BatchSize::SmallInput, + ) + }); + let mut mixed_scene = unplanned_mixed_scene(512); + mixed_scene.finish(); + group.bench_function("traverse_1024_mixed_batches", |bencher| { + bencher.iter(|| { + for command in mixed_scene.render_commands() { + black_box(command); + } + }) + }); + group.finish(); +} + +criterion_group!(benches, bench_renderer); +criterion_main!(benches); diff --git a/vendor/gpui-wgpu/examples/custom_gpu.rs b/vendor/gpui-wgpu/examples/custom_gpu.rs new file mode 100644 index 000000000..632bdbdba --- /dev/null +++ b/vendor/gpui-wgpu/examples/custom_gpu.rs @@ -0,0 +1,369 @@ +//! A minimal custom GPU control. +//! +//! The control owns its buffers, pipeline, and offscreen target. GPUI owns the +//! device and queue, so those resources are recreated after device loss. +//! +//! Run with `--features custom-gpu` on supported targets. macOS windows also +//! need the WGPU renderer: +//! +//! ```sh +//! cargo run -p gpui_ce_wgpu --example custom_gpu --features custom-gpu,gpui_platform/macos-wgpu +//! ``` + +#[cfg(any( + target_family = "wasm", + target_os = "linux", + target_os = "freebsd", + target_os = "macos" +))] +mod custom_gpu { + use std::borrow::Cow; + + use gpui::{ + App, AppContext, Bounds, Context, ParentElement, Render, Styled, TitlebarOptions, Window, + WindowBounds, WindowOptions, div, px, size, + }; + use gpui_ce_wgpu::{WgpuContextHandle, WgpuRenderTarget}; + use gpui_platform::application; + use wgpu::util::DeviceExt; + + const SHADER: &str = r#" +struct Uniforms { + time: f32, + aspect: f32, + pad: vec2, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexInput { + @location(0) position: vec2, + @location(1) color: vec3, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec3, +}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + let angle = uniforms.time; + let rotation = mat2x2( + cos(angle), -sin(angle), + sin(angle), cos(angle), + ); + let position = rotation * vec2(input.position.x / uniforms.aspect, input.position.y); + + var output: VertexOutput; + output.position = vec4(position, 0.0, 1.0); + output.color = input.color; + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + return vec4(input.color, 1.0); +} +"#; + + #[repr(C)] + #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] + struct Uniforms { + time: f32, + aspect: f32, + _pad: [f32; 2], + } + + #[repr(C)] + #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] + struct Vertex { + position: [f32; 2], + color: [f32; 3], + } + + const VERTICES: &[Vertex] = &[ + Vertex { + position: [0.0, 0.75], + color: [1.0, 0.2, 0.2], + }, + Vertex { + position: [-0.75, -0.5], + color: [0.2, 1.0, 0.2], + }, + Vertex { + position: [0.75, -0.5], + color: [0.2, 0.4, 1.0], + }, + ]; + + struct CustomGpuControl { + context: Option, + target: Option, + pipeline: Option, + bind_group: Option, + uniform_buffer: Option, + vertex_buffer: Option, + time: f32, + } + + impl CustomGpuControl { + fn new(_window: &mut Window, _cx: &mut Context) -> Self { + Self { + context: None, + target: None, + pipeline: None, + bind_group: None, + uniform_buffer: None, + vertex_buffer: None, + time: 0.0, + } + } + + fn clear_gpu_resources(&mut self) { + self.context = None; + self.target = None; + self.pipeline = None; + self.bind_group = None; + self.uniform_buffer = None; + self.vertex_buffer = None; + } + + fn ensure_gpu_resources(&mut self, window: &Window, context: &WgpuContextHandle) { + let viewport = window.viewport_size(); + let scale_factor = window.scale_factor(); + let target_size = size( + gpui::DevicePixels((f32::from(viewport.width) * scale_factor).round() as i32), + gpui::DevicePixels((f32::from(viewport.height) * scale_factor).round() as i32), + ); + let target_size = size( + target_size.width.max(gpui::DevicePixels(1)), + target_size.height.max(gpui::DevicePixels(1)), + ); + + if self + .target + .as_ref() + .is_none_or(|target| target.size() != target_size) + { + self.target = Some(WgpuRenderTarget::new(context, target_size)); + } + + if self.pipeline.is_some() { + return; + } + + let device = context.device(); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("custom-gpu-shader"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SHADER)), + }); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("custom-gpu-uniforms"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("custom-gpu-bind-group-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("custom-gpu-bind-group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("custom-gpu-pipeline-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("custom-gpu-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x3, + offset: std::mem::size_of::<[f32; 2]>() as u64, + shader_location: 1, + }, + ], + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: context.texture_format(), + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("custom-gpu-vertices"), + contents: bytemuck::cast_slice(VERTICES), + usage: wgpu::BufferUsages::VERTEX, + }); + + self.pipeline = Some(pipeline); + self.bind_group = Some(bind_group); + self.uniform_buffer = Some(uniform_buffer); + self.vertex_buffer = Some(vertex_buffer); + } + + fn render_gpu(&mut self, window: &mut Window) { + let Some(context) = WgpuContextHandle::from_window(window) else { + self.clear_gpu_resources(); + return; + }; + if self + .context + .as_ref() + .is_some_and(|previous| !context.is_same_device(previous)) + { + self.clear_gpu_resources(); + } + if context.device_lost() { + self.clear_gpu_resources(); + return; + } + self.context = Some(context.clone()); + + self.ensure_gpu_resources(window, &context); + self.time += 0.02; + let target = self.target.as_ref().unwrap(); + let uniform_buffer = self.uniform_buffer.as_ref().unwrap(); + let pipeline = self.pipeline.as_ref().unwrap(); + let bind_group = self.bind_group.as_ref().unwrap(); + let vertex_buffer = self.vertex_buffer.as_ref().unwrap(); + let aspect = target.size().width.0 as f32 / target.size().height.0 as f32; + context.queue().write_buffer( + uniform_buffer, + 0, + bytemuck::bytes_of(&Uniforms { + time: self.time, + aspect, + _pad: [0.0; 2], + }), + ); + + let mut encoder = + context + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("custom-gpu-encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("custom-gpu-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target.view(), + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.set_vertex_buffer(0, vertex_buffer.slice(..)); + pass.draw(0..VERTICES.len() as u32, 0..1); + } + context.queue().submit([encoder.finish()]); + } + } + + impl Render for CustomGpuControl { + fn render( + &mut self, + window: &mut Window, + _cx: &mut Context, + ) -> impl gpui::IntoElement { + window.request_animation_frame(); + self.render_gpu(window); + let mut root = div().size_full(); + if let Some(target) = self.target.as_ref() { + root = root.child(target.surface()); + } + root + } + } + + pub fn run() { + env_logger::init(); + application().run(|cx: &mut App| { + cx.open_window( + WindowOptions::new() + .titlebar(Some(TitlebarOptions { + title: Some("Custom GPU rendering".into()), + ..Default::default() + })) + .window_bounds(Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(800.0), px(600.0)), + cx, + )))), + |window, cx| cx.new(|cx| CustomGpuControl::new(window, cx)), + ) + .unwrap(); + cx.activate(true); + }); + } +} + +#[cfg(any( + target_family = "wasm", + target_os = "linux", + target_os = "freebsd", + target_os = "macos" +))] +fn main() { + custom_gpu::run(); +} + +#[cfg(not(any( + target_family = "wasm", + target_os = "linux", + target_os = "freebsd", + target_os = "macos" +)))] +fn main() { + eprintln!("custom_gpu is supported on Linux, FreeBSD, macOS, and WASM targets only"); +} diff --git a/vendor/gpui-wgpu/src/gpui_wgpu.rs b/vendor/gpui-wgpu/src/gpui_wgpu.rs new file mode 100644 index 000000000..cd8bd6a12 --- /dev/null +++ b/vendor/gpui-wgpu/src/gpui_wgpu.rs @@ -0,0 +1,17 @@ +mod wgpu_atlas; +mod wgpu_context; +mod wgpu_renderer; + +#[cfg(all(test, not(target_family = "wasm")))] +mod test_gpu; + +pub use wgpu; +pub use wgpu_atlas::*; +pub use wgpu_context::*; +#[cfg(all(feature = "test-support", not(target_family = "wasm")))] +pub use wgpu_renderer::WgpuHeadlessRenderer; +pub use wgpu_renderer::{ + FontRasterizationSettings, GpuContext, SubpixelOrder, WgpuRenderer, WgpuSurfaceConfig, +}; + +pub use wgpu_renderer::RecoveryPending; diff --git a/vendor/gpui-wgpu/src/test_gpu.rs b/vendor/gpui-wgpu/src/test_gpu.rs new file mode 100644 index 000000000..7d17b0dda --- /dev/null +++ b/vendor/gpui-wgpu/src/test_gpu.rs @@ -0,0 +1,15 @@ +/// Keep independent DX12 test devices from overlapping creation and teardown +/// on Windows. CPU tests still run in parallel, and runtime contexts are unchanged. +pub(crate) fn guard() -> Option> { + #[cfg(target_os = "windows")] + { + static GPU_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + Some( + GPU_TESTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + } + #[cfg(not(target_os = "windows"))] + None +} diff --git a/vendor/gpui-wgpu/src/wgpu_atlas.rs b/vendor/gpui-wgpu/src/wgpu_atlas.rs new file mode 100644 index 000000000..8b8050002 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_atlas.rs @@ -0,0 +1,622 @@ +use anyhow::{Context as _, Result}; +use collections::FxHashMap; +use etagere::{BucketedAtlasAllocator, size2}; +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, + GlyphAtlasCache, GlyphAtlasEntry, PlatformAtlas, Point, RenderGlyphParams, Size, + ValidatedRasterizedGlyph, +}; +use parking_lot::Mutex; +use std::{borrow::Cow, ops, sync::Arc}; + +use crate::WgpuContext; + +fn device_size_to_etagere(size: Size) -> etagere::Size { + size2(size.width.0, size.height.0) +} + +fn etagere_point_to_device(point: etagere::Point) -> Point { + Point { + x: DevicePixels(point.x), + y: DevicePixels(point.y), + } +} + +pub struct WgpuAtlas(Mutex); + +/// Identity of a backing atlas texture, distinct from its reusable slot ID. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct WgpuTextureIdentity(u64); + +struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, + data: Vec, +} + +struct WgpuAtlasState { + device: Arc, + queue: Arc, + max_texture_size: u32, + color_texture_format: wgpu::TextureFormat, + storage: WgpuAtlasStorage, + tiles_by_key: FxHashMap, + glyph_cache: GlyphAtlasCache, + pending_uploads: Vec, + next_texture_identity: u64, + generation: u64, +} + +pub struct WgpuTextureInfo { + pub view: wgpu::TextureView, + /// Distinguishes backing textures even when their AtlasTextureId slot is reused. + pub identity: WgpuTextureIdentity, +} + +impl WgpuAtlas { + pub fn new( + device: Arc, + queue: Arc, + color_texture_format: wgpu::TextureFormat, + ) -> Self { + let max_texture_size = device.limits().max_texture_dimension_2d; + WgpuAtlas(Mutex::new(WgpuAtlasState { + device, + queue, + max_texture_size, + color_texture_format, + storage: WgpuAtlasStorage::default(), + tiles_by_key: Default::default(), + glyph_cache: Default::default(), + pending_uploads: Vec::new(), + next_texture_identity: 0, + generation: 0, + })) + } + + pub fn from_context(context: &WgpuContext) -> Self { + Self::new( + context.device.clone(), + context.queue.clone(), + context.color_texture_format(), + ) + } + + pub fn before_frame(&self) { + let mut lock = self.0.lock(); + lock.flush_uploads(); + } + + pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { + let lock = self.0.lock(); + let texture = &lock.storage[id]; + WgpuTextureInfo { + view: texture.view.clone(), + identity: texture.identity, + } + } + + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { + let mut lock = self.0.lock(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.glyph_cache.clear(); + lock.pending_uploads.clear(); + lock.generation = lock.generation.wrapping_add(1); + } + + /// Handles device lost by clearing all textures and cached tiles. + /// The atlas will lazily recreate textures as needed on subsequent frames. + pub fn handle_device_lost(&self, context: &WgpuContext) { + let mut lock = self.0.lock(); + lock.device = context.device.clone(); + lock.queue = context.queue.clone(); + lock.color_texture_format = context.color_texture_format(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.glyph_cache.clear(); + lock.pending_uploads.clear(); + lock.generation = lock.generation.wrapping_add(1); + } +} + +impl PlatformAtlas for WgpuAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + let mut lock = self.0.lock(); + if let Some(tile) = lock.tiles_by_key.get(key) { + return Ok(Some(*tile)); + } + + profiling::scope!("new tile"); + let Some((size, bytes)) = build()? else { + return Ok(None); + }; + key.texture_kind().validate_upload(size, &bytes)?; + let tile = lock.insert_tile(key.clone(), size, &bytes)?; + + Ok(Some(tile)) + } + + fn get_or_insert_glyph_with( + &self, + params: &RenderGlyphParams, + build: &mut dyn FnMut() -> Result, + ) -> Result { + let mut lock = self.0.lock(); + if let Some(entry) = lock.glyph_cache.get(params) { + return Ok(entry); + } + + let glyph = build()?; + let tile = if glyph.size == Size::default() { + None + } else { + let key = AtlasKey::from((params.clone(), glyph.format)); + Some(lock.insert_tile(key, glyph.size, &glyph.pixels)?) + }; + Ok(lock.glyph_cache.insert(params, &glyph, tile)) + } + + fn remove(&self, key: &AtlasKey) { + let mut lock = self.0.lock(); + + lock.glyph_cache.remove(key); + + let Some(tile) = lock.tiles_by_key.remove(key) else { + return; + }; + let id = tile.texture_id; + + let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { + return; + }; + + if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); + texture.decrement_ref_count(); + if texture.is_unreferenced() { + lock.pending_uploads + .retain(|upload| upload.id != texture.id); + lock.storage[id.kind] + .free_list + .push(texture.id.index as usize); + } else { + *texture_slot = Some(texture); + } + lock.generation = lock.generation.wrapping_add(1); + } + } + + fn generation(&self) -> u64 { + self.0.lock().generation + } +} + +impl WgpuAtlasState { + fn insert_tile( + &mut self, + key: AtlasKey, + size: Size, + bytes: &[u8], + ) -> Result { + anyhow::ensure!( + size.width.0 as u32 <= self.max_texture_size + && size.height.0 as u32 <= self.max_texture_size, + "atlas tile {size:?} exceeds the device texture limit {}", + self.max_texture_size + ); + let tile = self + .allocate(size, key.texture_kind()) + .context("failed to allocate")?; + self.upload_texture(tile.texture_id, tile.bounds, bytes); + self.tiles_by_key.insert(key, tile); + + Ok(tile) + } + + fn allocate( + &mut self, + size: Size, + texture_kind: AtlasTextureKind, + ) -> Option { + { + let textures = &mut self.storage[texture_kind]; + + if let Some(tile) = textures + .iter_mut() + .rev() + .find_map(|texture| texture.allocate(size)) + { + return Some(tile); + } + } + + let texture = self.push_texture(size, texture_kind); + texture.allocate(size) + } + + fn push_texture( + &mut self, + min_size: Size, + kind: AtlasTextureKind, + ) -> &mut WgpuAtlasTexture { + let identity = WgpuTextureIdentity(self.next_texture_identity); + self.next_texture_identity = self.next_texture_identity.wrapping_add(1); + const DEFAULT_ATLAS_SIZE: Size = Size { + width: DevicePixels(1024), + height: DevicePixels(1024), + }; + let max_texture_size = self.max_texture_size as i32; + let max_atlas_size = Size { + width: DevicePixels(max_texture_size), + height: DevicePixels(max_texture_size), + }; + + let size = min_size.max(&DEFAULT_ATLAS_SIZE).min(&max_atlas_size); + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("atlas"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let texture_list = &mut self.storage[kind]; + let index = texture_list.free_list.pop(); + + let atlas_texture = WgpuAtlasTexture { + id: AtlasTextureId { + index: index.unwrap_or(texture_list.textures.len()) as u32, + kind, + }, + allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), + format, + texture, + view, + identity, + live_atlas_keys: 0, + }; + + if let Some(ix) = index { + texture_list.textures[ix] = Some(atlas_texture); + texture_list + .textures + .get_mut(ix) + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } else { + texture_list.textures.push(Some(atlas_texture)); + texture_list + .textures + .last_mut() + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } + } + + fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { + let data = self + .storage + .get(id) + .map(|texture| swizzle_upload_data(bytes, texture.format)) + .unwrap_or_else(|| bytes.to_vec()); + + self.pending_uploads + .push(PendingUpload { id, bounds, data }); + } + + fn flush_uploads(&mut self) { + for upload in self.pending_uploads.drain(..) { + let Some(texture) = self.storage.get(upload.id) else { + continue; + }; + let bytes_per_pixel = texture.bytes_per_pixel(); + + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: upload.bounds.origin.x.0 as u32, + y: upload.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + &upload.data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(upload.bounds.size.width.0 as u32 * bytes_per_pixel as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: upload.bounds.size.width.0 as u32, + height: upload.bounds.size.height.0 as u32, + depth_or_array_layers: 1, + }, + ); + } + } +} + +#[derive(Default)] +struct WgpuAtlasStorage { + monochrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, +} + +impl ops::Index for WgpuAtlasStorage { + type Output = AtlasTextureList; + fn index(&self, kind: AtlasTextureKind) -> &Self::Output { + match kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + } + } +} + +impl ops::IndexMut for WgpuAtlasStorage { + fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { + match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + } + } +} + +impl WgpuAtlasStorage { + fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> { + self[id.kind] + .textures + .get(id.index as usize) + .and_then(|t| t.as_ref()) + } +} + +impl ops::Index for WgpuAtlasStorage { + type Output = WgpuAtlasTexture; + + fn index(&self, id: AtlasTextureId) -> &Self::Output { + self.get(id).expect("texture must exist") + } +} + +struct WgpuAtlasTexture { + id: AtlasTextureId, + allocator: BucketedAtlasAllocator, + texture: wgpu::Texture, + view: wgpu::TextureView, + identity: WgpuTextureIdentity, + format: wgpu::TextureFormat, + live_atlas_keys: u32, +} + +impl WgpuAtlasTexture { + fn allocate(&mut self, size: Size) -> Option { + let allocation = self.allocator.allocate(device_size_to_etagere(size))?; + let tile = AtlasTile { + texture_id: self.id, + tile_id: allocation.id.into(), + padding: 0, + bounds: Bounds { + origin: etagere_point_to_device(allocation.rectangle.min), + size, + }, + }; + self.live_atlas_keys += 1; + Some(tile) + } + + fn bytes_per_pixel(&self) -> u8 { + match self.format { + wgpu::TextureFormat::R8Unorm => 1, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm => 4, + _ => 4, + } + } + + fn decrement_ref_count(&mut self) { + self.live_atlas_keys -= 1; + } + + fn is_unreferenced(&self) -> bool { + self.live_atlas_keys == 0 + } +} + +fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { + match format { + wgpu::TextureFormat::Rgba8Unorm => { + let mut data = bytes.to_vec(); + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + data + } + _ => bytes.to_vec(), + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use gpui::block_on; + use gpui::{ImageId, RenderImageParams}; + use std::sync::Arc; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; + Ok((Arc::new(device), Arc::new(queue))) + }) + } + + #[test] + fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(1), + frame_index: 0, + }); + let size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))); + + // Regression test: before the fix, this panicked in flush_uploads + atlas + .get_or_insert_with(&key, &mut build)? + .expect("tile should be created"); + atlas.remove(&key); + atlas.before_frame(); + Ok(()) + } + + #[test] + fn remove_deallocates_tile_space_for_reuse() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + let big = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + + let make_key = |image_id: usize| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: &AtlasKey, size: Size| { + let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; + atlas + .get_or_insert_with(key, &mut || { + Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let keeper_key = make_key(1); + let big_key_a = make_key(2); + let big_key_b = make_key(3); + + let keeper_tile = insert(&keeper_key, small); + let tile_a = insert(&big_key_a, big); + assert_eq!(keeper_tile.texture_id, tile_a.texture_id); + + atlas.remove(&big_key_a); + let tile_b = insert(&big_key_b, big); + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + Ok(()) + } + + #[test] + fn reusing_an_atlas_texture_slot_changes_its_identity() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + let key = |image_id| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: &AtlasKey| { + atlas + .get_or_insert_with(key, &mut || { + Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let first = insert(&key(1)); + let identity = atlas.get_texture_info(first.texture_id).identity; + atlas.remove(&key(1)); + let second = insert(&key(2)); + + assert_eq!(first.texture_id, second.texture_id); + assert_ne!(atlas.get_texture_info(second.texture_id).identity, identity); + Ok(()) + } + + #[test] + fn swizzle_upload_data_preserves_bgra_uploads() { + let input = vec![0x10, 0x20, 0x30, 0x40]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Bgra8Unorm), + input + ); + } + + #[test] + fn swizzle_upload_data_converts_bgra_to_rgba() { + let input = vec![0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Rgba8Unorm), + vec![0x30, 0x20, 0x10, 0x40, 0xCC, 0xBB, 0xAA, 0xDD] + ); + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_context.rs b/vendor/gpui-wgpu/src/wgpu_context.rs new file mode 100644 index 000000000..110a7633f --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_context.rs @@ -0,0 +1,1123 @@ +#[cfg(not(target_family = "wasm"))] +use anyhow::Context as _; +#[cfg(not(target_family = "wasm"))] +use gpui_util::ResultExt; +#[cfg(not(target_family = "wasm"))] +use smallvec::SmallVec; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use wgpu::TextureFormat; + +/// Environment variable that forces the HWND swapchain path on Windows. +pub const DISABLE_DIRECT_COMPOSITION_ENV: &str = "GPUI_DISABLE_DIRECT_COMPOSITION"; + +/// A single native graphics API that can back a [`WgpuContext`]. +/// +/// Keeping this as an enum, rather than passing a [`wgpu::Backends`] bit-set through the native +/// initialization path, makes it impossible for the renderer to accidentally initialize a +/// fallback API alongside the preferred one. In particular, constructing WGPU's GL backend +/// creates and retains EGL state even when a Vulkan adapter is eventually selected. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[cfg(not(target_family = "wasm"))] +pub(crate) enum NativeBackend { + #[cfg(target_vendor = "apple")] + Metal, + #[cfg(target_os = "windows")] + Dx12, + #[cfg(not(target_vendor = "apple"))] + Vulkan, + #[cfg(not(target_vendor = "apple"))] + Gl, +} + +/// A WGPU instance paired with the single native API it was created to load. +/// +/// Keeping the backend token attached prevents later initialization stages from +/// accidentally widening adapter discovery back to multiple APIs. +#[cfg(not(target_family = "wasm"))] +pub(crate) struct NativeInstance { + pub(crate) backend: NativeBackend, + pub(crate) raw: wgpu::Instance, +} + +#[cfg(not(target_family = "wasm"))] +impl NativeBackend { + #[cfg(target_vendor = "apple")] + const PREFERENCE: &'static [Self] = &[Self::Metal]; + #[cfg(target_os = "windows")] + const PREFERENCE: &'static [Self] = &[Self::Dx12, Self::Vulkan, Self::Gl]; + #[cfg(not(any(target_vendor = "apple", target_os = "windows")))] + const PREFERENCE: &'static [Self] = &[Self::Vulkan, Self::Gl]; + + pub(crate) fn instance( + self, + display: Option>, + ) -> NativeInstance { + #[cfg(not(target_os = "windows"))] + let backend_options = wgpu::BackendOptions::default(); + #[cfg(target_os = "windows")] + let backend_options = match self { + Self::Dx12 => { + let mut options = wgpu::BackendOptions::default(); + let direct_composition_disabled = std::env::var(DISABLE_DIRECT_COMPOSITION_ENV) + .is_ok_and(|value| value == "true" || value == "1"); + options.dx12.presentation_system = if direct_composition_disabled { + wgpu::Dx12SwapchainKind::DxgiFromHwnd + } else { + wgpu::Dx12SwapchainKind::DxgiFromVisual + }; + options + } + Self::Vulkan | Self::Gl => wgpu::BackendOptions::default(), + }; + + NativeInstance { + backend: self, + raw: wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: self.into(), + flags: wgpu::InstanceFlags::default(), + backend_options, + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display, + }), + } + } + + pub(crate) fn try_in_preference_order( + operation: &'static str, + mut attempt: impl FnMut(Self) -> anyhow::Result, + ) -> anyhow::Result { + let mut failures = SmallVec::<[NativeBackendFailure; 3]>::new(); + for &backend in Self::PREFERENCE { + match attempt(backend) { + Ok(value) => return Ok(value), + Err(source) => failures.push(NativeBackendFailure { backend, source }), + } + } + Err(NativeBackendFallbackError { + operation, + failures, + } + .into()) + } +} + +#[cfg(not(target_family = "wasm"))] +impl From for wgpu::Backends { + fn from(backend: NativeBackend) -> Self { + match backend { + #[cfg(target_vendor = "apple")] + NativeBackend::Metal => Self::METAL, + #[cfg(target_os = "windows")] + NativeBackend::Dx12 => Self::DX12, + #[cfg(not(target_vendor = "apple"))] + NativeBackend::Vulkan => Self::VULKAN, + #[cfg(not(target_vendor = "apple"))] + NativeBackend::Gl => Self::GL, + } + } +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug)] +struct NativeBackendFailure { + backend: NativeBackend, + source: anyhow::Error, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug)] +struct NativeBackendFallbackError { + operation: &'static str, + failures: SmallVec<[NativeBackendFailure; 3]>, +} + +#[cfg(not(target_family = "wasm"))] +impl std::fmt::Display for NativeBackendFallbackError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "failed to initialize {}; attempted backends in order: ", + self.operation + )?; + for (index, failure) in self.failures.iter().enumerate() { + if index > 0 { + formatter.write_str("; ")?; + } + write!(formatter, "{:?}: {:#}", failure.backend, failure.source)?; + } + Ok(()) + } +} + +#[cfg(not(target_family = "wasm"))] +impl std::error::Error for NativeBackendFallbackError {} + +#[cfg(not(target_family = "wasm"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SoftwareAdapterPolicy { + Allow, + Reject, +} + +#[cfg(not(target_family = "wasm"))] +impl SoftwareAdapterPolicy { + fn accepts(self, device_type: wgpu::DeviceType) -> bool { + self == Self::Allow || device_type != wgpu::DeviceType::Cpu + } +} + +struct CreatedDevice { + device: wgpu::Device, + queue: wgpu::Queue, + dual_source_blending: bool, + color_texture_format: TextureFormat, + renderer_tier: RendererTier, +} + +#[cfg(not(target_family = "wasm"))] +struct SelectedAdapter { + adapter: wgpu::Adapter, + device: CreatedDevice, +} + +pub struct WgpuContext { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: Arc, + pub queue: Arc, + backend: WgpuBackend, + dual_source_blending: bool, + color_texture_format: wgpu::TextureFormat, + renderer_tier: RendererTier, + device_lost: Arc, + uncaptured_error: Arc>>, + fatal_error: Arc>>, +} + +/// The resource transport selected for a device. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RendererTier { + /// WebGPU-class devices use storage buffers for packed scene data. + Modern, + /// GLES 3.0 and WebGL2 use a build-generated data-texture transport. + WebGl2, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WgpuBackend { + BrowserWebGpu, + Gl, + Native(wgpu::Backend), +} + +#[cfg(target_family = "wasm")] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum WebBackendPreference { + #[default] + Auto, + WebGpu, + WebGl, +} + +#[cfg(target_family = "wasm")] +pub struct PreparedWebGraphics { + pub context: WgpuContext, + pub surface: wgpu::Surface<'static>, +} + +/// wgpu-core refuses to create a surface when neither the instance nor the surface +/// target carries a display handle, and `SurfaceTarget::Canvas` always passes `None`. +/// The WebGL2 backend never reads the handle (WebGPU bypasses wgpu-core entirely), so +/// a unit web display handle on the instance satisfies the check. +#[cfg(target_family = "wasm")] +#[derive(Debug)] +struct WebDisplaySource; + +#[cfg(target_family = "wasm")] +impl raw_window_handle::HasDisplayHandle for WebDisplaySource { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(raw_window_handle::DisplayHandle::web()) + } +} + +#[derive(Clone, Copy)] +pub struct CompositorGpuHint { + pub vendor_id: u32, + pub device_id: u32, +} + +/// A typed view of the GPU resources shared with GPUI. +/// +/// The device and queue are owned by GPUI and remain valid until +/// [`Self::device_lost`] returns `true`. A control should drop its GPU +/// resources and reacquire this handle after recovery. +#[derive(Clone)] +pub struct WgpuContextHandle { + device: Arc, + queue: Arc, + texture_format: wgpu::TextureFormat, + adapter_info: wgpu::AdapterInfo, + backend: WgpuBackend, + features: wgpu::Features, + limits: wgpu::Limits, + device_lost: Arc, +} + +impl WgpuContextHandle { + pub(crate) fn from_resources( + device: Arc, + queue: Arc, + texture_format: wgpu::TextureFormat, + adapter_info: wgpu::AdapterInfo, + backend: WgpuBackend, + device_lost: Arc, + ) -> Self { + let features = device.features(); + let limits = device.limits(); + Self { + device, + queue, + texture_format, + adapter_info, + backend, + features, + limits, + device_lost, + } + } + + /// Returns the shared device. + pub fn device(&self) -> &wgpu::Device { + &self.device + } + + /// Returns the shared queue. + pub fn queue(&self) -> &wgpu::Queue { + &self.queue + } + + /// Returns whether two handles refer to the same device instance. + pub fn is_same_device(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.device, &other.device) + } + + /// Returns the texture format used by GPUI's window surface. + pub fn texture_format(&self) -> wgpu::TextureFormat { + self.texture_format + } + + /// Returns information about the selected adapter. + pub fn adapter_info(&self) -> &wgpu::AdapterInfo { + &self.adapter_info + } + + /// Returns the backend selected for this window. + pub fn backend(&self) -> WgpuBackend { + self.backend + } + + /// Returns the features enabled on the shared device. + pub fn features(&self) -> wgpu::Features { + self.features + } + + /// Returns the limits used when creating the shared device. + pub fn limits(&self) -> &wgpu::Limits { + &self.limits + } + + /// Returns whether the device has been lost and custom resources must be + /// recreated after GPUI recovers. + pub fn device_lost(&self) -> bool { + self.device_lost.load(Ordering::Relaxed) + } + + /// Returns the typed wgpu context associated with a GPUI window. + #[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "windows", + all(target_os = "macos", feature = "custom-gpu"), + all(target_family = "wasm", feature = "custom-gpu") + ))] + pub fn from_window(window: &gpui::Window) -> Option { + window + .gpu_context_info()? + .downcast::() + .ok() + .map(|context| *context) + } +} + +/// A reusable offscreen render target suitable for [`gpui::Window::paint_surface`]. +pub struct WgpuRenderTarget { + texture: Arc, + view: wgpu::TextureView, + size: gpui::Size, + format: wgpu::TextureFormat, +} + +impl WgpuRenderTarget { + /// Creates a render target using GPUI's surface format. + pub fn new(context: &WgpuContextHandle, size: gpui::Size) -> Self { + Self::with_format(context, size, context.texture_format()) + } + + /// Creates a render target with an explicit texture format. + pub fn with_format( + context: &WgpuContextHandle, + size: gpui::Size, + format: wgpu::TextureFormat, + ) -> Self { + let size = normalize_target_size(size); + let texture = create_render_target_texture(context.device(), size, format); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Self { + texture: Arc::new(texture), + view, + size, + format, + } + } + + /// Resizes the target, preserving its texture format. + pub fn resize(&mut self, context: &WgpuContextHandle, size: gpui::Size) { + let size = normalize_target_size(size); + if self.size == size { + return; + } + let texture = create_render_target_texture(context.device(), size, self.format); + self.view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.texture = Arc::new(texture); + self.size = size; + } + + /// Returns the target texture for use as a GPUI surface. + pub fn texture(&self) -> Arc { + Arc::clone(&self.texture) + } + + /// Creates a GPUI element that composites this target at its layout bounds. + #[cfg(any( + target_os = "linux", + target_os = "freebsd", + all(target_os = "macos", feature = "custom-gpu"), + all(target_family = "wasm", feature = "custom-gpu") + ))] + pub fn surface(&self) -> gpui::Surface { + gpui::surface(gpui::SurfaceSource::Texture { + texture: self.texture(), + size: self.size, + }) + } + + /// Returns the target texture view for rendering. + pub fn view(&self) -> &wgpu::TextureView { + &self.view + } + + /// Returns the target dimensions in device pixels. + pub fn size(&self) -> gpui::Size { + self.size + } + + /// Returns the target texture format. + pub fn format(&self) -> wgpu::TextureFormat { + self.format + } +} + +fn normalize_target_size(size: gpui::Size) -> gpui::Size { + gpui::size( + gpui::DevicePixels(size.width.0.max(1)), + gpui::DevicePixels(size.height.0.max(1)), + ) +} + +fn create_render_target_texture( + device: &wgpu::Device, + size: gpui::Size, + format: wgpu::TextureFormat, +) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("gpui-custom-render-target"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) +} + +/// Extra wgpu features and limits that an application can request on top of +/// gpui's baseline. Pass an instance to the platform via +/// [`gpui::App::set_gpu_requirements`] *before* opening any windows. +#[derive(Clone, Debug, Default)] +pub struct WgpuDeviceRequirements { + /// Additional [`wgpu::Features`] to enable. These are OR-ed with gpui's + /// own required features. + pub features: wgpu::Features, + /// Additional [`wgpu::Limits`] to request. Each field is merged by taking + /// `max(gpui_limit, app_limit)` for upper-bound limits and + /// `min(gpui_limit, app_limit)` for alignment/lower-bound limits. + pub limits: Option, +} + +impl WgpuContext { + pub(crate) fn handle(&self, texture_format: wgpu::TextureFormat) -> WgpuContextHandle { + WgpuContextHandle::from_resources( + Arc::clone(&self.device), + Arc::clone(&self.queue), + texture_format, + self.adapter.get_info(), + self.backend, + Arc::clone(&self.device_lost), + ) + } + + /// Creates a native device without a presentation surface. + #[cfg(not(target_family = "wasm"))] + pub fn new_headless( + extra_requirements: Option<&WgpuDeviceRequirements>, + ) -> anyhow::Result { + NativeBackend::try_in_preference_order("a headless GPU context", |backend| { + let instance = backend.instance(None); + let adapter = + gpui::block_on(instance.raw.request_adapter(&wgpu::RequestAdapterOptions { + // LowPower avoids waking a discrete GPU just for snapshots on dual-GPU + // systems. + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + })) + .map_err(|error| { + anyhow::anyhow!("failed to request headless GPU adapter: {error}") + })?; + let device = gpui::block_on(Self::create_device(&adapter, extra_requirements))?; + Self::from_created_device(instance.raw, adapter, device) + }) + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn new_with_adapter_policy( + instance: NativeInstance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + adapter_policy: SoftwareAdapterPolicy, + extra_requirements: Option<&WgpuDeviceRequirements>, + ) -> anyhow::Result { + let device_id_filter = match std::env::var("ZED_DEVICE_ID") { + Ok(val) => parse_pci_id(&val) + .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") + .log_err(), + Err(std::env::VarError::NotPresent) => None, + err => { + err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") + .log_err(); + None + } + }; + + // Select an adapter by actually testing surface configuration with the real device. + // This is the only reliable way to determine compatibility on hybrid GPU systems. + let selection = gpui::block_on(Self::select_adapter_and_device( + &instance.raw, + instance.backend, + device_id_filter, + surface, + compositor_gpu.as_ref(), + adapter_policy, + extra_requirements, + ))?; + + Self::from_created_device(instance.raw, selection.adapter, selection.device) + } + + #[cfg(target_family = "wasm")] + pub async fn new_web( + canvas: &web_sys::HtmlCanvasElement, + preference: WebBackendPreference, + ) -> anyhow::Result { + Self::new_web_with_backend(canvas, preference).await + } + + #[cfg(target_family = "wasm")] + #[allow(clippy::arc_with_non_send_sync)] + async fn new_web_with_backend( + canvas: &web_sys::HtmlCanvasElement, + preference: WebBackendPreference, + ) -> anyhow::Result { + let backends = match preference { + WebBackendPreference::Auto => wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL, + WebBackendPreference::WebGpu => wgpu::Backends::BROWSER_WEBGPU, + WebBackendPreference::WebGl => wgpu::Backends::GL, + }; + let descriptor = wgpu::InstanceDescriptor { + backends, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: Some(Box::new(WebDisplaySource)), + }; + let instance = if preference == WebBackendPreference::Auto { + wgpu::util::new_instance_with_webgpu_detection(descriptor).await + } else { + wgpu::Instance::new(descriptor) + }; + let surface = instance + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|error| { + anyhow::anyhow!("Failed to create browser graphics surface: {error}") + })?; + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|error| { + anyhow::anyhow!( + "Failed to request a {preference:?} adapter compatible with the canvas: {error}" + ) + })?; + let device = Self::create_device(&adapter, None).await?; + let context = Self::from_created_device(instance, adapter, device)?; + log::info!( + "Browser graphics initialized: requested={preference:?}, selected={backend:?}, \ + adapter={:?}, limits={:?}, dual_source_blending={dual_source_blending}", + context.adapter.get_info().name, + context.device.limits(), + backend = context.backend, + dual_source_blending = context.dual_source_blending, + ); + Ok(PreparedWebGraphics { context, surface }) + } + + fn from_created_device( + instance: wgpu::Instance, + adapter: wgpu::Adapter, + device: CreatedDevice, + ) -> anyhow::Result { + let (device_lost, uncaptured_error, fatal_error) = install_device_callbacks(&device.device); + let info = adapter.get_info(); + log::info!("Selected GPU adapter: {:?} ({:?})", info.name, info.backend); + #[cfg(target_family = "wasm")] + let backend = match info.backend { + wgpu::Backend::BrowserWebGpu => WgpuBackend::BrowserWebGpu, + wgpu::Backend::Gl => WgpuBackend::Gl, + backend => anyhow::bail!( + "Browser graphics initialization selected unexpected backend {backend:?}" + ), + }; + #[cfg(not(target_family = "wasm"))] + let backend = WgpuBackend::Native(info.backend); + + Ok(Self { + instance, + adapter, + backend, + device: Arc::new(device.device), + queue: Arc::new(device.queue), + dual_source_blending: device.dual_source_blending, + color_texture_format: device.color_texture_format, + renderer_tier: device.renderer_tier, + device_lost, + uncaptured_error, + fatal_error, + }) + } + + async fn create_device( + adapter: &wgpu::Adapter, + extra_requirements: Option<&WgpuDeviceRequirements>, + ) -> anyhow::Result { + let renderer_tier = renderer_tier(adapter); + // Our LCD shader uses storage buffers even when the adapter exposes dual-source + // blending. Downlevel devices must use grayscale and the data-texture dialect. + let dual_source_blending = renderer_tier == RendererTier::Modern + && adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + + let mut required_features = wgpu::Features::empty(); + if dual_source_blending { + required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; + } else { + log::warn!( + "Dual-source blending not available on this GPU. \ + Subpixel text antialiasing will be disabled." + ); + } + + let color_texture_format = Self::select_color_texture_format(adapter)?; + + let baseline = match renderer_tier { + RendererTier::Modern => wgpu::Limits::downlevel_defaults(), + RendererTier::WebGl2 => wgpu::Limits::downlevel_webgl2_defaults(), + }; + let mut required_limits = baseline + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()); + + // Merge application-requested requirements. + if let Some(reqs) = extra_requirements { + required_features |= reqs.features; + if let Some(limits) = &reqs.limits { + required_limits = required_limits.or_better_values_from(limits); + } + } + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits, + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; + + Ok(CreatedDevice { + device, + queue, + dual_source_blending, + color_texture_format, + renderer_tier, + }) + } + + pub fn check_compatible_with_surface(&self, surface: &wgpu::Surface<'_>) -> anyhow::Result<()> { + let caps = surface.get_capabilities(&self.adapter); + if caps.formats.is_empty() { + let info = self.adapter.get_info(); + anyhow::bail!( + "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the \ + display surface for this window.", + info.name, + info.backend, + info.device, + ); + } + Ok(()) + } + + /// Select an adapter and create a device, testing that the surface can actually be configured. + /// This is the only reliable way to determine compatibility on hybrid GPU systems, where + /// adapters may report surface compatibility via get_capabilities() but fail when actually + /// configuring (e.g., NVIDIA reporting Vulkan Wayland support but failing because the + /// Wayland compositor runs on the Intel GPU). + #[cfg(not(target_family = "wasm"))] + async fn select_adapter_and_device( + instance: &wgpu::Instance, + backend: NativeBackend, + device_id_filter: Option, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option<&CompositorGpuHint>, + adapter_policy: SoftwareAdapterPolicy, + extra_requirements: Option<&WgpuDeviceRequirements>, + ) -> anyhow::Result { + let mut adapters: Vec<_> = instance.enumerate_adapters(backend.into()).await; + + if adapters.is_empty() { + anyhow::bail!("No GPU adapters found"); + } + + if let Some(device_id) = device_id_filter { + log::info!("ZED_DEVICE_ID filter: {:#06x}", device_id); + } + + // Sort adapters into a single priority order. Tiers (from highest to lowest): + // + // 1. ZED_DEVICE_ID match — explicit user override + // 2. Compositor GPU match — the GPU the display server is rendering on + // 3. Device type (Discrete > Integrated > Other > Virtual > Cpu). + // "Other" ranks above "Virtual" because OpenGL seems to count as "Other". + // 4. Backend — prefer Vulkan/Metal/Dx12 over GL/etc. + adapters.sort_by_key(|adapter| { + let info = adapter.get_info(); + + // Backends like OpenGL report device=0 for all adapters, so + // device-based matching is only meaningful when non-zero. + let device_known = info.device != 0; + + let user_override: u8 = match device_id_filter { + Some(id) if device_known && info.device == id => 0, + _ => 1, + }; + + let compositor_match: u8 = match compositor_gpu { + Some(hint) + if device_known + && info.vendor == hint.vendor_id + && info.device == hint.device_id => + { + 0 + } + _ => 1, + }; + + #[cfg(target_vendor = "apple")] + let type_priority: u8 = match info.device_type { + // Preserves the native renderer's low-power preference on Intel Macs. + wgpu::DeviceType::IntegratedGpu => 0, + wgpu::DeviceType::DiscreteGpu => 1, + wgpu::DeviceType::Other => 2, + wgpu::DeviceType::VirtualGpu => 3, + wgpu::DeviceType::Cpu => 4, + }; + #[cfg(not(target_vendor = "apple"))] + let type_priority: u8 = if info.device_type == wgpu::DeviceType::Cpu { + 4 + } else { + match info.device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + wgpu::DeviceType::VirtualGpu => 3, + wgpu::DeviceType::Cpu => 4, + } + }; + + let backend_priority: u8 = match info.backend { + wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, + _ => 1, + }; + + ( + user_override, + compositor_match, + type_priority, + backend_priority, + ) + }); + + // Log all available adapters (in sorted order) + log::info!("Found {} GPU adapter(s):", adapters.len()); + for adapter in &adapters { + let info = adapter.get_info(); + log::info!( + " - {} (vendor={:#06x}, device={:#06x}, backend={:?}, type={:?})", + info.name, + info.vendor, + info.device, + info.backend, + info.device_type, + ); + } + + // Test each adapter by creating a device and configuring the surface + for adapter in adapters { + let info = adapter.get_info(); + + if !adapter_policy.accepts(info.device_type) { + log::info!( + "Skipping software renderer: {} ({:?})", + info.name, + info.backend + ); + continue; + } + + log::info!("Testing adapter: {} ({:?})...", info.name, info.backend); + + match Self::try_adapter_with_surface(&adapter, surface, extra_requirements).await { + Ok(device) => { + log::info!( + "Adapter passed surface configuration test: {} ({:?})", + info.name, + info.backend + ); + return Ok(SelectedAdapter { adapter, device }); + } + Err(e) => { + log::info!( + " Adapter {} ({:?}) failed: {}, trying next...", + info.name, + info.backend, + e + ); + } + } + } + + anyhow::bail!("No GPU adapter found that can configure the display surface") + } + + /// Try to use an adapter with a surface by creating a device and testing configuration. + /// Returns the device and queue if successful, allowing them to be reused. + #[cfg(not(target_family = "wasm"))] + async fn try_adapter_with_surface( + adapter: &wgpu::Adapter, + surface: &wgpu::Surface<'_>, + extra_requirements: Option<&WgpuDeviceRequirements>, + ) -> anyhow::Result { + let caps = surface.get_capabilities(adapter); + if caps.formats.is_empty() { + anyhow::bail!("no compatible surface formats"); + } + if caps.alpha_modes.is_empty() { + anyhow::bail!("no compatible alpha modes"); + } + + let device = Self::create_device(adapter, extra_requirements).await?; + let error_scope = device + .device + .push_error_scope(wgpu::ErrorFilter::Validation); + + let test_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: caps.formats[0], + width: 64, + height: 64, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + }; + + surface.configure(&device.device, &test_config); + + let error = error_scope.pop().await; + if let Some(e) = error { + anyhow::bail!("surface configuration failed: {e}"); + } + + Ok(device) + } + + fn select_color_texture_format(adapter: &wgpu::Adapter) -> anyhow::Result { + let required_usages = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let bgra_features = adapter.get_texture_format_features(wgpu::TextureFormat::Bgra8Unorm); + let rgba_features = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba8Unorm); + #[cfg(target_family = "wasm")] + if adapter.get_info().backend == wgpu::Backend::Gl + && rgba_features.allowed_usages.contains(required_usages) + { + return Ok(wgpu::TextureFormat::Rgba8Unorm); + } + if bgra_features.allowed_usages.contains(required_usages) { + return Ok(wgpu::TextureFormat::Bgra8Unorm); + } + if rgba_features.allowed_usages.contains(required_usages) { + let info = adapter.get_info(); + log::warn!( + "Adapter {} ({:?}) does not support Bgra8Unorm atlas textures with usages {:?}; \ + falling back to Rgba8Unorm atlas textures.", + info.name, + info.backend, + required_usages, + ); + return Ok(wgpu::TextureFormat::Rgba8Unorm); + } + + let info = adapter.get_info(); + Err(anyhow::anyhow!( + "Adapter {} ({:?}, device={:#06x}) does not support a usable color atlas texture \ + format with usages {:?}. Bgra8Unorm allowed usages: {:?}; \ + Rgba8Unorm allowed usages: {:?}.", + info.name, + info.backend, + info.device, + required_usages, + bgra_features.allowed_usages, + rgba_features.allowed_usages, + )) + } + pub fn backend(&self) -> WgpuBackend { + self.backend + } + + pub fn uses_webgl_instance_data(&self) -> bool { + matches!(self.backend, WgpuBackend::Gl) && cfg!(target_family = "wasm") + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } + + pub fn color_texture_format(&self) -> wgpu::TextureFormat { + self.color_texture_format + } + + pub fn renderer_tier(&self) -> RendererTier { + self.renderer_tier + } + + /// Returns true if the GPU device was lost (driver crash, suspend/resume). + /// When this returns true, the context should be recreated. + pub fn device_lost(&self) -> bool { + self.device_lost.load(Ordering::Relaxed) + } + + /// Returns a clone of the device_lost flag for sharing with renderers. + pub(crate) fn device_lost_flag(&self) -> Arc { + Arc::clone(&self.device_lost) + } + + pub(crate) fn fatal_error_slot(&self) -> Arc>> { + Arc::clone(&self.fatal_error) + } + + pub(crate) fn uncaptured_error_slot(&self) -> Arc>> { + Arc::clone(&self.uncaptured_error) + } +} + +fn renderer_tier(adapter: &wgpu::Adapter) -> RendererTier { + let limits = adapter.limits(); + let flags = adapter.get_downlevel_capabilities().flags; + if limits.max_storage_buffers_per_shader_stage > 0 + && flags.contains(wgpu::DownlevelFlags::VERTEX_STORAGE) + && flags.contains(wgpu::DownlevelFlags::FRAGMENT_STORAGE) + { + RendererTier::Modern + } else { + RendererTier::WebGl2 + } +} + +fn install_device_callbacks( + device: &wgpu::Device, +) -> ( + Arc, + Arc>>, + Arc>>, +) { + let device_lost = Arc::new(AtomicBool::new(false)); + device.set_device_lost_callback({ + let device_lost = Arc::clone(&device_lost); + move |reason, message| { + log::error!("wgpu device lost: reason={reason:?}, message={message}"); + if reason != wgpu::DeviceLostReason::Destroyed { + device_lost.store(true, Ordering::Relaxed); + } + } + }); + let uncaptured_error = Arc::new(Mutex::new(None)); + let fatal_error = Arc::new(Mutex::new(None)); + device.on_uncaptured_error(Arc::new({ + let uncaptured_error = Arc::clone(&uncaptured_error); + let fatal_error = Arc::clone(&fatal_error); + move |error| { + let message = error.to_string(); + log::error!("uncaptured wgpu error: {message}"); + if matches!( + error, + wgpu::Error::OutOfMemory { .. } | wgpu::Error::Internal { .. } + ) { + *fatal_error.lock().unwrap() = Some(message.clone()); + } + *uncaptured_error.lock().unwrap() = Some(message); + } + })); + (device_lost, uncaptured_error, fatal_error) +} + +#[cfg(not(target_family = "wasm"))] +fn parse_pci_id(id: &str) -> anyhow::Result { + let mut id = id.trim(); + + if id.starts_with("0x") || id.starts_with("0X") { + id = &id[2..]; + } + let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); + let is_4_chars = id.len() == 4; + anyhow::ensure!( + is_4_chars && is_hex_string, + "Expected a 4 digit PCI ID in hexadecimal format" + ); + + u32::from_str_radix(id, 16).context("parsing PCI ID as hex") +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::{NativeBackend, SoftwareAdapterPolicy, parse_pci_id}; + + #[test] + fn native_backend_fallbacks_are_individually_initialized() { + let backends = NativeBackend::PREFERENCE; + assert!(!backends.is_empty()); + assert!( + backends + .iter() + .all(|backend| wgpu::Backends::from(*backend).bits().count_ones() == 1), + "each fallback step must initialize exactly one WGPU backend" + ); + + #[cfg(target_os = "linux")] + assert_eq!(backends, &[NativeBackend::Vulkan, NativeBackend::Gl]); + #[cfg(target_os = "windows")] + assert_eq!( + backends, + &[ + NativeBackend::Dx12, + NativeBackend::Vulkan, + NativeBackend::Gl, + ] + ); + #[cfg(target_os = "macos")] + assert_eq!(backends, &[NativeBackend::Metal]); + } + + #[test] + fn native_backend_fallback_preserves_ordered_failures() { + let mut attempted = Vec::new(); + let error = NativeBackend::try_in_preference_order::<()>("test context", |backend| { + attempted.push(backend); + anyhow::bail!("unavailable") + }) + .unwrap_err(); + + assert_eq!(attempted, NativeBackend::PREFERENCE); + let message = format!("{error:#}"); + let mut previous = 0; + for backend in NativeBackend::PREFERENCE { + let index = message[previous..] + .find(&format!("{backend:?}: unavailable")) + .expect("each backend failure should be reported in preference order"); + previous += index; + } + } + + #[test] + fn software_adapter_policy_is_explicit() { + assert!(SoftwareAdapterPolicy::Allow.accepts(wgpu::DeviceType::Cpu)); + assert!(!SoftwareAdapterPolicy::Reject.accepts(wgpu::DeviceType::Cpu)); + assert!( + SoftwareAdapterPolicy::Reject.accepts(wgpu::DeviceType::IntegratedGpu), + "rejecting software must not reject lower-power hardware adapters" + ); + } + + #[test] + fn test_parse_device_id() { + assert!(parse_pci_id("0xABCD").is_ok()); + assert!(parse_pci_id("ABCD").is_ok()); + assert!(parse_pci_id("abcd").is_ok()); + assert!(parse_pci_id("1234").is_ok()); + assert!(parse_pci_id("123").is_err()); + assert_eq!( + parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), + ); + + assert_eq!( + parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), + ); + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer.rs b/vendor/gpui-wgpu/src/wgpu_renderer.rs new file mode 100644 index 000000000..ae420652a --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer.rs @@ -0,0 +1,1181 @@ +use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext, WgpuContextHandle, WgpuDeviceRequirements}; +use gpui::{DevicePixels, GpuSpecs, Scene, Size}; + +#[cfg(test)] +use gpui::BackdropFilter; +#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] +use gpui::{Bounds, ColorExt, Quad, ScaledPixels, Underline, blue, red, white}; +#[cfg(test)] +use gpui_render::blur::{BlurKernel, MAX_GAUSSIAN_SAMPLES_PER_SIDE}; +#[cfg(test)] +use gpui_render::shaders::interface as shader_interface; +#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] +use smallvec::{SmallVec, smallvec}; + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +mod buffers; +mod drawing; +mod filters; +mod frame; +#[cfg(all(feature = "test-support", not(target_family = "wasm")))] +mod headless; +#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] +mod lazy_smoothing_tests; +mod path_types; +mod pipelines; +mod platform; +pub use platform::RecoveryPending; +mod resources; +mod retained; +mod settings; +mod surfaces; +mod target; + +#[cfg(all(feature = "test-support", not(target_family = "wasm")))] +pub use headless::WgpuHeadlessRenderer; +use pipelines::WgpuPipelines as ShaderPipelines; +use resources::{GlobalBufferLayout, ResourceMetadata, WgpuResources}; +use settings::RenderingParameters; +pub use settings::{FontRasterizationSettings, SubpixelOrder, WgpuSurfaceConfig}; +use target::RenderTarget; + +/// Shared GPU context reference, used to coordinate device recovery across multiple windows. +pub type GpuContext = Rc>>; + +struct GpuFaultState { + pending_error: Arc>>, + consecutive_failed_frames: u32, + terminal_error: Option, + fatal_error: Arc>>, + cleaned_up_failed_frames: bool, + device_lost: Arc, + #[cfg(not(target_family = "wasm"))] + recovery_not_before: Option, +} + +pub struct WgpuRenderer { + /// Shared GPU context for device recovery coordination (unused on WASM). + #[allow(dead_code)] + context: Option, + #[cfg(not(target_family = "wasm"))] + native_backend: Option, + /// Compositor GPU hint for adapter selection (unused on WASM). + #[allow(dead_code)] + compositor_gpu: Option, + /// Application-requested extra wgpu features/limits, stored for device recovery. + #[allow(dead_code)] + extra_requirements: Option, + resources: Option, + target: RenderTarget, + atlas: Arc, + globals: GlobalBufferLayout, + rendering_params: RenderingParameters, + subpixel_order: SubpixelOrder, + dual_source_blending: bool, + adapter_info: wgpu::AdapterInfo, + uploaded_globals: Option, + faults: GpuFaultState, + retained: retained::RetainedFrame, + #[cfg(test)] + last_retained_presentation: &'static str, +} + +impl WgpuRenderer { + fn resources(&self) -> &WgpuResources { + self.resources + .as_ref() + .expect("GPU resources not available") + } + + fn resources_mut(&mut self) -> &mut WgpuResources { + self.resources + .as_mut() + .expect("GPU resources not available") + } + + fn new_internal( + gpu_context: Option, + context: &WgpuContext, + surface: Option>, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + extra_requirements: Option, + atlas: Arc, + ) -> anyhow::Result { + let adapter_info = context.adapter.get_info(); + let target = RenderTarget::new( + &context.adapter, + &context.device, + surface.as_ref(), + config, + adapter_info.backend, + )?; + if let Some(surface) = surface.as_ref() { + surface.configure(&context.device, target.configuration()); + } + + let dual_source_blending = context.supports_dual_source_blending(); + let rendering_params = RenderingParameters::new(&context.adapter, target.format()); + let (resources, metadata) = WgpuResources::new( + context, + surface, + target.configuration(), + &rendering_params, + dual_source_blending, + )?; + let ResourceMetadata { + globals, + last_error, + } = metadata; + + Ok(Self { + context: gpu_context, + #[cfg(not(target_family = "wasm"))] + native_backend: None, + compositor_gpu, + extra_requirements, + resources: Some(resources), + target, + atlas, + globals, + rendering_params, + subpixel_order: SubpixelOrder::RedGreenBlue, + dual_source_blending, + adapter_info, + uploaded_globals: None, + retained: retained::RetainedFrame::new(), + #[cfg(test)] + last_retained_presentation: "direct", + faults: GpuFaultState { + pending_error: last_error, + consecutive_failed_frames: 0, + terminal_error: None, + fatal_error: context.fatal_error_slot(), + cleaned_up_failed_frames: false, + device_lost: context.device_lost_flag(), + #[cfg(not(target_family = "wasm"))] + recovery_not_before: None, + }, + }) + } + + /// A confirmed GPU failure, consumed by platforms before their fatal error handling. + pub fn terminal_error(&self) -> Option { + self.faults + .terminal_error + .clone() + .or_else(|| self.faults.fatal_error.lock().unwrap().clone()) + } + + // Only the strict Windows integration delegates fatal handling to its application. + pub(super) fn reports_terminal_errors(&self) -> bool { + #[cfg(target_os = "windows")] + { + self.native_backend == Some(crate::NativeBackend::Dx12) + } + #[cfg(not(target_os = "windows"))] + { + false + } + } + + pub fn update_drawable_size(&mut self, size: Size) { + if !self.target.resize(size) { + return; + } + self.retained.invalidate(); + let config = self.target.configuration().clone(); + let Some(resources) = self.resources.as_mut() else { + return; + }; + resources.invalidate_intermediate_textures(); + if let Some(surface) = resources.surface.as_ref() { + surface.configure(&resources.device, &config); + } + } + + /// Selects the physical LCD component order used by subpixel glyph correction. + pub fn set_subpixel_order(&mut self, order: SubpixelOrder) { + if order != self.subpixel_order { + self.retained.invalidate(); + } + self.subpixel_order = order; + } + + /// Updates platform-specific text rasterization parameters. + pub fn set_font_rasterization_settings(&mut self, settings: FontRasterizationSettings) { + self.retained.invalidate(); + self.rendering_params.font_rasterization = settings; + self.subpixel_order = settings.subpixel_order; + } + + /// Compatibility wrapper for callers that still report the layout as a boolean. + pub fn set_subpixel_layout(&mut self, is_bgr: bool) { + self.set_subpixel_order(if is_bgr { + SubpixelOrder::BlueGreenRed + } else { + SubpixelOrder::RedGreenBlue + }); + } + + pub fn update_transparency(&mut self, transparent: bool) { + if !self.target.set_transparent(transparent) { + return; + } + self.retained.invalidate(); + let config = self.target.configuration().clone(); + if let Some(resources) = self.resources.as_ref() { + if let Some(surface) = resources.surface.as_ref() { + surface.configure(&resources.device, &config); + } + } + self.rebuild_pipelines(); + } + + pub(super) fn rebuild_pipelines(&mut self) { + let config = self.target.configuration(); + let Some(resources) = self.resources.as_mut() else { + return; + }; + resources.pipelines = ShaderPipelines::new( + &resources.device, + &resources.bind_group_layouts, + config.format, + config.alpha_mode, + self.rendering_params.path_sample_count, + self.dual_source_blending, + resources.renderer_tier, + ); + } + + #[allow(dead_code)] + pub fn viewport_size(&self) -> Size { + self.target.viewport_size() + } + + pub fn sprite_atlas(&self) -> &Arc { + &self.atlas + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } + + pub fn gpu_context(&self) -> (Arc, Arc) { + let resources = self.resources(); + (resources.device.clone(), resources.queue.clone()) + } + + pub fn gpu_context_info(&self) -> Option { + if let Some(context) = self.context.as_ref().and_then(|context| { + context + .borrow() + .as_ref() + .map(|context| context.handle(self.target.format())) + }) { + return Some(context); + } + self.resources.as_ref().map(|resources| { + WgpuContextHandle::from_resources( + resources.device.clone(), + resources.queue.clone(), + self.target.format(), + self.adapter_info.clone(), + match self.adapter_info.backend { + wgpu::Backend::BrowserWebGpu => crate::WgpuBackend::BrowserWebGpu, + wgpu::Backend::Gl => crate::WgpuBackend::Gl, + backend => crate::WgpuBackend::Native(backend), + }, + self.faults.device_lost.clone(), + ) + }) + } + + pub fn gpu_specs(&self) -> GpuSpecs { + GpuSpecs { + is_software_emulated: self.adapter_info.device_type == wgpu::DeviceType::Cpu, + device_name: self.adapter_info.name.clone(), + driver_name: self.adapter_info.driver.clone(), + driver_info: self.adapter_info.driver_info.clone(), + } + } + /// Release the retained frame when a window is deactivated. + pub fn release_retained_frame(&mut self) { + self.retained.invalidate(); + } + + pub fn max_texture_size(&self) -> u32 { + self.target.maximum_dimension() + } + + /// Encodes through the shared scene path with the exact presentation texture. + fn render_to_texture( + &mut self, + scene: &Scene, + texture: &wgpu::Texture, + view: &wgpu::TextureView, + ) -> bool { + frame::render_to_view(self, scene, view, texture, None).is_some() + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn render_to_view_with_readback( + &mut self, + scene: &Scene, + frame_view: &wgpu::TextureView, + readback: frame::ReadbackCopy<'_>, + ) -> Option { + frame::render_to_view(self, scene, frame_view, readback.texture, Some(readback)) + } +} + +fn begin_color_render_pass<'encoder>( + encoder: &'encoder mut wgpu::CommandEncoder, + label: &'encoder str, + target: &'encoder wgpu::TextureView, + load: wgpu::LoadOp, +) -> wgpu::RenderPass<'encoder> { + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wgpu_renderer::filters::FrameUniformRequirements; + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn dashed_border_scene(dash_length: f32, dash_gap: f32) -> Scene { + let full_bounds = test_bounds(0.0, 0.0, 40.0, 20.0); + let quad_bounds = |left| test_bounds(left, 4.0, 12.0, 12.0); + let mut scene = Scene::default(); + + for (bounds, corner_smoothing) in [(quad_bounds(4.0), 0.0), (quad_bounds(24.0), 0.6)] { + scene.insert_primitive(Quad { + bounds, + content_mask: gpui::ContentMask { + bounds: full_bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.05, 0.8, 0.45, 1.0)), + border_style: gpui::BorderStyle::Dashed, + border_dashed_length: dash_length, + border_dashed_gap: dash_gap, + border_color: gpui::hsla(0.6, 0.9, 0.7, 1.0).into(), + corner_radii: gpui::Corners::all(ScaledPixels(4.0)), + border_widths: gpui::Edges::all(ScaledPixels(2.0)), + corner_smoothing, + ..Default::default() + }); + } + + scene.finish(); + + scene + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn images_differ_in_region( + first: &image::RgbaImage, + second: &image::RgbaImage, + left: u32, + right: u32, + ) -> bool { + (4..16).any(|y| (left..right).any(|x| first.get_pixel(x, y) != second.get_pixel(x, y))) + } + + #[test] + fn rust_storage_types_match_shader_strides() { + fn module_layout(source: &wgsl_rs::Source) -> (naga::Module, naga::proc::Layouter) { + let source = source.wgsl_source().expect("shader must generate WGSL"); + let module = + naga::front::wgsl::parse_str(&format!("enable dual_source_blending;\n{source}")) + .expect("generated shader must parse as WGSL"); + let mut layouter = naga::proc::Layouter::default(); + layouter + .update(module.to_ctx()) + .expect("generated shader types must have valid layouts"); + (module, layouter) + } + + let (base, base_layout) = module_layout(&gpui_render::shaders::base::WGSL_SOURCE); + let (subpixel, subpixel_layout) = + module_layout(&gpui_render::shaders::subpixel_sprite::WGSL_SOURCE); + + for abi in shader_interface::SCENE_STORAGE_ABI + .iter() + .chain(path_types::STORAGE_ABI) + { + let base_handle = base + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some(abi.wgsl_type)) + .map(|(handle, _)| handle); + let shader_stride = if let Some(handle) = base_handle { + base_layout[handle].to_stride() + } else { + let (handle, _) = subpixel + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some(abi.wgsl_type)) + .unwrap_or_else(|| panic!("missing WGSL type {}", abi.wgsl_type)); + subpixel_layout[handle].to_stride() + }; + assert_eq!( + shader_stride as usize, abi.rust_stride, + "Rust and WGSL layouts differ for {}", + abi.wgsl_type, + ); + } + } + + #[test] + fn large_filter_scenes_reserve_unique_uniform_slots() { + let mut scene = Scene::default(); + scene + .backdrop_filters + .resize_with(100, BackdropFilter::default); + scene.finish(); + + assert_eq!( + frame::FrameRequirements::for_scene( + &scene, + super::buffers::InstanceTransport::StorageBuffer + ) + .uniforms, + FrameUniformRequirements { + filter_count: 401, + surface_count: 0, + } + ); + } + + #[test] + fn large_blur_radii_expand_tap_spacing_instead_of_truncating_the_kernel() { + let kernel = BlurKernel::for_radius(100.0).expect("positive radii have a blur kernel"); + assert_eq!(kernel.standard_deviation, 50.0); + assert_eq!(kernel.sample_count, MAX_GAUSSIAN_SAMPLES_PER_SIDE); + assert!(kernel.sample_step > 4.0); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + #[test] + fn desktop_blending_preserves_source_over_alpha() { + let straight = pipelines::desktop_scene_blend_state(wgpu::CompositeAlphaMode::Opaque); + assert_eq!(straight.color.src_factor, wgpu::BlendFactor::SrcAlpha); + assert_eq!( + straight.alpha.dst_factor, + wgpu::BlendFactor::OneMinusSrcAlpha + ); + + let premultiplied = + pipelines::desktop_scene_blend_state(wgpu::CompositeAlphaMode::PreMultiplied); + assert_eq!(premultiplied.color.src_factor, wgpu::BlendFactor::One); + assert_eq!( + premultiplied.alpha.dst_factor, + wgpu::BlendFactor::OneMinusSrcAlpha + ); + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn opaque_black_quad(width: f32, height: f32) -> Quad { + let bounds = test_bounds(0.0, 0.0, width, height); + Quad { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), + ..Default::default() + } + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn underline_opacity_is_applied_once() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let size = gpui::size(DevicePixels(3), DevicePixels(3)); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let bounds = test_bounds(0.0, 0.0, 3.0, 3.0); + let mut scene = Scene::default(); + // Forces a non-zero first-instance index in the mixed-type arena. + // The retired Metal baseline clears to black. Paint that background + // explicitly so Windows' white clear colour does not change the fixture. + scene.insert_primitive(opaque_black_quad(3.0, 3.0)); + scene.insert_primitive(Underline { + order: 0, + padding: 0, + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + color: gpui::hsla(0.0, 1.0, 0.5, 0.5).into(), + thickness: ScaledPixels(1.0), + wavy: false.into(), + }); + scene.finish(); + + let image = renderer.render_to_image(&scene)?; + let center = image.get_pixel(1, 1).0; + // UNORM8 blending can round half intensity to either adjacent value. + // A second opacity application would produce 64, far outside this bound. + assert!(center[0].abs_diff(128) <= 1, "half-opacity red: {center:?}"); + assert_eq!(¢er[1..], &[0, 0, 255]); + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn configurable_dashes_reach_both_wgpu_quad_pipelines() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let size = gpui::size(DevicePixels(40), DevicePixels(20)); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let default_image = renderer.render_to_image(&dashed_border_scene(2.0, 1.0))?; + let custom_image = renderer.render_to_image(&dashed_border_scene(4.0, 0.5))?; + + assert!( + images_differ_in_region(&default_image, &custom_image, 4, 16), + "custom dash length and gap must change the ordinary quad pipeline" + ); + assert!( + images_differ_in_region(&default_image, &custom_image, 24, 36), + "custom dash length and gap must change the smoothed quad pipeline" + ); + + Ok(()) + } + + /// Gradients add up to two units of dither per channel, seeded by a sine + /// hash of the pixel position (`gradient_dither`). Software rasterizers + /// still differ in UNORM8 rounding across versions and backends; preserve + /// upgrades' two-unit tolerance there. Hardware GPUs can evaluate the + /// sine hash differently, so their gradients can differ by twice the + /// dither plus rounding. Alpha and same-backend cropped/full comparisons + /// remain exact. + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn gradient_tolerance(context: &WgpuContext) -> u8 { + if context.adapter.get_info().device_type == wgpu::DeviceType::Cpu { + 2 + } else { + 5 + } + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn quad_backgrounds_match_legacy_metal_pixels() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + const LEGACY: &[u8] = &[ + 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 38, 110, 217, 255, 255, 0, 0, 255, 255, + 0, 0, 255, 38, 110, 217, 255, 0, 0, 0, 255, 10, 34, 4, 255, 57, 196, 22, 255, 193, 114, + 152, 255, 223, 155, 105, 255, 57, 196, 22, 255, 10, 34, 4, 255, 165, 60, 182, 255, 193, + 114, 151, 255, + ]; + let bounds = |x, y| test_bounds(x, y, 2.0, 2.0); + let mut scene = Scene::default(); + scene.insert_primitive(opaque_black_quad(4.0, 4.0)); + for (order, (bounds, background)) in [ + ( + bounds(0.0, 0.0), + gpui::solid_background(gpui::hsla(0.0, 1.0, 0.5, 1.0)), + ), + ( + bounds(2.0, 0.0), + gpui::checkerboard(gpui::hsla(0.6, 0.7, 0.5, 1.0), 1.0), + ), + ( + bounds(0.0, 2.0), + gpui::pattern_slash(gpui::hsla(0.3, 0.8, 0.5, 1.0), 1.0, 1.0), + ), + ( + bounds(2.0, 2.0), + gpui::linear_gradient( + 45.0, + gpui::linear_color_stop(gpui::hsla(0.8, 0.9, 0.4, 1.0), 0.0), + gpui::linear_color_stop(gpui::hsla(0.1, 0.8, 0.6, 1.0), 1.0), + ), + ), + ] + .into_iter() + .enumerate() + { + scene.insert_primitive(Quad { + order: order as u32, + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background, + ..Default::default() + }); + } + scene.finish(); + let context = WgpuContext::new_headless(None)?; + let gradient_tolerance = gradient_tolerance(&context); + let mut renderer = + WgpuRenderer::new_headless(&context, gpui::size(DevicePixels(4), DevicePixels(4)))?; + let actual = renderer.render_to_image(&scene)?; + for (index, (actual, expected)) in actual.as_raw().iter().zip(LEGACY).enumerate() { + let (x, y) = ((index / 4) % 4, (index / 4) / 4); + // The gradient quad covers the bottom-right 2x2 pixels. + let tolerance = if index % 4 == 3 { + 0 + } else if x >= 2 && y >= 2 { + gradient_tolerance + } else { + // Preserve upgrades' cross-backend UNORM8 rounding tolerance. + 2 + }; + assert!( + actual.abs_diff(*expected) <= tolerance, + "legacy mismatch at pixel ({x}, {y}), channel {}: got {actual}, expected {expected} ± {tolerance}", + index % 4 + ); + } + assert_eq!(actual.as_raw().len(), LEGACY.len()); + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn quad_border_backgrounds_use_the_fill_coordinate_space() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let gradient_tolerance = gradient_tolerance(&context).max(1); + let size = gpui::size(DevicePixels(12), DevicePixels(12)); + let bounds = test_bounds(0.0, 0.0, 12.0, 12.0); + let render = |quad| -> anyhow::Result { + let mut scene = Scene::default(); + // A white background exposes unwanted fills behind transparent + // pattern pixels and keeps the comparison independent of OS clears. + scene.insert_primitive(Quad { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 1.0, 1.0)), + ..Default::default() + }); + scene.insert_primitive(quad); + scene.finish(); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + renderer.render_to_image(&scene) + }; + // Fills and patterns compare within one unit everywhere; gradients + // carry dither (see `gradient_tolerance`). + let backgrounds = [ + (gpui::solid_background(gpui::hsla(0.0, 1.0, 0.5, 1.0)), 1), + (gpui::checkerboard(gpui::hsla(0.6, 0.7, 0.5, 1.0), 2.0), 1), + ( + gpui::pattern_slash(gpui::hsla(0.3, 0.8, 0.5, 1.0), 2.0, 2.0), + 1, + ), + ( + gpui::linear_gradient( + 90.0, + gpui::linear_color_stop(gpui::hsla(0.8, 0.9, 0.4, 1.0), 0.0), + gpui::linear_color_stop(gpui::hsla(0.1, 0.8, 0.6, 1.0), 1.0), + ), + gradient_tolerance, + ), + ]; + let border_samples = [ + (1, 1), + (6, 1), + (10, 1), + (1, 6), + (10, 6), + (1, 10), + (6, 10), + (10, 10), + ]; + + for (background, tolerance) in backgrounds { + let filled = render(Quad { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background, + ..Default::default() + })?; + let bordered = render(Quad { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + border_color: background, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 0.0)), + border_widths: gpui::Edges::all(ScaledPixels(4.0)), + ..Default::default() + })?; + + for (x, y) in border_samples { + let border_pixel = bordered.get_pixel(x, y).0; + let fill_pixel = filled.get_pixel(x, y).0; + for (channel, (border, fill)) in + border_pixel.iter().zip(fill_pixel.iter()).enumerate() + { + assert!( + border.abs_diff(*fill) <= tolerance, + "border background must sample like a fill at ({x}, {y}), channel {channel}, for {background:?}: got {border_pixel:?}, expected {fill_pixel:?}" + ); + } + } + assert_eq!( + bordered.get_pixel(6, 6).0, + [255, 255, 255, 255], + "border background must not paint the quad interior for {background:?}" + ); + } + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn cached_filter_bindings_accept_frame_local_dynamic_offsets() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let size = gpui::size(DevicePixels(4), DevicePixels(4)); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let bounds = test_bounds(0.0, 0.0, 4.0, 4.0); + let mut scene = Scene::default(); + scene.insert_primitive(BackdropFilter { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + filters: smallvec::smallvec![gpui::ScaledFilter::Blur(ScaledPixels(1.0))], + opacity: 1.0, + ..BackdropFilter::default() + }); + scene.finish(); + + renderer.render_to_image(&scene)?; + renderer.render_to_image(&scene)?; + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn odd_sized_blur_preserves_edge_symmetry() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let size = gpui::size(DevicePixels(5), DevicePixels(5)); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let full_bounds = test_bounds(0.0, 0.0, 5.0, 5.0); + let center_bounds = test_bounds(2.0, 2.0, 1.0, 1.0); + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + order: 0, + bounds: full_bounds, + content_mask: gpui::ContentMask { + bounds: full_bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), + ..Default::default() + }); + scene.insert_primitive(Quad { + order: 1, + bounds: center_bounds, + content_mask: gpui::ContentMask { + bounds: center_bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 1.0, 1.0)), + ..Default::default() + }); + scene.insert_primitive(BackdropFilter { + order: 2, + bounds: full_bounds, + content_mask: gpui::ContentMask { + bounds: full_bounds, + ..Default::default() + }, + filters: smallvec::smallvec![gpui::ScaledFilter::Blur(ScaledPixels(2.0))], + opacity: 1.0, + ..Default::default() + }); + scene.finish(); + + let image = renderer.render_to_image(&scene)?; + for y in 0..5 { + for x in 0..5 { + let actual = image.get_pixel(x, y).0; + let horizontal = image.get_pixel(4 - x, y).0; + let vertical = image.get_pixel(x, 4 - y).0; + for channel in 0..4 { + assert!( + actual[channel].abs_diff(horizontal[channel]) <= 1, + "horizontal blur asymmetry at ({x}, {y}), channel {channel}: {actual:?} vs {horizontal:?}" + ); + assert!( + actual[channel].abs_diff(vertical[channel]) <= 1, + "vertical blur asymmetry at ({x}, {y}), channel {channel}: {actual:?} vs {vertical:?}" + ); + } + } + } + assert!( + image.get_pixel(1, 2).0[0] > 0, + "blur must spread from the center pixel" + ); + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn alternating_instance_batches_fit_the_exact_upload_arena() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let size = gpui::size(DevicePixels(1), DevicePixels(1)); + let mut renderer = WgpuRenderer::new_headless(&context, size)?; + let bounds = test_bounds(0.0, 0.0, 1.0, 1.0); + let mut scene = Scene::default(); + for index in 0..512 { + scene.insert_primitive(Quad { + order: index * 2, + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), + ..Default::default() + }); + scene.insert_primitive(Underline { + order: index * 2 + 1, + padding: 0, + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + color: gpui::hsla(0.0, 0.0, 1.0, 1.0).into(), + thickness: ScaledPixels(1.0), + wavy: false.into(), + }); + } + scene.finish(); + assert_eq!( + scene + .render_commands() + .iter() + .filter(|command| matches!(command, gpui::RenderCommand::Batch(_))) + .count(), + 1024 + ); + + renderer.render_to_image(&scene)?; + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn test_bounds(left: f32, top: f32, width: f32, height: f32) -> Bounds { + Bounds::new( + gpui::point(ScaledPixels(left), ScaledPixels(top)), + gpui::size(ScaledPixels(width), ScaledPixels(height)), + ) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn test_quad( + bounds: Bounds, + mask: Bounds, + color: gpui::Hsla, + ) -> Quad { + Quad { + bounds, + content_mask: gpui::ContentMask { + bounds: mask, + ..Default::default() + }, + background: gpui::solid_background(color), + ..Default::default() + } + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn test_group( + scene: &mut Scene, + bounds: Bounds, + opacity: f32, + filters: SmallVec<[gpui::ScaledFilter; 4]>, + paint: impl FnOnce(&mut Scene), + ) { + let boundary = gpui::FilterBoundary { + order: 0, + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + corner_radii: gpui::Corners::default(), + corner_smoothing: 0.0, + filters, + opacity, + is_start: true, + }; + scene.insert_primitive(boundary.clone()); + paint(scene); + scene.insert_primitive(gpui::FilterBoundary { + is_start: false, + ..boundary + }); + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + fn test_renderers() -> anyhow::Result>> { + let mut renderers: Vec> = + vec![Box::new(WgpuHeadlessRenderer::new()?)]; + + if let Some(native) = gpui_platform::current_headless_renderer() { + renderers.push(native); + } + + Ok(renderers) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn opacity_groups_preserve_overlap_alpha_and_deep_nesting() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let bounds = test_bounds(0.0, 0.0, 16.0, 8.0); + let target_size = gpui::size(DevicePixels(16), DevicePixels(8)); + let green = gpui::hsla(1.0 / 3.0, 1.0, 0.5, 1.0); + let mut slider = Scene::default(); + test_group(&mut slider, bounds, 0.5, smallvec![], |scene| { + scene.insert_primitive(test_quad(test_bounds(0.0, 3.0, 8.0, 2.0), bounds, red())); + scene.insert_primitive(test_quad(test_bounds(4.0, 1.0, 4.0, 6.0), bounds, white())); + }); + + let mut translucent = Scene::default(); + translucent.insert_primitive(test_quad(bounds, bounds, green)); + test_group(&mut translucent, bounds, 0.5, smallvec![], |scene| { + scene.insert_primitive(test_quad(bounds, bounds, red().opacity(0.5))); + scene.insert_primitive(test_quad(bounds, bounds, blue().opacity(0.5))); + }); + + let mut nested = Scene::default(); + test_group(&mut nested, bounds, 0.5, smallvec![], |scene| { + scene.insert_primitive(test_quad(bounds, bounds, red())); + test_group(scene, bounds, 0.5, smallvec![], |scene| { + scene.insert_primitive(test_quad(bounds, bounds, blue())); + test_group(scene, bounds, 0.5, smallvec![], |scene| { + scene.insert_primitive(test_quad(bounds, bounds, white())); + }); + }); + }); + nested.insert_primitive(test_quad(test_bounds(12.0, 0.0, 4.0, 8.0), bounds, green)); + + for scene in [&mut slider, &mut translucent, &mut nested] { + scene.finish(); + } + + let cases = [ + ( + "overlap", + &slider, + &[ + (6, 4, [128, 128, 128, 255]), + (1, 4, [128, 0, 0, 255]), + (6, 1, [128, 128, 128, 255]), + (8, 4, [0, 0, 0, 255]), + ][..], + ), + ( + "intrinsic alpha", + &translucent, + &[(6, 4, [32, 159, 64, 255])][..], + ), + ( + "nesting", + &nested, + &[(6, 4, [96, 32, 64, 255]), (14, 4, [0, 255, 0, 255])][..], + ), + ]; + + for (renderer_idx, renderer) in test_renderers()?.iter_mut().enumerate() { + for (name, scene, samples) in cases { + let image = renderer.render_scene_to_image(scene, target_size)?; + + for &(x, y, expected) in samples { + let actual = image.get_pixel(x, y).0; + assert!( + actual + .iter() + .zip(expected) + .all(|(actual, expected)| actual.abs_diff(expected) <= 2), + "renderer {renderer_idx}, {name}, pixel ({x}, {y}): {actual:?}, expected {expected:?}" + ); + } + } + } + + Ok(()) + } + + #[cfg(all(feature = "test-support", not(target_family = "wasm")))] + #[test] + fn opacity_groups_preserve_blur_and_inherited_backdrops() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let bounds = test_bounds(0.0, 0.0, 16.0, 8.0); + let target_size = gpui::size(DevicePixels(16), DevicePixels(8)); + let green = gpui::hsla(1.0 / 3.0, 1.0, 0.5, 1.0); + let blur = smallvec![gpui::ScaledFilter::Blur(ScaledPixels(2.0))]; + let backdrop = BackdropFilter { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + filters: blur.clone(), + opacity: 1.0, + ..Default::default() + }; + let paint_background = |scene: &mut Scene| { + scene.insert_primitive(test_quad(bounds, bounds, red())); + scene.insert_primitive(test_quad(test_bounds(8.0, 0.0, 8.0, 8.0), bounds, blue())); + }; + + let mut original = Scene::default(); + paint_background(&mut original); + original.finish(); + + for (renderer_idx, renderer) in test_renderers()?.iter_mut().enumerate() { + let background = renderer.render_scene_to_image(&original, target_size)?; + + for (name, depth, with_local_quad, content_blur) in [ + ("inherited backdrop", 2, false, false), + ("local backdrop", 1, true, false), + ("content blur", 1, false, true), + ] { + let paint = |scene: &mut Scene, blur_enabled: bool| { + if with_local_quad { + scene.insert_primitive(test_quad( + test_bounds(6.0, 0.0, 4.0, 8.0), + bounds, + green, + )); + } + + if content_blur { + let filters = if blur_enabled { + blur.clone() + } else { + smallvec![] + }; + test_group(scene, bounds, 1.0, filters, |scene| { + scene.insert_primitive(test_quad( + test_bounds(6.0, 2.0, 4.0, 4.0), + bounds, + white(), + )); + }); + } else if blur_enabled { + scene.insert_primitive(backdrop.clone()); + } + }; + + let mut reference = Scene::default(); + let mut grouped = Scene::default(); + let mut unfiltered = Scene::default(); + + if !content_blur { + paint_background(&mut reference); + paint_background(&mut grouped); + paint_background(&mut unfiltered); + } + + paint(&mut reference, true); + paint(&mut unfiltered, false); + test_group(&mut grouped, bounds, 0.5, smallvec![], |scene| { + if depth == 2 { + test_group(scene, bounds, 0.5, smallvec![], |scene| paint(scene, true)); + } else { + paint(scene, true); + } + }); + + reference.finish(); + grouped.finish(); + unfiltered.finish(); + + let reference = renderer.render_scene_to_image(&reference, target_size)?; + let actual = renderer.render_scene_to_image(&grouped, target_size)?; + let unfiltered = renderer.render_scene_to_image(&unfiltered, target_size)?; + let opacity = 0.5_f32.powi(depth); + + assert!( + reference + .pixels() + .zip(unfiltered.pixels()) + .any(|(blurred, plain)| { + (0..3).any(|channel_idx| { + blurred[channel_idx].abs_diff(plain[channel_idx]) > 8 + }) + }), + "renderer {renderer_idx}, {name}: blur must visibly change the unfiltered scene" + ); + + if content_blur { + let falloff = reference.get_pixel(5, 4).0; + assert!( + falloff[0] > 8 && falloff[1] > 8 && falloff[2] > 8, + "renderer {renderer_idx}: content blur must spread outside the white quad: {falloff:?}" + ); + } + + for (pixel_idx, ((actual, reference), background)) in actual + .pixels() + .zip(reference.pixels()) + .zip(background.pixels()) + .enumerate() + { + for channel_idx in 0..3 { + let original = if content_blur { + 0.0 + } else { + background[channel_idx] as f32 + }; + let expected = (reference[channel_idx] as f32 * opacity + + original * (1.0 - opacity)) + .round() as u8; + assert!( + actual[channel_idx].abs_diff(expected) <= 3, + "renderer {renderer_idx}, {name}, pixel {pixel_idx}, channel {channel_idx}: {}, expected {expected}", + actual[channel_idx] + ); + } + } + } + } + + Ok(()) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs b/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs new file mode 100644 index 000000000..0d0aebb8d --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs @@ -0,0 +1,766 @@ +use collections::FxHashMap; +use gpui::AtlasTextureId; +use gpui_render::InstanceRange; +use gpui_render::artifacts::DATA_TEXTURE_WIDTH; +use gpui_render::shaders::interface::{self as shader_interface, BufferData}; +use std::{ + cell::{Cell, RefCell}, + marker::PhantomData, + num::NonZeroU64, + ops::Range, +}; + +use crate::{RendererTier, WgpuTextureIdentity}; + +use super::pipelines::{InstanceBindingSource, WgpuBindGroupLayouts}; + +/// Size of one `rgba32uint` data-texture texel, the downlevel transport's unit. +const TEXEL_BYTES: u64 = 16; + +/// How scene instance payloads reach the shaders. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum InstanceTransport { + /// Vertex-stage read-only storage buffer (WebGPU-class devices). + StorageBuffer, + /// Data texture plus a per-batch dynamic-offset range uniform (WebGL2/GLES). + DataTexture, +} + +impl InstanceTransport { + pub(super) fn from_tier(tier: RendererTier) -> Self { + match tier { + RendererTier::Modern => Self::StorageBuffer, + RendererTier::WebGl2 => Self::DataTexture, + } + } + + /// Byte alignment between batches; the downlevel base must land on a texel. + pub(super) fn batch_alignment(self, element_stride: u64) -> u64 { + match self { + Self::StorageBuffer => element_stride, + Self::DataTexture => TEXEL_BYTES, + } + } +} + +pub(super) struct InstanceSlice { + /// The draw arguments. On the storage-buffer transport the base indexes the whole + /// arena; downlevel draws always start at instance zero and carry the base in + /// their range-uniform slot instead. + instances: InstanceRange, + transport: InstanceTransport, + /// Byte offset of this batch's range-uniform slot; downlevel transport only. + range_offset: u32, + value: PhantomData, +} + +impl InstanceSlice { + pub(super) fn range(&self) -> Range { + self.instances.as_range() + } + + /// Binds the group-1 data bind group, adding the dynamic offset downlevel. + pub(super) fn set_data_bind_group( + &self, + pass: &mut wgpu::RenderPass<'_>, + bind_group: &wgpu::BindGroup, + ) { + match self.transport { + InstanceTransport::StorageBuffer => { + pass.set_bind_group(shader_interface::DATA_BIND_GROUP, bind_group, &[]) + } + InstanceTransport::DataTexture => pass.set_bind_group( + shader_interface::DATA_BIND_GROUP, + bind_group, + &[self.range_offset], + ), + } + } +} + +/// One frame's mapped scene-data upload: staging for all payloads plus downlevel slots. +pub(super) struct InstanceUpload { + staging: Option, + cursor: u64, + size: u64, + transport: InstanceTransport, + /// Downlevel only: mapped `DATA_RANGE` region and its slot allocator. + range_upload: Option, + /// Downlevel only: staging-to-texture copy recorded at finish. + texture_copy: Option, +} + +impl InstanceUpload { + pub(super) fn write(&mut self, values: &[T]) -> Option> { + if values.is_empty() { + return Some(self.empty_batch()); + } + let stride = std::mem::size_of::() as u64; + let data = shader_interface::slice_as_bytes(values); + let count = u32::try_from(values.len()).ok()?; + let (offset, range_offset) = self.allocate_batch(data.len() as u64, stride, count)?; + let staging = self + .staging + .as_mut() + .expect("non-empty frame uploads have mapped staging memory"); + staging + .slice(offset as usize..(offset + data.len() as u64) as usize) + .copy_from_slice(data); + Some(self.batch(offset, stride, count, range_offset)) + } + + pub(super) fn write_iter( + &mut self, + count: usize, + values: impl IntoIterator, + ) -> Option> { + if count == 0 { + return Some(self.empty_batch()); + } + let count = u32::try_from(count).ok()?; + let stride = std::mem::size_of::() as u64; + let size = stride.checked_mul(count as u64)?; + let (offset, range_offset) = self.allocate_batch(size, stride, count)?; + let staging = self.staging.as_mut()?; + let mut destination = staging.slice(offset as usize..(offset + size) as usize); + let mut values = values.into_iter(); + for index in 0..count { + let value = values.next()?; + let index = index as u64; + destination + .slice((index * stride) as usize..((index + 1) * stride) as usize) + .copy_from_slice(shader_interface::bytes_of(&value)); + } + if values.next().is_some() { + return None; + } + Some(self.batch(offset, stride, count, range_offset)) + } + + /// Reserves an aligned batch region, allocating its downlevel range slot. + fn allocate_batch(&mut self, size: u64, stride: u64, elements: u32) -> Option<(u64, u32)> { + let align = self.transport.batch_alignment(stride); + let offset = self.cursor.next_multiple_of(align); + if offset + size > self.size { + return None; + } + self.cursor = offset + size; + let range_offset = match self.transport { + InstanceTransport::StorageBuffer => 0, + InstanceTransport::DataTexture => { + let base_texel = u32::try_from(offset / TEXEL_BYTES).ok()?; + self.range_upload.as_mut()?.write(base_texel, elements)? + } + }; + Some((offset, range_offset)) + } + + /// An empty batch: no bytes, no range slot, no draw. + fn empty_batch(&self) -> InstanceSlice { + InstanceSlice { + instances: InstanceRange::new(0..0).expect("the empty range is addressable"), + transport: self.transport, + range_offset: 0, + value: PhantomData, + } + } + + fn batch( + &self, + offset: u64, + stride: u64, + count: u32, + range_offset: u32, + ) -> InstanceSlice { + let first = match self.transport { + InstanceTransport::StorageBuffer => { + usize::try_from(offset / stride).expect("batch offsets are stride-aligned") + } + InstanceTransport::DataTexture => 0, + }; + InstanceSlice { + instances: InstanceRange::new(first..first + count as usize) + .expect("arena capacity is bounded by the 32-bit instance index"), + transport: self.transport, + range_offset, + value: PhantomData, + } + } + + pub(super) fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { + // Dropping the mapped views schedules queue writes before the next submission. + self.staging.take(); + self.range_upload.take(); + if let Some(copy) = &self.texture_copy { + let used_texels = self.cursor.div_ceil(TEXEL_BYTES); + if used_texels > 0 { + let height = used_texels.div_ceil(u64::from(DATA_TEXTURE_WIDTH)); + encoder.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer: ©.staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(DATA_TEXTURE_WIDTH * TEXEL_BYTES as u32), + rows_per_image: Some(height as u32), + }, + }, + copy.texture.as_image_copy(), + wgpu::Extent3d { + width: DATA_TEXTURE_WIDTH, + height: height as u32, + depth_or_array_layers: 1, + }, + ); + } + } + } +} + +/// Mapped per-batch `DATA_RANGE` slots: batch base in texels plus element count. +/// +/// The shader reads only the first two fields, but browser/WebGL downlevel +/// backends require uniform binding sizes to be 16-byte aligned. +struct RangeUpload { + staging: wgpu::QueueWriteBufferView, + stride: u64, + slots: u64, + next_slot: u64, +} + +impl RangeUpload { + fn write(&mut self, base_texel: u32, elements: u32) -> Option { + let slot = self.next_slot; + if slot >= self.slots { + return None; + } + self.next_slot += 1; + let offset = slot * self.stride; + let mut bytes = [0u8; 16]; + bytes[..4].copy_from_slice(&base_texel.to_le_bytes()); + bytes[4..8].copy_from_slice(&elements.to_le_bytes()); + self.staging + .slice(offset as usize..(offset + 16) as usize) + .copy_from_slice(&bytes); + u32::try_from(offset).ok() + } +} + +/// Staging-to-texture copy destination recorded when the frame upload finishes. +struct TextureCopy { + staging: wgpu::Buffer, + texture: wgpu::Texture, +} + +/// Alignment-aware uniform storage with frame-local slot allocation. +pub(super) struct DynamicUniformBuffer { + pub(super) buffer: wgpu::Buffer, + label: &'static str, + stride: u64, + capacity: u64, + generation: u64, + next_slot: Cell, + staging: RefCell>, + value: PhantomData, +} + +impl DynamicUniformBuffer { + pub(super) fn new( + device: &wgpu::Device, + label: &'static str, + initial_capacity: u64, + uniform_alignment: u64, + ) -> Self { + let stride = (std::mem::size_of::() as u64).next_multiple_of(uniform_alignment); + let buffer = create_buffer( + device, + label, + stride * initial_capacity, + wgpu::BufferUsages::UNIFORM, + ); + Self { + buffer, + label, + stride, + capacity: initial_capacity, + generation: 0, + next_slot: Cell::new(0), + staging: RefCell::new(None), + value: PhantomData, + } + } + + pub(super) fn ensure_capacity( + &mut self, + device: &wgpu::Device, + required_capacity: u64, + maximum_buffer_size: u64, + ) -> bool { + if required_capacity <= self.capacity { + return true; + } + + let new_capacity = required_capacity.next_power_of_two(); + let Some(new_size) = self.stride.checked_mul(new_capacity) else { + return false; + }; + if new_size > maximum_buffer_size || new_size > u32::MAX as u64 { + return false; + } + + self.buffer = create_buffer(device, self.label, new_size, wgpu::BufferUsages::UNIFORM); + self.capacity = new_capacity; + self.generation = self.generation.wrapping_add(1); + true + } + + pub(super) fn begin_upload(&self, queue: &wgpu::Queue, required_capacity: u64) -> bool { + if required_capacity > self.capacity { + return false; + } + self.next_slot.set(0); + let Some(size) = NonZeroU64::new(self.stride * required_capacity) else { + self.staging.take(); + return true; + }; + let Some(mut staging) = queue.write_buffer_with(&self.buffer, 0, size) else { + return false; + }; + staging.slice(..).fill(0); + self.staging.replace(Some(staging)); + true + } + + pub(super) fn generation(&self) -> u64 { + self.generation + } + + pub(super) fn finish_upload(&self) { + self.staging.take(); + } + + pub(super) fn write(&self, value: &T) -> u32 { + let slot = self.next_slot.get(); + assert!( + slot < self.capacity, + "{} capacity must be ensured before rendering", + self.label + ); + self.next_slot.set(slot + 1); + let offset = slot * self.stride; + let bytes = shader_interface::bytes_of(value); + self.staging + .borrow_mut() + .as_mut() + .expect("uniform uploads must begin before rendering") + .slice(offset as usize..offset as usize + bytes.len()) + .copy_from_slice(bytes); + u32::try_from(offset).expect("dynamic uniform offsets are limited to u32") + } +} + +/// One frame's zero-copy scene data arena. +pub(super) struct InstanceBufferArena { + transport: InstanceTransport, + storage: InstanceStorage, + bind_group: wgpu::BindGroup, + textured_bind_groups: RefCell, + capacity: u64, + maximum_size: u64, +} + +/// Backing store for the arena's payload bytes. +enum InstanceStorage { + /// Read-only storage buffer consumed directly by the shaders. + Buffer(wgpu::Buffer), + /// Data texture fed from per-frame staging, plus range-uniform slots. + DataTexture { + texture: wgpu::Texture, + view: wgpu::TextureView, + staging: wgpu::Buffer, + ranges: RangeUniformArena, + }, +} + +/// Per-batch range slots in dynamic-offset-alignment stride, one bound per draw. +struct RangeUniformArena { + buffer: wgpu::Buffer, + stride: u64, + capacity: u64, + maximum: u64, +} + +impl RangeUniformArena { + const INITIAL_CAPACITY: u64 = 256; + + fn new(device: &wgpu::Device) -> Self { + let alignment = u64::from(device.limits().min_uniform_buffer_offset_alignment); + let stride = alignment.max(256); + // The whole-buffer limit constrains slot count, not the binding-size limit. + let maximum = device.limits().max_buffer_size / stride; + let capacity = Self::INITIAL_CAPACITY.min(maximum); + Self { + buffer: create_buffer( + device, + "downlevel_range_uniforms", + capacity * stride, + wgpu::BufferUsages::UNIFORM, + ), + stride, + capacity, + maximum, + } + } + + fn ensure_capacity(&mut self, device: &wgpu::Device, required_slots: u64) -> bool { + if required_slots <= self.capacity { + return true; + } + if required_slots > self.maximum { + return false; + } + self.capacity = required_slots.next_power_of_two().min(self.maximum); + self.buffer = create_buffer( + device, + "downlevel_range_uniforms", + self.capacity * self.stride, + wgpu::BufferUsages::UNIFORM, + ); + true + } + + fn binding(&self) -> wgpu::BufferBinding<'_> { + // One full slot stays inside max_uniform_buffer_binding_size. + wgpu::BufferBinding { + buffer: &self.buffer, + offset: 0, + size: NonZeroU64::new(self.stride), + } + } +} + +#[derive(Default)] +struct TexturedBindGroups { + groups: FxHashMap< + (shader_interface::DataLayout, AtlasTextureId), + (WgpuTextureIdentity, wgpu::BindGroup), + >, + path: FxHashMap, +} + +impl InstanceBufferArena { + // Large scenes grow geometrically; ordinary UI frames skip a permanent floor. + const INITIAL_CAPACITY: u64 = 64 * 1024; + + pub(super) fn new( + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + tier: RendererTier, + ) -> Self { + let transport = InstanceTransport::from_tier(tier); + let maximum_size = match transport { + InstanceTransport::StorageBuffer => device + .limits() + .max_buffer_size + .min(device.limits().max_storage_buffer_binding_size), + InstanceTransport::DataTexture => { + let texels = u64::from(DATA_TEXTURE_WIDTH) + * u64::from(device.limits().max_texture_dimension_2d); + (texels * TEXEL_BYTES).min(device.limits().max_buffer_size) / TEXEL_BYTES + * TEXEL_BYTES + } + }; + let capacity = Self::INITIAL_CAPACITY.min(maximum_size); + let storage = match transport { + InstanceTransport::StorageBuffer => InstanceStorage::Buffer(create_buffer( + device, + "instance_buffer", + capacity, + wgpu::BufferUsages::STORAGE, + )), + InstanceTransport::DataTexture => { + let texture = create_data_texture(device, capacity / TEXEL_BYTES); + let view = view_of(&texture); + InstanceStorage::DataTexture { + texture, + view, + staging: create_buffer( + device, + "instance_staging", + capacity, + wgpu::BufferUsages::COPY_SRC, + ), + ranges: RangeUniformArena::new(device), + } + } + }; + let bind_group = instance_bind_group(device, layouts, &storage); + Self { + transport, + storage, + bind_group, + textured_bind_groups: RefCell::default(), + capacity, + maximum_size, + } + } + + pub(super) fn transport(&self) -> InstanceTransport { + self.transport + } + + pub(super) fn ensure_capacity( + &mut self, + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + required: u64, + required_range_slots: u64, + ) -> bool { + if let InstanceStorage::DataTexture { ranges, .. } = &self.storage { + if required_range_slots > ranges.maximum { + log::error!( + "scene requires {required_range_slots} instance batches, exceeding the \ + range uniform limit of {} slots", + ranges.maximum + ); + return false; + } + } + let storage_grew = match &self.storage { + InstanceStorage::Buffer(_) => required > self.capacity, + InstanceStorage::DataTexture { ranges, .. } => { + required > self.capacity || required_range_slots > ranges.capacity + } + }; + if !storage_grew { + return true; + } + if required > self.maximum_size { + log::error!( + "scene requires {required} storage bytes, exceeding the GPU limit of {}", + self.maximum_size + ); + return false; + } + + match &mut self.storage { + InstanceStorage::Buffer(buffer) => { + self.capacity = required.next_power_of_two().min(self.maximum_size); + *buffer = create_buffer( + device, + "instance_buffer", + self.capacity, + wgpu::BufferUsages::STORAGE, + ); + } + InstanceStorage::DataTexture { + texture, + view, + staging, + ranges, + } => { + if required > self.capacity { + self.capacity = required.next_power_of_two().min(self.maximum_size); + *texture = create_data_texture(device, self.capacity / TEXEL_BYTES); + *view = view_of(texture); + *staging = create_buffer( + device, + "instance_staging", + self.capacity, + wgpu::BufferUsages::COPY_SRC, + ); + } + ranges.ensure_capacity(device, required_range_slots); + } + } + self.bind_group = instance_bind_group(device, layouts, &self.storage); + *self.textured_bind_groups.get_mut() = TexturedBindGroups::default(); + log::info!("increased instance buffer size to {}", self.capacity); + true + } + + pub(super) fn begin_upload( + &self, + queue: &wgpu::Queue, + required: u64, + required_range_slots: u64, + ) -> Option { + if required > self.capacity { + return None; + } + let staging = if let Some(size) = NonZeroU64::new(required) { + Some(match &self.storage { + InstanceStorage::Buffer(buffer) => queue.write_buffer_with(buffer, 0, size)?, + InstanceStorage::DataTexture { staging, .. } => { + queue.write_buffer_with(staging, 0, size)? + } + }) + } else { + None + }; + let range_upload = match &self.storage { + InstanceStorage::DataTexture { ranges, .. } if required_range_slots > 0 => { + Some(RangeUpload { + staging: queue.write_buffer_with( + &ranges.buffer, + 0, + NonZeroU64::new(required_range_slots.min(ranges.capacity) * ranges.stride)?, + )?, + stride: ranges.stride, + slots: required_range_slots.min(ranges.capacity), + next_slot: 0, + }) + } + _ => None, + }; + Some(InstanceUpload { + staging, + cursor: 0, + size: required, + transport: self.transport, + range_upload, + texture_copy: match &self.storage { + InstanceStorage::DataTexture { + texture, staging, .. + } => Some(TextureCopy { + staging: staging.clone(), + texture: texture.clone(), + }), + InstanceStorage::Buffer(_) => None, + }, + }) + } + + pub(super) fn bind_group(&self) -> &wgpu::BindGroup { + &self.bind_group + } + + pub(super) fn textured_bind_group( + &self, + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + data_layout: shader_interface::DataLayout, + texture_id: AtlasTextureId, + texture_identity: WgpuTextureIdentity, + texture: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + let mut cache = self.textured_bind_groups.borrow_mut(); + let key = (data_layout, texture_id); + if let Some((identity, bind_group)) = cache.groups.get(&key) + && *identity == texture_identity + { + return bind_group.clone(); + } + + let bind_group = layouts.create_textured_instances( + device, + data_layout, + self.binding_source(), + texture, + sampler, + ); + cache + .groups + .insert(key, (texture_identity, bind_group.clone())); + bind_group + } + + pub(super) fn path_bind_group( + &self, + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + data_layout: shader_interface::DataLayout, + texture: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + let mut cache = self.textured_bind_groups.borrow_mut(); + cache + .path + .entry(data_layout) + .or_insert_with(|| { + layouts.create_textured_instances( + device, + data_layout, + self.binding_source(), + texture, + sampler, + ) + }) + .clone() + } + + pub(super) fn binding_source(&self) -> InstanceBindingSource<'_> { + match &self.storage { + InstanceStorage::Buffer(buffer) => InstanceBindingSource::Buffer(whole_buffer(buffer)), + InstanceStorage::DataTexture { view, ranges, .. } => { + InstanceBindingSource::DataTexture { + texture: view, + range_uniforms: ranges.binding(), + } + } + } + } + + pub(super) fn invalidate_texture_bindings(&self) { + *self.textured_bind_groups.borrow_mut() = TexturedBindGroups::default(); + } +} + +/// Creates the group-1 bind group for the arena's current storage. +fn instance_bind_group( + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + storage: &InstanceStorage, +) -> wgpu::BindGroup { + let source = match storage { + InstanceStorage::Buffer(buffer) => InstanceBindingSource::Buffer(whole_buffer(buffer)), + InstanceStorage::DataTexture { view, ranges, .. } => InstanceBindingSource::DataTexture { + texture: view, + range_uniforms: ranges.binding(), + }, + }; + layouts.create_instances(device, source) +} + +fn create_data_texture(device: &wgpu::Device, texels: u64) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("instance_data_texture"), + size: wgpu::Extent3d { + width: DATA_TEXTURE_WIDTH, + height: texels.div_ceil(u64::from(DATA_TEXTURE_WIDTH)) as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba32Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) +} + +fn view_of(texture: &wgpu::Texture) -> wgpu::TextureView { + texture.create_view(&wgpu::TextureViewDescriptor::default()) +} + +fn whole_buffer(buffer: &wgpu::Buffer) -> wgpu::BufferBinding<'_> { + wgpu::BufferBinding { + buffer, + offset: 0, + size: None, + } +} + +fn create_buffer( + device: &wgpu::Device, + label: &str, + size: u64, + primary_usage: wgpu::BufferUsages, +) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage: primary_usage | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs b/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs new file mode 100644 index 000000000..b8642e5ba --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs @@ -0,0 +1,298 @@ +use gpui::{ + AtlasTextureId, MonochromeSprite, Path, PolychromeSprite, Quad, ScaledPixels, Shadow, + SubpixelSprite, Underline, +}; + +use crate::WgpuTextureInfo; +use gpui_render::shaders::interface::{self as shader_interface, BufferData}; + +use super::{ + WgpuRenderer, + buffers::{InstanceSlice, InstanceUpload}, + frame, path_types, pipelines, +}; + +impl WgpuRenderer { + /// Replace every RGBA channel in a damaged region before replaying its scene. + /// Source-over blending cannot erase old alpha with a transparent clear. + pub(super) fn draw_damage_clear( + &self, + quads: &[Quad], + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + self.draw_instances( + quads, + self.resources() + .pipelines + .damage_clear_quads + .as_ref() + .expect("damage clear pipeline is prepared before partial encoding"), + instances, + pass, + ) + } + + pub(super) fn draw_quads( + &self, + quads: &[Quad], + smoothed: bool, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let pipelines = &self.resources().pipelines; + let pipeline = if smoothed { + pipelines + .smoothed_quads + .as_ref() + .expect("smoothed quads prepared before encoding") + } else { + &pipelines.quads + }; + self.draw_instances(quads, pipeline, instances, pass) + } + + pub(super) fn draw_shadows( + &self, + shadows: &[Shadow], + smoothed: bool, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let pipelines = &self.resources().pipelines; + let pipeline = if smoothed { + pipelines + .smoothed_shadows + .as_ref() + .expect("smoothed shadows prepared before encoding") + } else { + &pipelines.shadows + }; + self.draw_instances(shadows, pipeline, instances, pass) + } + + pub(super) fn draw_underlines( + &self, + underlines: &[Underline], + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + self.draw_instances( + underlines, + &self.resources().pipelines.underlines, + instances, + pass, + ) + } + + pub(super) fn draw_monochrome_sprites( + &self, + sprites: &[MonochromeSprite], + texture_id: AtlasTextureId, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let texture = self.atlas.get_texture_info(texture_id); + self.draw_instances_with_texture( + sprites, + texture_id, + &texture, + &self.resources().pipelines.monochrome_sprites, + instances, + pass, + ) + } + + pub(super) fn draw_subpixel_sprites( + &self, + sprites: &[SubpixelSprite], + texture_id: AtlasTextureId, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let texture = self.atlas.get_texture_info(texture_id); + let resources = self.resources(); + let pipeline = resources + .pipelines + .subpixel_sprites + .as_ref() + .unwrap_or(&resources.pipelines.monochrome_sprites); + self.draw_instances_with_texture(sprites, texture_id, &texture, pipeline, instances, pass) + } + + pub(super) fn draw_polychrome_sprites( + &self, + sprites: &[PolychromeSprite], + texture_id: AtlasTextureId, + smoothed: bool, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let texture = self.atlas.get_texture_info(texture_id); + let pipelines = &self.resources().pipelines; + let pipeline = if smoothed { + pipelines + .smoothed_polychrome_sprites + .as_ref() + .expect("smoothed sprites prepared before encoding") + } else { + &pipelines.polychrome_sprites + }; + self.draw_instances_with_texture(sprites, texture_id, &texture, pipeline, instances, pass) + } + + fn draw_instances( + &self, + values: &[T], + pipeline: &pipelines::WgpuRenderPipeline, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + if values.is_empty() { + return Ok(()); + } + let resources = self.resources(); + self.draw_bound_instances( + values, + pipeline, + resources.instances.bind_group(), + instances, + pass, + ) + } + + fn draw_bound_instances( + &self, + values: &[T], + pipeline: &pipelines::WgpuRenderPipeline, + bind_group: &wgpu::BindGroup, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let Some(slice) = instances.write(values) else { + return Err(frame::DrawError::CapacityPlanningInvariant); + }; + self.draw_bound_slice(pipeline, bind_group, &slice, pass); + Ok(()) + } + + fn draw_bound_slice( + &self, + pipeline: &pipelines::WgpuRenderPipeline, + bind_group: &wgpu::BindGroup, + slice: &InstanceSlice, + pass: &mut wgpu::RenderPass<'_>, + ) { + pass.set_pipeline(pipeline); + slice.set_data_bind_group(pass, bind_group); + pass.draw(0..pipeline.fixed_vertex_count(), slice.range()); + } + + fn draw_instances_with_texture( + &self, + values: &[T], + texture_id: AtlasTextureId, + texture: &WgpuTextureInfo, + pipeline: &pipelines::WgpuRenderPipeline, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + if values.is_empty() { + return Ok(()); + } + let resources = self.resources(); + let bind_group = resources.instances.textured_bind_group( + &resources.device, + &resources.bind_group_layouts, + pipeline.data_layout(), + texture_id, + texture.identity, + &texture.view, + &resources.atlas_sampler, + ); + self.draw_bound_instances(values, pipeline, &bind_group, instances, pass) + } + + pub(super) fn draw_paths_from_intermediate( + &self, + paths: &[Path], + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + let sprite_count = path_types::sprite_count(paths); + let Some(sprite_slice) = instances.write_iter(sprite_count, path_types::sprites(paths)) + else { + return Err(frame::DrawError::CapacityPlanningInvariant); + }; + let resources = self.resources(); + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { + return Err(frame::DrawError::MissingIntermediateTarget); + }; + let bind_group = resources.instances.path_bind_group( + &resources.device, + &resources.bind_group_layouts, + resources.pipelines.paths.data_layout(), + path_intermediate_view, + &resources.atlas_sampler, + ); + self.draw_bound_slice(&resources.pipelines.paths, &bind_group, &sprite_slice, pass); + Ok(()) + } + + pub(super) fn draw_paths_to_intermediate( + &self, + encoder: &mut wgpu::CommandEncoder, + paths: &[Path], + instances: &mut InstanceUpload, + ) -> frame::DrawResult { + let vertex_count = path_types::rasterization_vertex_count(paths); + if vertex_count == 0 { + return Ok(()); + } + + let Some(vertex_slice) = + instances.write_iter(vertex_count, path_types::rasterization_vertices(paths)) + else { + return Err(frame::DrawError::CapacityPlanningInvariant); + }; + let resources = self.resources(); + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { + return Err(frame::DrawError::MissingIntermediateTarget); + }; + let (target_view, resolve_target) = if let Some(msaa_view) = &resources.path_msaa_view { + (msaa_view, Some(path_intermediate_view)) + } else { + (path_intermediate_view, None) + }; + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("path_rasterization_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + // Only the resolved texture is sampled later. The MSAA + // attachment is transient and cannot use StoreOp::Store. + store: if resolve_target.is_some() { + wgpu::StoreOp::Discard + } else { + wgpu::StoreOp::Store + }, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_pipeline(&resources.pipelines.path_rasterization); + pass.set_bind_group( + shader_interface::GLOBAL_BIND_GROUP, + &resources.path_globals_bind_group, + &[], + ); + vertex_slice.set_data_bind_group(&mut pass, resources.instances.bind_group()); + pass.draw(vertex_slice.range(), 0..1); + Ok(()) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs b/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs new file mode 100644 index 000000000..e69d8725a --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs @@ -0,0 +1,284 @@ +use gpui::{Bounds, Corners, ScaledPixels, point, size}; +use gpui_render::shaders::interface as shader_interface; +use gpui_render::{ + blur::{ + BlurAxis, BlurKernel, FilterCompositeClip, FilterCompositeParameters, + GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS, ScissorRectangle, downsampled_dimension, + }, + shaders::blur::BlurUniforms, +}; + +use super::{WgpuRenderer, begin_color_render_pass, pipelines}; + +pub(super) const FILTER_UNIFORMS_PER_COMPOSITE: u64 = 4; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct FrameUniformRequirements { + pub(super) filter_count: u64, + pub(super) surface_count: u64, +} + +const _: () = assert!(std::mem::size_of::() == 112); + +impl WgpuRenderer { + fn draw_filter_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + label: &str, + pipeline: &pipelines::WgpuRenderPipeline, + target: &wgpu::TextureView, + source: &wgpu::TextureView, + uniforms: BlurUniforms, + load: wgpu::LoadOp, + scissor: Option, + ) { + let resources = self.resources(); + let uniform_offset = resources.filter_uniforms.write(&uniforms); + let bind_group = resources.blur_bind_group(source); + let mut pass = begin_color_render_pass(encoder, label, target, load); + pass.set_pipeline(pipeline); + pass.set_bind_group( + shader_interface::GLOBAL_BIND_GROUP, + &resources.globals_bind_group, + &[], + ); + pass.set_bind_group( + shader_interface::DATA_BIND_GROUP, + &bind_group, + &[uniform_offset], + ); + + if let Some(scissor) = scissor { + pass.set_scissor_rect(scissor.x, scissor.y, scissor.width, scissor.height); + } + + pass.draw(0..pipeline.fixed_vertex_count(), 0..1); + } + + pub(super) fn blur_and_composite( + &self, + encoder: &mut wgpu::CommandEncoder, + source: &wgpu::TextureView, + target: &wgpu::TextureView, + parameters: FilterCompositeParameters, + ) { + if parameters.blur_radius <= 0.0 + && matches!(parameters.clip, FilterCompositeClip::RoundedBounds) + { + return; + } + + let full_size = [self.target.width() as f32, self.target.height() as f32]; + + let Some(kernel) = BlurKernel::for_radius(parameters.blur_radius) else { + self.composite_texture(encoder, source, target, parameters, full_size); + + return; + }; + let full_width = self.target.width(); + let full_height = self.target.height(); + let blur_size = [ + downsampled_dimension(full_width) as f32, + downsampled_dimension(full_height) as f32, + ]; + let dilation = GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS * parameters.blur_radius; + let scissor = ScissorRectangle::for_blurred_bounds( + parameters.bounds, + dilation, + full_width, + full_height, + ); + if scissor.is_empty() { + return; + } + + let (horizontal_target, vertical_target) = { + let resources = self.resources(); + match ( + resources.blur_ping_view.as_ref(), + resources.blur_pong_view.as_ref(), + ) { + (Some(horizontal), Some(vertical)) => (horizontal.clone(), vertical.clone()), + _ => return, + } + }; + + self.draw_filter_pass( + encoder, + "blur_downsample", + &self.resources().pipelines.blur_downsample, + &horizontal_target, + source, + BlurUniforms::downsample([full_width as f32, full_height as f32], blur_size), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + Some(scissor), + ); + self.draw_filter_pass( + encoder, + "blur_horizontal", + &self.resources().pipelines.blur, + &vertical_target, + &horizontal_target, + BlurUniforms::gaussian(BlurAxis::Horizontal, blur_size, kernel), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + Some(scissor), + ); + self.draw_filter_pass( + encoder, + "blur_vertical", + &self.resources().pipelines.blur, + &horizontal_target, + &vertical_target, + BlurUniforms::gaussian(BlurAxis::Vertical, blur_size, kernel), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + Some(scissor), + ); + + self.composite_texture(encoder, &horizontal_target, target, parameters, blur_size); + } + + fn composite_texture( + &self, + encoder: &mut wgpu::CommandEncoder, + source: &wgpu::TextureView, + target: &wgpu::TextureView, + parameters: FilterCompositeParameters, + source_size: [f32; 2], + ) { + let clips_to_bounds = matches!(parameters.clip, FilterCompositeClip::RoundedBounds); + let composite_bounds = if clips_to_bounds { + parameters.bounds + } else { + parameters.bounds.dilate(ScaledPixels( + GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS * parameters.blur_radius, + )) + }; + let uniforms = BlurUniforms::composite( + composite_bounds, + parameters.content_mask, + parameters.corner_radii, + parameters.corner_smoothing, + parameters.opacity, + parameters.clip, + source_size, + [self.target.width() as f32, self.target.height() as f32], + ); + let resources = self.resources(); + let pipeline = if uniforms.corner_smoothing > 0.0 { + resources + .pipelines + .smoothed_blur_composite + .as_ref() + .expect("smoothed blur prepared before encoding") + } else { + &resources.pipelines.blur_composite + }; + + self.draw_filter_pass( + encoder, + "blur_composite", + pipeline, + target, + source, + uniforms, + wgpu::LoadOp::Load, + None, + ); + } + + pub(super) fn snapshot_backdrop<'a>( + &self, + encoder: &mut wgpu::CommandEncoder, + sources: impl Iterator, + ) -> wgpu::TextureView { + let target = self + .resources() + .backdrop_snapshot_view + .as_ref() + .expect("backdrop snapshot was prepared") + .clone(); + let full_size = [self.target.width() as f32, self.target.height() as f32]; + let bounds = Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(full_size[0]), ScaledPixels(full_size[1])), + ); + drop(begin_color_render_pass( + encoder, + "clear_backdrop_snapshot", + &target, + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + )); + + for source in sources { + self.composite_texture( + encoder, + source, + &target, + FilterCompositeParameters { + bounds, + content_mask: bounds, + corner_radii: Corners::default(), + corner_smoothing: 0.0, + blur_radius: 0.0, + opacity: 1.0, + clip: FilterCompositeClip::ContentShape, + }, + full_size, + ); + } + + target + } + + pub(super) fn blit_to_frame( + &self, + encoder: &mut wgpu::CommandEncoder, + source: &wgpu::TextureView, + frame_view: &wgpu::TextureView, + ) { + let size = [self.target.width() as f32, self.target.height() as f32]; + + self.draw_filter_pass( + encoder, + "scene_blit", + &self.resources().pipelines.blur_downsample, + frame_view, + source, + BlurUniforms::copy(size), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + None, + ); + } + + /// Draws the texture `bind_group` samples over `frame_view` with copy uniforms. + pub(super) fn draw_blit( + &self, + encoder: &mut wgpu::CommandEncoder, + bind_group: &wgpu::BindGroup, + uniform_offset: u32, + frame_view: &wgpu::TextureView, + ) { + let resources = self.resources(); + let mut pass = begin_color_render_pass( + encoder, + "scene_blit", + frame_view, + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + ); + pass.set_pipeline(&resources.pipelines.blur_downsample); + pass.set_bind_group( + shader_interface::GLOBAL_BIND_GROUP, + &resources.globals_bind_group, + &[], + ); + pass.set_bind_group( + shader_interface::DATA_BIND_GROUP, + bind_group, + &[uniform_offset], + ); + pass.draw( + 0..resources.pipelines.blur_downsample.fixed_vertex_count(), + 0..1, + ); + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs b/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs new file mode 100644 index 000000000..06b0ba90e --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs @@ -0,0 +1,1104 @@ +use super::{ + WgpuRenderer, begin_color_render_pass, + buffers::{InstanceTransport, InstanceUpload}, + filters::{FILTER_UNIFORMS_PER_COMPOSITE, FrameUniformRequirements}, + path_types, + pipelines::SmoothingRequirements, +}; +use gpui::{ + FilterRenderTarget, MonochromeSprite, PolychromeSprite, PrimitiveBatch, Quad, RenderCommand, + Scene, ScenePlanRequirements, Shadow, SubpixelSprite, Underline, +}; +use gpui_render::TargetStack; +use gpui_render::blur::ScissorRectangle; +use gpui_render::blur::{FilterCompositeClip, FilterCompositeParameters}; +use gpui_render::damage::{Damage, batch_intersects, clear_quad}; +use gpui_render::shaders::{ + common::{FontRasterizationUniforms, GlobalUniforms, ShaderBool}, + interface as shader_interface, +}; + +pub(super) fn render_to_view( + renderer: &mut WgpuRenderer, + scene: &Scene, + frame_view: &wgpu::TextureView, + frame_texture: &wgpu::Texture, + readback: Option>, +) -> Option { + if !begin_frame(renderer) { + return None; + } + let atlas_generation = if renderer.retained.enabled() { + gpui::PlatformAtlas::generation(renderer.atlas.as_ref()) + } else { + 0 + }; + let mut retained = renderer.retained.prepare( + scene, + atlas_generation, + renderer.target.width(), + renderer.target.height(), + renderer.target.retained_copy_enabled(), + renderer.resources.as_ref()?, + renderer.target.format(), + renderer.target.alpha_mode(), + renderer.target.clear_color(), + ); + let transport = renderer.resources().instances.transport(); + let requirements = FrameRequirements::for_damage(scene, &retained.damage, transport); + let resources = renderer.resources_mut(); + if let Err(error) = resources + .pipelines + .prepare_smoothing(&resources.device, requirements.smoothing) + { + let message = format!("creating smoothing pipelines: {error}"); + if matches!( + error, + wgpu::Error::OutOfMemory { .. } | wgpu::Error::Internal { .. } + ) { + *renderer.faults.fatal_error.lock().unwrap() = Some(message.clone()); + } + // Let the next begin_frame consume this error. Handling it here and + // clearing the slot would reset the retry counter at the next frame. + *renderer.faults.pending_error.lock().unwrap() = Some(message); + renderer.retained.complete(retained, false); + renderer.target.request_redraw(); + return None; + } + let mut targets = PreparedTargets::prepare(renderer, scene, frame_view, &requirements)?; + // The retained texture matches the target by construction. The frame + // texture may not: it can predate a resize or lack COPY_DST. + let retained_copy = (retained.copy_texture.take()) + .filter(|source| { + targets.offscreen.is_none() + && frame_texture + .usage() + .contains(wgpu::TextureUsages::COPY_DST) + && frame_texture.size() == source.size() + && frame_texture.format() == source.format() + }) + .map(|source| (source, frame_texture.clone())); + if let Some(view) = &retained.view { + targets.active = view.clone(); + // Create the overwrite-clear PSO with the target's first full frame, so + // its first partial frame doesn't compile a pipeline on hover. + if retained.damage != Damage::Reuse { + let device = renderer.resources().device.clone(); + renderer + .resources_mut() + .pipelines + .ensure_damage_clear(&device); + } + } + // Prune once against the complete scene. Doing this inside each draw batch would evict a + // texture that reappears after an intervening primitive and recreate its platform view. + renderer.retain_surface_cache(&scene.surfaces); + + let retained_blit = retained.blit.take().filter(|_| retained_copy.is_none()); + #[cfg(test)] + { + renderer.last_retained_presentation = if retained_copy.is_some() { + "copy" + } else if retained_blit.is_some() { + "blit" + } else { + "direct" + }; + } + let result = FrameEncoder::new( + renderer, + scene, + targets, + retained.damage, + retained_blit, + retained_copy, + ) + .encode(readback); + renderer.retained.complete(retained, result.is_ok()); + match result { + Ok(command_buffer) => { + let submission = renderer.resources().queue.submit([command_buffer]); + // A cleanup skipped a frame. Only a real submission without a new validation + // error can reset that retry budget; merely starting the next frame cannot. + if renderer.faults.cleaned_up_failed_frames + && renderer.faults.pending_error.lock().unwrap().is_none() + { + renderer.faults.consecutive_failed_frames = 0; + renderer.faults.cleaned_up_failed_frames = false; + } + Some(submission) + } + Err(DrawError::ExternalSurface) => None, + Err(DrawError::CapacityPlanningInvariant) => { + log::error!("frame storage exceeded its precomputed capacity"); + None + } + Err(DrawError::MissingIntermediateTarget) => { + log::error!("frame preparation did not create a required intermediate target"); + None + } + } +} + +pub(super) struct ReadbackCopy<'a> { + pub(super) texture: &'a wgpu::Texture, + pub(super) buffer: &'a wgpu::Buffer, + pub(super) bytes_per_row: u32, + pub(super) width: u32, + pub(super) height: u32, +} + +struct PreparedTargets { + active: wgpu::TextureView, + presentation: wgpu::TextureView, + offscreen: Option, + instances: InstanceUpload, +} + +impl PreparedTargets { + fn prepare( + renderer: &mut WgpuRenderer, + scene: &Scene, + frame_view: &wgpu::TextureView, + requirements: &FrameRequirements, + ) -> Option { + let device = renderer.resources().device.clone(); + let resources = renderer.resources_mut(); + if !resources.instances.ensure_capacity( + &device, + &resources.bind_group_layouts, + requirements.storage_bytes, + requirements.instance_batches, + ) { + return None; + } + let instances = { + let resources = renderer.resources(); + resources.instances.begin_upload( + &resources.queue, + requirements.storage_bytes, + requirements.instance_batches, + )? + }; + if !renderer.ensure_uniform_capacity(requirements.uniforms) { + return None; + } + + if requirements.planned.uses_path_target { + renderer.ensure_path_textures(scene); + } + if requirements.planned.uses_offscreen_target { + renderer.ensure_filter_textures(&requirements.planned); + } + write_shader_globals(renderer); + + if requirements.planned.uses_offscreen_target { + let resources = renderer.resources(); + let offscreen = resources + .scene_color_view + .as_ref() + .expect("blur texture preparation must create a scene target") + .clone(); + Some(Self { + active: offscreen.clone(), + presentation: frame_view.clone(), + offscreen: Some(offscreen), + instances, + }) + } else { + Some(Self { + active: frame_view.clone(), + presentation: frame_view.clone(), + offscreen: None, + instances, + }) + } + } +} + +fn begin_frame(renderer: &mut WgpuRenderer) -> bool { + // Release the callback mutex before atlas uploads can report another GPU error. + let pending_error = renderer.faults.pending_error.lock().unwrap().take(); + let Some(error) = pending_error else { + if !renderer.faults.cleaned_up_failed_frames { + renderer.faults.consecutive_failed_frames = 0; + } + renderer.atlas.before_frame(); + return true; + }; + + handle_gpu_error(renderer, error) +} + +pub(super) fn handle_gpu_error(renderer: &mut WgpuRenderer, error: String) -> bool { + renderer.release_retained_frame(); + renderer.faults.consecutive_failed_frames += 1; + log::error!( + "GPU error during frame (failure {} of 10): {error}", + renderer.faults.consecutive_failed_frames + ); + if renderer.faults.consecutive_failed_frames > 10 { + if renderer.reports_terminal_errors() { + renderer.faults.terminal_error = Some(format!( + "too many consecutive GPU errors; last error: {error}" + )); + return false; + } + panic!("too many consecutive GPU errors; last error: {error}"); + } + if renderer.faults.consecutive_failed_frames > 5 && !renderer.faults.cleaned_up_failed_frames { + if let Some(resources) = renderer.resources.as_mut() { + resources.invalidate_intermediate_textures(); + } + renderer.atlas.clear(); + renderer.target.request_redraw(); + renderer.faults.cleaned_up_failed_frames = true; + if !renderer.reports_terminal_errors() { + // Preserve the existing retry policy for other platform integrations. + renderer.faults.consecutive_failed_frames = 0; + renderer.faults.cleaned_up_failed_frames = false; + } + return false; + } + + renderer.atlas.before_frame(); + true +} + +#[cfg(all(test, feature = "test-support", target_os = "windows"))] +mod failure_tests { + use super::*; + + #[test] + fn persistent_dx12_errors_become_terminal_and_a_healthy_frame_resets_the_count() { + let context = crate::WgpuContext::new_headless(None).unwrap(); + let mut renderer = WgpuRenderer::new_headless( + &context, + gpui::size(gpui::DevicePixels(16), gpui::DevicePixels(16)), + ) + .unwrap(); + for _ in 0..18 { + handle_gpu_error(&mut renderer, "injected validation failure".into()); + } + assert_eq!(renderer.faults.consecutive_failed_frames, 0); + assert!(renderer.terminal_error().is_none()); + renderer.native_backend = Some(crate::NativeBackend::Dx12); + for _ in 0..6 { + handle_gpu_error(&mut renderer, "injected validation failure".into()); + } + assert!(renderer.faults.cleaned_up_failed_frames); + assert!(begin_frame(&mut renderer)); + assert_eq!(renderer.faults.consecutive_failed_frames, 6); + let mut scene = Scene::default(); + scene.finish(); + renderer.render_to_image(&scene).unwrap(); + assert_eq!(renderer.faults.consecutive_failed_frames, 0); + for _ in 0..10 { + handle_gpu_error(&mut renderer, "injected validation failure".into()); + } + assert!(renderer.terminal_error().is_none()); + assert!(!handle_gpu_error( + &mut renderer, + "injected validation failure".into() + )); + assert!( + renderer + .terminal_error() + .unwrap() + .contains("validation failure") + ); + // The error can be consumed without holding a callback/GPU mutex across application notification. + assert!(renderer.faults.fatal_error.try_lock().is_ok()); + } +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) struct GlobalUniformState { + globals: GlobalUniforms, + path_globals: GlobalUniforms, + font_rasterization: FontRasterizationUniforms, +} + +fn write_shader_globals(renderer: &mut WgpuRenderer) { + let font = renderer.rendering_params.font_rasterization; + let font_rasterization = FontRasterizationUniforms { + gamma_ratios: wgsl_rs::std::vec4f( + font.gamma_ratios[0], + font.gamma_ratios[1], + font.gamma_ratios[2], + font.gamma_ratios[3], + ), + grayscale_enhanced_contrast: font.grayscale_enhanced_contrast, + subpixel_enhanced_contrast: font.subpixel_enhanced_contrast, + uses_blue_green_red_subpixel_order: ShaderBool::from( + renderer.subpixel_order == super::SubpixelOrder::BlueGreenRed, + ), + padding: 0, + }; + let globals = GlobalUniforms { + viewport_size: wgsl_rs::std::vec2f( + renderer.target.width() as f32, + renderer.target.height() as f32, + ), + premultiplied_alpha: ShaderBool::from( + renderer.target.alpha_mode() == wgpu::CompositeAlphaMode::PreMultiplied, + ), + padding: 0, + }; + let path_globals = GlobalUniforms { + viewport_size: renderer + .resources() + .path_intermediate_texture + .as_ref() + .map(|texture| wgsl_rs::std::vec2f(texture.width() as f32, texture.height() as f32)) + .unwrap_or(globals.viewport_size), + premultiplied_alpha: ShaderBool::Disabled, + ..globals + }; + let state = GlobalUniformState { + globals, + path_globals, + font_rasterization, + }; + if renderer.uploaded_globals == Some(state) { + return; + } + + let resources = renderer.resources(); + let globals_size = std::mem::size_of::(); + let font_size = std::mem::size_of::(); + let upload_size = renderer.globals.font_offset + font_size as u64; + let mut upload = resources + .queue + .write_buffer_with( + &resources.globals_buffer, + 0, + std::num::NonZeroU64::new(upload_size).expect("global uniforms are non-empty"), + ) + .expect("global uniform upload must fit its buffer"); + upload.slice(..).fill(0); + upload + .slice(..globals_size) + .copy_from_slice(shader_interface::bytes_of(&globals)); + upload + .slice( + renderer.globals.path_offset as usize + ..renderer.globals.path_offset as usize + globals_size, + ) + .copy_from_slice(shader_interface::bytes_of(&path_globals)); + upload + .slice( + renderer.globals.font_offset as usize + ..renderer.globals.font_offset as usize + font_size, + ) + .copy_from_slice(shader_interface::bytes_of(&font_rasterization)); + drop(upload); + renderer.uploaded_globals = Some(state); +} + +#[derive(Clone, Copy, Default)] +pub(super) struct FrameRequirements { + storage_bytes: u64, + /// Instance batches this frame; one downlevel range-uniform slot per batch. + instance_batches: u64, + pub(super) uniforms: FrameUniformRequirements, + planned: ScenePlanRequirements, + smoothing: SmoothingRequirements, +} + +impl FrameRequirements { + /// What a frame uploads: the whole scene, the batches a partial frame + /// repaints, or nothing when it reuses the retained frame. + fn for_damage(scene: &Scene, damage: &Damage, transport: InstanceTransport) -> Self { + match damage { + Damage::Full => Self::for_scene(scene, transport), + Damage::Partial(region) => Self::for_partial_scene(scene, *region, transport), + Damage::Reuse => Self::default(), + } + } + + pub(super) fn for_scene(scene: &Scene, transport: InstanceTransport) -> Self { + let planned = scene.render_plan().requirements(); + let mut requirements = Self::scene_metadata(scene); + + for command in scene.render_commands() { + if let RenderCommand::Batch(batch) = command { + requirements.reserve_batch(batch, transport); + } + requirements.include_filter_smoothing(scene, command); + } + debug_assert_eq!( + requirements.instance_batches as usize, + planned.instance_batch_count + ); + + requirements + } + + fn scene_metadata(scene: &Scene) -> Self { + let planned = scene.render_plan().requirements(); + Self { + uniforms: FrameUniformRequirements { + filter_count: FILTER_UNIFORMS_PER_COMPOSITE + * (planned.backdrop_filter_count + planned.isolated_filter_count) as u64 + + u64::from(planned.uses_offscreen_target) + + if planned.isolated_target_count > 0 { + (planned.backdrop_filter_count * (planned.isolated_target_count + 1)) as u64 + } else { + 0 + }, + surface_count: planned.surface_count as u64, + }, + planned: *planned, + ..Default::default() + } + } + + fn for_partial_scene( + scene: &Scene, + region: ScissorRectangle, + transport: InstanceTransport, + ) -> Self { + let mut requirements = Self::scene_metadata(scene); + // Encoding prepends a clear and skips nonintersecting batches. Either + // changes the alignment of every following type, so a full-frame + // estimate can't be reused. + requirements.reserve(std::mem::size_of::(), 1, transport); + for command in scene.render_commands() { + if let RenderCommand::Batch(batch) = command { + if !batch_intersects(scene, batch, region) { + continue; + } + requirements.reserve_batch(batch, transport); + } + requirements.include_filter_smoothing(scene, command); + } + requirements + } + + fn include_filter_smoothing(&mut self, scene: &Scene, command: &RenderCommand) { + if self.smoothing.blur_composite { + return; + } + self.smoothing.blur_composite = match command { + RenderCommand::Batch(PrimitiveBatch::BackdropFilters(range)) => scene.backdrop_filters + [range.clone()] + .iter() + .any(|filter| filter.corner_smoothing > 0.0 && filter.max_blur_radius() > 0.0), + RenderCommand::EndFilter { + boundary_index, + target: FilterRenderTarget::Isolated(_), + .. + } => { + let boundary = &scene.filter_boundaries[*boundary_index]; + // Isolated groups composite even without blur (for example, opacity + // groups), and select the smoothing variant from this value alone. + boundary.corner_smoothing > 0.0 + } + _ => false, + }; + } + + fn reserve(&mut self, element_size: usize, count: usize, transport: InstanceTransport) { + if count > 0 { + let stride = element_size as u64; + self.storage_bytes = self + .storage_bytes + .next_multiple_of(transport.batch_alignment(stride)); + self.storage_bytes = self + .storage_bytes + .saturating_add(stride.saturating_mul(count as u64)); + self.instance_batches += 1; + } + } + + fn reserve_batch(&mut self, batch: &PrimitiveBatch, transport: InstanceTransport) { + match batch { + PrimitiveBatch::Shadows { range, smoothed } => { + self.smoothing.shadows |= *smoothed && !range.is_empty(); + self.reserve(std::mem::size_of::(), range.len(), transport) + } + PrimitiveBatch::Quads { range, smoothed } => { + self.smoothing.quads |= *smoothed && !range.is_empty(); + self.reserve(std::mem::size_of::(), range.len(), transport) + } + PrimitiveBatch::Paths { + rasterization_vertex_count, + sprite_count, + .. + } if *rasterization_vertex_count > 0 => { + self.reserve( + std::mem::size_of::(), + *rasterization_vertex_count, + transport, + ); + self.reserve( + std::mem::size_of::(), + *sprite_count, + transport, + ); + } + PrimitiveBatch::Underlines(range) => { + self.reserve(std::mem::size_of::(), range.len(), transport) + } + PrimitiveBatch::MonochromeSprites { range, .. } => self.reserve( + std::mem::size_of::(), + range.len(), + transport, + ), + PrimitiveBatch::SubpixelSprites { range, .. } => self.reserve( + std::mem::size_of::(), + range.len(), + transport, + ), + PrimitiveBatch::PolychromeSprites { + range, smoothed, .. + } => { + self.smoothing.polychrome_sprites |= *smoothed && !range.is_empty(); + self.reserve( + std::mem::size_of::(), + range.len(), + transport, + ) + } + PrimitiveBatch::Paths { .. } + | PrimitiveBatch::Surfaces(_) + | PrimitiveBatch::BackdropFilters(_) + | PrimitiveBatch::FilterBoundary(_) => {} + } + } +} + +#[cfg(test)] +mod planning_tests { + use super::*; + + #[test] + fn partial_alignment_includes_the_clear() { + let transport = InstanceTransport::StorageBuffer; + let sequence = [ + (std::mem::size_of::(), 1), + (std::mem::size_of::(), 1), + (std::mem::size_of::(), 1), + (std::mem::size_of::(), 1), + ( + std::mem::size_of::(), + 3, + ), + (std::mem::size_of::(), 1), + (std::mem::size_of::(), 1), + (std::mem::size_of::(), 1), + ]; + let mut full = FrameRequirements::default(); + for &(stride, count) in &sequence { + full.reserve(stride, count, transport); + } + assert_eq!(full.storage_bytes, 1984); + let old_estimate = full.storage_bytes + 2 * std::mem::size_of::() as u64; + assert_eq!(old_estimate, 2480); + let mut partial = FrameRequirements::default(); + partial.reserve(std::mem::size_of::(), 1, transport); + for &(stride, count) in &sequence { + partial.reserve(stride, count, transport); + } + assert_eq!(partial.storage_bytes, 2728); + assert_eq!(partial.instance_batches, 9); + assert!(partial.storage_bytes > old_estimate); + } + + #[test] + fn partial_planner_counts_only_intersecting_batches_and_clear_slots() { + use gpui::{Bounds, ContentMask, ScaledPixels, point, size}; + let bounds = |x, y| { + Bounds::new( + point(ScaledPixels(x), ScaledPixels(y)), + size(ScaledPixels(10.0), ScaledPixels(10.0)), + ) + }; + let mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(100.0), ScaledPixels(100.0)), + ), + ..Default::default() + }; + let mut scene = Scene::default(); + for (index, (x, y)) in [(0.0, 0.0), (20.0, 20.0)].into_iter().enumerate() { + scene.insert_primitive(Quad { + bounds: bounds(x, y), + content_mask: mask, + corner_smoothing: if index == 0 { 0.0 } else { 0.65 }, + ..Default::default() + }); + } + scene.finish(); + let region = |x, y| ScissorRectangle { + x, + y, + width: 5, + height: 5, + }; + for transport in [ + InstanceTransport::StorageBuffer, + InstanceTransport::DataTexture, + ] { + // The clear, then only the batch under the region: the second quad's. + let requirements = + FrameRequirements::for_partial_scene(&scene, region(24, 24), transport); + let mut expected = FrameRequirements::default(); + expected.reserve(std::mem::size_of::(), 1, transport); + expected.reserve(std::mem::size_of::(), 1, transport); + assert_eq!(requirements.instance_batches, 2); + assert_eq!(requirements.storage_bytes, expected.storage_bytes); + assert!(!requirements.planned.uses_path_target); + assert!(!requirements.planned.uses_offscreen_target); + assert!(requirements.smoothing.quads); + assert_eq!( + FrameRequirements::for_partial_scene(&scene, region(2, 2), transport).smoothing, + SmoothingRequirements::default(), + "an ordinary batch must not prepare the distant smoothing pipeline" + ); + // A region over neither quad replays only the clear. + let empty = FrameRequirements::for_partial_scene(&scene, region(60, 60), transport); + assert_eq!(empty.instance_batches, 1); + assert_eq!(empty.smoothing, SmoothingRequirements::default()); + // A frame uploads once, sized by its damage; a reused frame uploads nothing. + let partial = + FrameRequirements::for_damage(&scene, &Damage::Partial(region(24, 24)), transport); + assert_eq!(partial.storage_bytes, requirements.storage_bytes); + let reuse = FrameRequirements::for_damage(&scene, &Damage::Reuse, transport); + assert_eq!((reuse.storage_bytes, reuse.instance_batches), (0, 0)); + assert_eq!(reuse.smoothing, SmoothingRequirements::default()); + } + } + + #[test] + fn smoothing_planner_includes_backdrop_and_isolated_filter_composites() { + use gpui::{ + BackdropFilter, Bounds, ContentMask, Corners, FilterBoundary, ScaledFilter, + ScaledPixels, point, size, + }; + let bounds = Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(40.0), ScaledPixels(40.0)), + ); + let content_mask = ContentMask { + bounds, + ..Default::default() + }; + for isolated in [false, true] { + for (smoothing, radius, expected) in + [(0.0, 4.0, false), (0.7, 0.0, isolated), (0.7, 4.0, true)] + { + let mut scene = Scene::default(); + let filters = smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(radius))]; + if isolated { + let boundary = FilterBoundary { + order: 0, + bounds, + content_mask, + corner_radii: Corners::all(ScaledPixels(8.0)), + corner_smoothing: smoothing, + filters, + opacity: 1.0, + is_start: true, + }; + scene.insert_primitive(boundary.clone()); + scene.raise_order_floor(); + scene.insert_primitive(Quad { + bounds, + content_mask, + background: gpui::white().into(), + ..Default::default() + }); + scene.raise_order_floor(); + scene.insert_primitive(FilterBoundary { + is_start: false, + ..boundary + }); + } else { + scene.insert_primitive(BackdropFilter { + order: 0, + bounds, + content_mask, + corner_radii: Corners::all(ScaledPixels(8.0)), + corner_smoothing: smoothing, + filters, + opacity: 1.0, + }); + } + scene.finish(); + let required = + FrameRequirements::for_scene(&scene, InstanceTransport::StorageBuffer); + assert_eq!( + required.smoothing.blur_composite, expected, + "isolated={isolated}, smoothing={smoothing}, radius={radius}" + ); + assert!(!required.smoothing.quads); + assert_eq!( + FrameRequirements::for_damage( + &scene, + &Damage::Reuse, + InstanceTransport::StorageBuffer + ) + .smoothing, + SmoothingRequirements::default() + ); + } + } + } +} + +struct FrameEncoder<'a> { + renderer: &'a WgpuRenderer, + scene: &'a Scene, + encoder: wgpu::CommandEncoder, + targets: TargetStack, + offscreen: Option, + presentation: wgpu::TextureView, + instances: InstanceUpload, + damage: Damage, + retained_blit: Option, + retained_copy: Option<(wgpu::Texture, wgpu::Texture)>, +} + +impl<'a> FrameEncoder<'a> { + fn new( + renderer: &'a WgpuRenderer, + scene: &'a Scene, + targets: PreparedTargets, + damage: Damage, + retained_blit: Option, + retained_copy: Option<(wgpu::Texture, wgpu::Texture)>, + ) -> Self { + let encoder = + renderer + .resources() + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpui_frame"), + }); + Self { + renderer, + scene, + encoder, + targets: TargetStack::new(targets.active), + offscreen: targets.offscreen, + presentation: targets.presentation, + instances: targets.instances, + damage, + retained_blit, + retained_copy, + } + } + + fn encode( + mut self, + readback: Option>, + ) -> Result { + let result = self.encode_commands(); + if result.is_ok() { + if let Some((source, destination)) = &self.retained_copy { + self.encoder.copy_texture_to_texture( + source.as_image_copy(), + destination.as_image_copy(), + wgpu::Extent3d { + width: self.renderer.target.width(), + height: self.renderer.target.height(), + depth_or_array_layers: 1, + }, + ); + } + if let Some(bind_group) = &self.retained_blit { + self.renderer + .draw_blit(&mut self.encoder, bind_group, 0, &self.presentation); + } + if let Some(offscreen) = &self.offscreen { + self.renderer + .blit_to_frame(&mut self.encoder, offscreen, &self.presentation); + } + if let Some(readback) = readback { + self.encoder.copy_texture_to_buffer( + readback.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: readback.buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(readback.bytes_per_row), + rows_per_image: Some(readback.height), + }, + }, + wgpu::Extent3d { + width: readback.width, + height: readback.height, + depth_or_array_layers: 1, + }, + ); + } + } + self.instances.finish(&mut self.encoder); + self.renderer.resources().finish_frame_uploads(); + let command_buffer = self.encoder.finish(); + result.map(|()| command_buffer) + } + + fn encode_commands(&mut self) -> DrawResult { + match std::mem::replace(&mut self.damage, Damage::Reuse) { + Damage::Reuse => Ok(()), + Damage::Partial(region) => self.encode_region(Some(region)), + Damage::Full => self.encode_region(None), + } + } + fn encode_region(&mut self, region: Option) -> DrawResult { + let mut pass = begin_scene_render_pass( + self.renderer, + &mut self.encoder, + "main_pass", + self.targets.current(), + if region.is_some() { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(self.renderer.target.clear_color()) + }, + region, + ); + if let Some(region) = region { + let clear = self.renderer.target.clear_color(); + let clear = [clear.r, clear.g, clear.b, clear.a].map(|channel| channel as f32); + self.renderer.draw_damage_clear( + &[clear_quad(region, clear)], + &mut self.instances, + &mut pass, + )?; + } + + for command in self.scene.render_commands() { + if let (Some(region), RenderCommand::Batch(batch)) = (region, command) { + if !batch_intersects(self.scene, batch, region) { + continue; + } + } + match command { + RenderCommand::Batch(PrimitiveBatch::Paths { + range, + rasterization_vertex_count, + .. + }) => { + if *rasterization_vertex_count == 0 { + continue; + } + let paths = &self.scene.paths[range.clone()]; + drop(pass); + let rasterized = self.renderer.draw_paths_to_intermediate( + &mut self.encoder, + paths, + &mut self.instances, + ); + pass = begin_scene_render_pass( + self.renderer, + &mut self.encoder, + "after_paths", + self.targets.current(), + wgpu::LoadOp::Load, + region, + ); + rasterized?; + self.renderer.draw_paths_from_intermediate( + paths, + &mut self.instances, + &mut pass, + )?; + } + RenderCommand::Batch(PrimitiveBatch::BackdropFilters(range)) => { + drop(pass); + for filter in &self.scene.backdrop_filters[range.clone()] { + let snapshot = self.targets.inherits_backdrop().then(|| { + self.renderer.snapshot_backdrop( + &mut self.encoder, + self.targets.backdrop_layers(), + ) + }); + let source = snapshot.as_ref().unwrap_or_else(|| self.targets.current()); + self.renderer.blur_and_composite( + &mut self.encoder, + source, + self.targets.current(), + FilterCompositeParameters { + bounds: filter.bounds, + content_mask: filter.content_mask.bounds, + corner_radii: filter.corner_radii, + corner_smoothing: filter.corner_smoothing, + blur_radius: filter.max_blur_radius(), + opacity: filter.opacity, + clip: FilterCompositeClip::RoundedBounds, + }, + ); + } + pass = begin_scene_render_pass( + self.renderer, + &mut self.encoder, + "after_backdrop_filter", + self.targets.current(), + wgpu::LoadOp::Load, + region, + ); + } + RenderCommand::Batch(PrimitiveBatch::FilterBoundary(_)) => { + unreachable!("filter boundaries must be compiled into render commands") + } + RenderCommand::Batch(batch) => encode_inline_batch( + self.renderer, + self.scene, + batch, + &mut self.instances, + &mut pass, + )?, + RenderCommand::BeginFilter { + boundary_index, + target: FilterRenderTarget::Isolated(index), + } => { + drop(pass); + let target = + self.renderer.resources().filter_group_views[index.as_usize()].clone(); + self.targets.enter( + target, + self.scene.filter_boundaries[*boundary_index] + .filters + .is_empty(), + ); + pass = begin_scene_render_pass( + self.renderer, + &mut self.encoder, + "filter_group", + self.targets.current(), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + region, + ); + } + RenderCommand::EndFilter { + boundary_index, + target: FilterRenderTarget::Isolated(_), + .. + } => { + drop(pass); + let (filtered, parent) = self.targets.exit(); + let boundary = &self.scene.filter_boundaries[*boundary_index]; + self.renderer.blur_and_composite( + &mut self.encoder, + &filtered, + parent, + FilterCompositeParameters { + bounds: boundary.bounds, + content_mask: boundary.content_mask.bounds, + corner_radii: boundary.corner_radii, + corner_smoothing: boundary.corner_smoothing, + blur_radius: boundary.max_blur_radius(), + opacity: boundary.opacity, + clip: FilterCompositeClip::ContentShape, + }, + ); + pass = begin_scene_render_pass( + self.renderer, + &mut self.encoder, + "after_content_filter", + self.targets.current(), + wgpu::LoadOp::Load, + region, + ); + } + RenderCommand::BeginFilter { + target: FilterRenderTarget::Inline, + .. + } + | RenderCommand::EndFilter { + target: FilterRenderTarget::Inline, + .. + } => {} + } + } + drop(pass); + self.targets.assert_balanced(); + Ok(()) + } +} + +fn begin_scene_render_pass<'a>( + renderer: &'a WgpuRenderer, + encoder: &'a mut wgpu::CommandEncoder, + label: &'a str, + target: &'a wgpu::TextureView, + load: wgpu::LoadOp, + region: Option, +) -> wgpu::RenderPass<'a> { + let mut pass = begin_color_render_pass(encoder, label, target, load); + if let Some(region) = region { + pass.set_scissor_rect(region.x, region.y, region.width, region.height); + } + pass.set_bind_group( + shader_interface::GLOBAL_BIND_GROUP, + &renderer.resources().globals_bind_group, + &[], + ); + pass +} + +#[derive(Debug)] +pub(super) enum DrawError { + CapacityPlanningInvariant, + ExternalSurface, + MissingIntermediateTarget, +} + +pub(super) type DrawResult = Result<(), DrawError>; + +fn encode_inline_batch( + renderer: &WgpuRenderer, + scene: &Scene, + batch: &PrimitiveBatch, + instances: &mut InstanceUpload, + pass: &mut wgpu::RenderPass<'_>, +) -> DrawResult { + match batch { + PrimitiveBatch::Quads { range, smoothed } => { + renderer.draw_quads(&scene.quads[range.clone()], *smoothed, instances, pass) + } + PrimitiveBatch::Shadows { range, smoothed } => { + renderer.draw_shadows(&scene.shadows[range.clone()], *smoothed, instances, pass) + } + PrimitiveBatch::Underlines(range) => { + renderer.draw_underlines(&scene.underlines[range.clone()], instances, pass) + } + PrimitiveBatch::MonochromeSprites { texture_id, range } => renderer + .draw_monochrome_sprites( + &scene.monochrome_sprites[range.clone()], + *texture_id, + instances, + pass, + ), + PrimitiveBatch::SubpixelSprites { texture_id, range } => renderer.draw_subpixel_sprites( + &scene.subpixel_sprites[range.clone()], + *texture_id, + instances, + pass, + ), + PrimitiveBatch::PolychromeSprites { + texture_id, + range, + smoothed, + } => renderer.draw_polychrome_sprites( + &scene.polychrome_sprites[range.clone()], + *texture_id, + *smoothed, + instances, + pass, + ), + PrimitiveBatch::Surfaces(range) => { + renderer.draw_surfaces(&scene.surfaces[range.clone()], pass) + } + PrimitiveBatch::Paths { .. } + | PrimitiveBatch::BackdropFilters(_) + | PrimitiveBatch::FilterBoundary(_) => { + unreachable!("pass-interrupting batches are handled by FrameEncoder") + } + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs b/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs new file mode 100644 index 000000000..fdb26bd27 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs @@ -0,0 +1,2203 @@ +use std::sync::Arc; + +use gpui::{DevicePixels, Scene, Size}; + +use crate::{WgpuAtlas, WgpuContext}; + +use super::{WgpuRenderer, WgpuSurfaceConfig}; + +struct OffscreenTarget { + texture: wgpu::Texture, + view: wgpu::TextureView, + readback: wgpu::Buffer, + padded_bytes_per_row: u32, + size: Size, +} + +impl WgpuRenderer { + pub(super) fn new_headless( + context: &WgpuContext, + size: Size, + ) -> anyhow::Result { + let mut renderer = Self::new_internal( + None, + context, + None, + WgpuSurfaceConfig { + size, + transparent: false, + preferred_present_mode: None, + }, + None, + None, + Arc::new(WgpuAtlas::from_context(context)), + )?; + + renderer.target.clear_color = wgpu::Color::BLACK; + + Ok(renderer) + } + + fn create_offscreen_target(&self) -> OffscreenTarget { + let width = self.target.width(); + let height = self.target.height(); + let padded_bytes_per_row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let texture = self + .resources() + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("gpui_offscreen_target"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.target.format(), + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | if self.target.retained_copy_enabled() { + wgpu::TextureUsages::COPY_DST + } else { + wgpu::TextureUsages::empty() + }, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let readback = self + .resources() + .device + .create_buffer(&wgpu::BufferDescriptor { + label: Some("gpui_offscreen_readback"), + size: u64::from(padded_bytes_per_row) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + OffscreenTarget { + texture, + view, + readback, + padded_bytes_per_row, + size: self.viewport_size(), + } + } + + fn read_offscreen_target( + &self, + target: &OffscreenTarget, + submission: wgpu::SubmissionIndex, + ) -> anyhow::Result { + let width = target.size.width.0 as u32; + let height = target.size.height.0 as u32; + let bytes_per_row = width * 4; + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + target + .readback + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + self.resources() + .device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| anyhow::anyhow!("failed to poll offscreen readback: {error}"))?; + receiver + .recv() + .map_err(|error| anyhow::anyhow!("offscreen readback callback dropped: {error}"))? + .map_err(|error| anyhow::anyhow!("failed to map offscreen readback: {error}"))?; + + let mapped = target.readback.slice(..).get_mapped_range(); + let mut pixels = Vec::with_capacity(bytes_per_row as usize * height as usize); + for row in mapped.chunks_exact(target.padded_bytes_per_row as usize) { + pixels.extend_from_slice(&row[..bytes_per_row as usize]); + } + drop(mapped); + target.readback.unmap(); + if self.target.format() == wgpu::TextureFormat::Bgra8Unorm { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + image::RgbaImage::from_raw(width, height, pixels) + .ok_or_else(|| anyhow::anyhow!("offscreen readback dimensions did not match its data")) + } + + /// Renders through the normal scene path and reads back without presenting. + pub fn render_to_image(&mut self, scene: &Scene) -> anyhow::Result { + let target = self.create_offscreen_target(); + let submission = self + .render_to_view_with_readback(scene, &target.view, target.readback_copy()) + .ok_or_else(|| anyhow::anyhow!("failed to render scene into the offscreen target"))?; + self.read_offscreen_target(&target, submission) + } +} + +impl OffscreenTarget { + fn readback_copy(&self) -> super::frame::ReadbackCopy<'_> { + super::frame::ReadbackCopy { + texture: &self.texture, + buffer: &self.readback, + bytes_per_row: self.padded_bytes_per_row, + width: self.size.width.0 as u32, + height: self.size.height.0 as u32, + } + } +} + +/// Surface-free renderer used by GPUI visual tests and benchmarks. +pub struct WgpuHeadlessRenderer { + renderer: WgpuRenderer, + target: Option, +} + +impl WgpuHeadlessRenderer { + pub fn new() -> anyhow::Result { + let context = WgpuContext::new_headless(None)?; + let renderer = WgpuRenderer::new_headless( + &context, + Size { + width: DevicePixels(1), + height: DevicePixels(1), + }, + )?; + Ok(Self { + renderer, + target: None, + }) + } + + fn ensure_target(&mut self, size: Size) -> anyhow::Result<()> { + anyhow::ensure!( + size.width.0 > 0 && size.height.0 > 0, + "headless render target must have positive dimensions" + ); + if self + .target + .as_ref() + .is_some_and(|target| target.size == size) + { + return Ok(()); + } + self.renderer.update_drawable_size(size); + self.target = Some(self.renderer.create_offscreen_target()); + Ok(()) + } + + /// Renders through the normal submission path and waits for that work to finish. + pub fn render_scene_and_wait( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result<()> { + self.ensure_target(size)?; + let target = self.target.as_ref().expect("target was just ensured"); + let submission = super::frame::render_to_view( + &mut self.renderer, + scene, + &target.view, + &target.texture, + None, + ) + .ok_or_else(|| anyhow::anyhow!("failed to render headless scene"))?; + self.renderer + .resources() + .device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + .map_err(|error| anyhow::anyhow!("failed to wait for headless render: {error}"))?; + Ok(()) + } +} + +impl gpui::PlatformHeadlessRenderer for WgpuHeadlessRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.ensure_target(size)?; + let target = self.target.as_ref().expect("target was just ensured"); + let submission = self + .renderer + .render_to_view_with_readback(scene, &target.view, target.readback_copy()) + .ok_or_else(|| anyhow::anyhow!("failed to render headless scene"))?; + self.renderer.read_offscreen_target(target, submission) + } + + fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.ensure_target(size)?; + let target = self.target.as_ref().expect("target was just ensured"); + anyhow::ensure!( + self.renderer + .render_to_texture(scene, &target.texture, &target.view), + "failed to render headless scene" + ); + Ok(()) + } + + fn sprite_atlas(&self) -> Arc { + self.renderer.sprite_atlas().clone() + } +} + +#[cfg(test)] +mod path_target_tests { + use super::*; + use gpui::{ + AtlasKey, AtlasTile, Bounds, ContentMask, FontId, GlyphId, GlyphRenderMode, + MonochromeSprite, PathBuilder, PlatformAtlas, PreparedRasterStyle, Quad, + RasterizedGlyphFormat, RenderGlyphParams, ScaledPixels, Shadow, point, px, size, + solid_background, + }; + use std::borrow::Cow; + + fn scaled(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new( + point(ScaledPixels(x), ScaledPixels(y)), + size(ScaledPixels(width), ScaledPixels(height)), + ) + } + fn full_mask() -> ContentMask { + ContentMask { + bounds: scaled(0.0, 0.0, 200.0, 100.0), + ..Default::default() + } + } + + /// A clipped quad covers the same pixels as a straight underline, which + /// only fragment masking clips. Both follow a rasterized rectangle's rule + /// at the mask: a pixel centered on its left or top edge is inside, on its + /// right or bottom edge outside. + #[test] + fn clipped_quads_and_underlines_share_mask_edge_pixels() -> anyhow::Result<()> { + fn render( + renderer: &mut WgpuRenderer, + primitive: gpui::Primitive, + ) -> anyhow::Result { + let mut scene = Scene::default(); + scene.insert_primitive(primitive); + scene.finish(); + renderer.render_to_image(&scene) + } + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let mut renderer = + WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; + renderer.retained.disable_for_tests(); + let geometry = scaled(4.0, 4.0, 56.0, 56.0); + // Every edge runs through pixel centers, at 8.5 and 24.5. + let mask = ContentMask { + bounds: scaled(8.5, 8.5, 16.0, 16.0), + ..Default::default() + }; + let quad = Quad { + bounds: geometry, + content_mask: mask, + background: solid_background(gpui::white()), + ..Default::default() + }; + let underline = gpui::Underline { + order: 0, + padding: 0, + bounds: geometry, + content_mask: mask, + color: gpui::white().into(), + thickness: ScaledPixels(1.0), + wavy: Default::default(), + }; + let quad = render(&mut renderer, quad.into())?; + let underline = render(&mut renderer, underline.into())?; + let (white, black) = ( + image::Rgba([255, 255, 255, 255]), + image::Rgba([0, 0, 0, 255]), + ); + for (what, image) in [("quad", &quad), ("underline", &underline)] { + assert_eq!(*image.get_pixel(8, 12), white, "{what}: left edge"); + assert_eq!(*image.get_pixel(12, 8), white, "{what}: top edge"); + assert_eq!(*image.get_pixel(24, 12), black, "{what}: right edge"); + assert_eq!(*image.get_pixel(12, 24), black, "{what}: bottom edge"); + } + assert!( + quad == underline, + "the quad and the underline cover different pixels" + ); + Ok(()) + } + + /// Vertex clipping to the content mask must draw exactly what fragment + /// masking alone draws: every pixel centered inside the half-open mask + /// (its left and top edges included, right and bottom excluded) matches + /// the unmasked frame, and every other pixel is untouched. Mask edges land + /// on pixel centers whenever an odd logical coordinate is scaled by 1.5. + #[test] + fn vertex_clipping_matches_fragment_masking_on_pixel_center_edges() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let mut renderer = + WgpuRenderer::new_headless(&context, size(DevicePixels(200), DevicePixels(100)))?; + renderer.retained.disable_for_tests(); + let element = scaled(40.0, 30.0, 100.0, 40.0); + let radii = gpui::Corners::all(ScaledPixels(8.0)); + let scene = |kind: usize, content_mask: Option>| { + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds: scaled(0.0, 0.0, 200.0, 100.0), + content_mask: full_mask(), + background: solid_background(gpui::rgb(0x18212b)), + ..Default::default() + }); + if let Some(content_mask) = content_mask { + let shadow = Shadow { + order: 0, + blur_radius: ScaledPixels(6.0), + bounds: element, + corner_radii: radii, + content_mask, + color: gpui::hsla(0.6, 0.5, 0.1, 0.7).into(), + element_bounds: element, + element_corner_radii: radii, + inset: Default::default(), + corner_smoothing: 0.0, + }; + match kind { + 0 => scene.insert_primitive(Quad { + bounds: scaled(4.0, 3.0, 190.0, 92.0), + content_mask, + background: solid_background(gpui::hsla(0.33, 0.7, 0.55, 0.8)), + corner_radii: radii, + ..Default::default() + }), + 1 => scene.insert_primitive(shadow), + 2 => scene.insert_primitive(Shadow { + blur_radius: ScaledPixels(0.0), + ..shadow + }), + // A 1px inset ring, as interaction outlines paint. + 3 => scene.insert_primitive(Shadow { + blur_radius: ScaledPixels(0.0), + bounds: scaled(41.0, 31.0, 98.0, 38.0), + inset: true.into(), + ..shadow + }), + _ => scene.insert_primitive(Shadow { + blur_radius: ScaledPixels(3.0), + bounds: scaled(44.0, 34.0, 92.0, 32.0), + inset: true.into(), + ..shadow + }), + } + } + scene.finish(); + scene + }; + let background = renderer.render_to_image(&scene(0, None))?; + let mut failures = Vec::new(); + for kind in 0..5 { + let full = renderer.render_to_image(&scene(kind, Some(full_mask())))?; + // Both frames share the shader, so check its blur reaches past the + // shadow's bounds, 3px left of the element here. + if kind == 1 { + assert_ne!(full.get_pixel(37, 50), background.get_pixel(37, 50)); + } + for mask in [ + scaled(10.5, 10.5, 100.0, 50.0), + scaled(52.5, 37.5, 30.0, 21.0), + scaled(36.0, 26.0, 40.5, 50.5), + scaled(37.25, 27.75, 60.0, 30.0), + scaled(250.0, 140.0, 1.0, 1.0), + ] { + let masked = renderer.render_to_image(&scene( + kind, + Some(ContentMask { + bounds: mask, + ..Default::default() + }), + ))?; + let (left, top) = (mask.origin.x.0, mask.origin.y.0); + let (right, bottom) = (left + mask.size.width.0, top + mask.size.height.0); + let difference = masked.enumerate_pixels().find(|(x, y, pixel)| { + let (cx, cy) = (*x as f32 + 0.5, *y as f32 + 0.5); + let inside = cx >= left && cx < right && cy >= top && cy < bottom; + if inside { + *pixel != full.get_pixel(*x, *y) + } else { + *pixel != background.get_pixel(*x, *y) + } + }); + if let Some((x, y, _)) = difference { + failures.push(format!("kind={kind} mask={mask:?} first pixel=({x}, {y})")); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); + Ok(()) + } + + fn triangle( + x: f32, + y: f32, + edge: f32, + mask: ContentMask, + ) -> gpui::Path { + let mut builder = PathBuilder::fill(); + builder.move_to(point(px(x), px(y))); + builder.line_to(point(px(x + edge), px(y))); + builder.line_to(point(px(x), px(y + edge))); + builder.close(); + let mut path = builder.build().unwrap().scale(1.0); + path.content_mask = mask; + path.color = gpui::linear_gradient( + 90.0, + gpui::linear_color_stop(gpui::rgb(0xff0000), 0.0), + gpui::linear_color_stop(gpui::rgb(0x00ff00), 1.0), + ); + path + } + + fn hover_scene(row: usize, resting_fills: bool) -> Scene { + let mut scene = Scene::default(); + // The headless target clears to black. Leave uncovered pixels around each + // translucent row so a wrong retained clear color is observable. + for index in 0..3 { + if !resting_fills && index != row { + continue; + } + scene.insert_primitive(Quad { + bounds: scaled(4.0, 10.0 + index as f32 * 25.0, 192.0, 14.0), + content_mask: full_mask(), + background: solid_background(if row == index { + gpui::hsla(0.6, 0.4, 0.4, 0.7) + } else { + gpui::hsla(0.6, 0.1, 0.15, 0.7) + }), + ..Default::default() + }); + } + let mut graph = triangle( + 20.0, + 8.0, + 18.0, + ContentMask { + bounds: scaled(20.0, 10.0, 18.0, 14.0), + ..Default::default() + }, + ); + graph.color = solid_background(if row == 0 { + gpui::hsla(0.6, 0.4, 0.4, 0.7) + } else { + gpui::black() + }); + scene.insert_primitive(graph); + scene.insert_primitive(Quad { + bounds: scaled(80.0, 5.0, 15.0, 70.0), + content_mask: ContentMask { + bounds: scaled(82.0, 0.0, 10.0, 100.0), + ..Default::default() + }, + background: solid_background(gpui::hsla(0.02, 0.8, 0.6, 0.4)), + corner_radii: gpui::Corners::all(ScaledPixels(4.0)), + ..Default::default() + }); + scene.finish(); + scene + } + + #[test] + fn retained_hover_matches_full_pixels_for_rows_graph_clip_and_invalidation() + -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + for resting_fills in [true, false] { + let target = size(DevicePixels(200), DevicePixels(400)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + let mut retained = WgpuRenderer::new_headless(&context, target)?; + retained.retained.enable_for_tests(); + let mut revision = 0; + let mut previous = None; + for row in [0, 0, 1, 2, 0] { + if previous != Some(row) { + revision += 1; + } + let scene = hover_scene(row, resting_fills); + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); + let expected = full.render_to_image(&scene)?; + let actual = retained.render_to_image(&scene)?; + assert_eq!( + actual, expected, + "retained pixels differ for row {row}, revision {revision}, resting fills {resting_fills}" + ); + assert_eq!( + retained.retained.last_damage(), + if previous.is_none() { + "full" + } else if previous == Some(row) { + "reuse" + } else { + "partial" + }, + "damage selection for row {row}, previous {previous:?}, revision {revision}, resting fills {resting_fills}" + ); + previous = Some(row); + } + let scene = hover_scene(0, resting_fills); + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); + retained.atlas.clear(); + assert_eq!( + retained.render_to_image(&scene)?, + full.render_to_image(&scene)? + ); + assert_eq!( + retained.retained.last_damage(), + "full", + "atlas invalidates unchanged scene revision" + ); + let target = size(DevicePixels(190), DevicePixels(100)); + retained.update_drawable_size(target); + full.update_drawable_size(target); + assert_eq!( + retained.render_to_image(&scene)?, + full.render_to_image(&scene)? + ); + assert_eq!( + retained.retained.last_damage(), + "full", + "resize invalidates unchanged scene revision" + ); + retained.update_transparency(true); + full.update_transparency(true); + assert_eq!( + retained.render_to_image(&scene)?, + full.render_to_image(&scene)? + ); + assert_eq!( + retained.retained.last_damage(), + "full", + "appearance transition invalidates the previous opaque target" + ); + assert_eq!( + retained.render_to_image(&scene)?, + full.render_to_image(&scene)? + ); + assert_eq!(retained.retained.last_damage(), "reuse"); + } + Ok(()) + } + + #[test] + fn retained_regrouped_coincident_paths_match_full_pixels() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let target = size(DevicePixels(512), DevicePixels(512)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + let mask = ContentMask { + bounds: scaled(0.0, 0.0, 512.0, 512.0), + ..Default::default() + }; + // A quad in a tile of its own splits four paths into two batches. + // Coincident paths in one batch share MSAA samples; in two batches + // they composite twice, so regrouping them changes their edges. + let scene = |paths: [gpui::Path; 4]| { + let mut scene = Scene::default(); + // A layer each gives every primitive its own draw order. + let mut paint = |primitive: gpui::Primitive| { + scene.push_layer(mask.bounds); + scene.insert_primitive(primitive); + scene.pop_layer(); + }; + for (index, path) in paths.into_iter().enumerate() { + if index == 2 { + paint( + Quad { + bounds: scaled(420.0, 420.0, 20.0, 20.0), + content_mask: mask, + background: solid_background(gpui::white()), + ..Default::default() + } + .into(), + ); + } + paint(path.into()); + } + scene.finish(); + scene + }; + let moved = |x| triangle(x, 12.25, 24.75, mask); + // One wide triangle clipped to the left or the right: every path has + // the same bounds, so only the masks tell the batches apart. + let clipped = |left| { + let mask = ContentMask { + bounds: scaled(left, 0.0, 56.0, 256.0), + ..Default::default() + }; + triangle(12.25, 12.25, 288.0, mask) + }; + for (what, paired, crossed) in [ + ( + "moved", + scene([moved(12.25), moved(12.25), moved(204.25), moved(204.25)]), + scene([moved(12.25), moved(204.25), moved(12.25), moved(204.25)]), + ), + ( + "clipped", + scene([clipped(0.0), clipped(0.0), clipped(200.0), clipped(200.0)]), + scene([clipped(0.0), clipped(200.0), clipped(0.0), clipped(200.0)]), + ), + ] { + retained.release_retained_frame(); + let before = assert_retained_rgba(&mut full, &mut retained, &paired, 1, "full", what)?; + let after = + assert_retained_rgba(&mut full, &mut retained, &crossed, 2, "partial", what)?; + assert_ne!(before, after, "{what}: regrouping must change the edges"); + } + Ok(()) + } + + #[test] + fn retained_moves_shadows_rings_paths_and_rotated_sprites_exactly() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let target = size(DevicePixels(512), DevicePixels(512)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + let glyph = retained_glyph_tile(&full)?; + assert_eq!(glyph, retained_glyph_tile(&retained)?); + let mask = ContentMask { + bounds: scaled(0.0, 0.0, 512.0, 512.0), + ..Default::default() + }; + // Each step moves a blurred shadow, an inset ring and a path by a + // fractional offset and rotates a sprite, as floating surfaces, focus + // rings, graph edges and spinners do. + let scene = |step: usize| { + let offset = step as f32 * 3.25; + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds: scaled(0.0, 0.0, 512.0, 512.0), + content_mask: mask, + background: solid_background(gpui::rgb(0x18212b)), + ..Default::default() + }); + let card = scaled(100.25 + offset, 100.5, 30.0, 20.0); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(4.5), + bounds: card, + corner_radii: gpui::Corners::all(ScaledPixels(4.0)), + content_mask: mask, + color: solid_background(gpui::hsla(0.0, 0.0, 0.0, 0.6)), + element_bounds: card, + element_corner_radii: gpui::Corners::all(ScaledPixels(4.0)), + inset: gpui::ShaderBool::Disabled, + corner_smoothing: 0.0, + }); + let ring = scaled(100.5, 300.25 + offset, 40.0, 16.0); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds: scaled(101.5, 301.25 + offset, 38.0, 14.0), + corner_radii: gpui::Corners::all(ScaledPixels(3.0)), + content_mask: mask, + color: solid_background(gpui::hsla(0.6, 0.8, 0.6, 1.0)), + element_bounds: ring, + element_corner_radii: gpui::Corners::all(ScaledPixels(4.0)), + inset: gpui::ShaderBool::Enabled, + corner_smoothing: 0.0, + }); + let mut path = gpui::Path::new(point(px(300.25 + offset), px(100.5))); + path.line_to(point(px(320.5 + offset), px(110.25))); + path.line_to(point(px(305.75 + offset), px(125.5))); + let mut path = path.scale(1.0); + path.content_mask = mask; + for vertex in &mut path.vertices { + vertex.content_mask = mask; + } + path.color = solid_background(gpui::hsla(0.3, 0.7, 0.6, 0.8)); + scene.insert_primitive(path); + let center = point(ScaledPixels(304.0), ScaledPixels(304.0)); + scene.insert_primitive(MonochromeSprite { + order: 0, + padding: 0, + bounds: scaled(300.0, 300.0, 8.0, 8.0), + content_mask: mask, + color: gpui::hsla(0.1, 0.6, 0.7, 1.0).into(), + tile: glyph, + transformation: gpui::TransformationMatrix::unit() + .translate(center) + .rotate(gpui::radians(step as f32 * 0.7)) + .translate(point(ScaledPixels(-304.0), ScaledPixels(-304.0))), + }); + scene.finish(); + scene + }; + for step in 0..4 { + assert_retained_rgba( + &mut full, + &mut retained, + &scene(step), + step as u64 + 1, + if step == 0 { "full" } else { "partial" }, + &format!("step {step}"), + )?; + } + Ok(()) + } + + fn assert_retained_rgba( + full: &mut WgpuRenderer, + retained: &mut WgpuRenderer, + scene: &Scene, + revision: u64, + damage: &str, + context: &str, + ) -> anyhow::Result { + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); + let expected = full + .render_to_image(scene) + .map_err(|error| anyhow::anyhow!("{context}: full renderer: {error:#}"))?; + let actual = retained + .render_to_image(scene) + .map_err(|error| anyhow::anyhow!("{context}: retained renderer: {error:#}"))?; + assert_eq!(actual.dimensions(), expected.dimensions(), "{context}"); + let first_difference = actual + .as_raw() + .iter() + .zip(expected.as_raw()) + .enumerate() + .find(|(_, (actual, expected))| actual != expected); + assert!( + first_difference.is_none(), + "{context}: exact RGBA comparison failed at byte {first_difference:?}" + ); + assert_eq!( + retained.retained.last_damage(), + damage, + "{context}: the requested retained path must actually execute" + ); + Ok(actual) + } + + fn empty_transparent_hover_scene(row: Option) -> Scene { + let mut scene = Scene::default(); + if let Some(row) = row { + scene.insert_primitive(Quad { + bounds: scaled(12.25, 10.5 + row as f32 * 25.0, 120.5, 13.25), + content_mask: full_mask(), + background: gpui::rgba(0x4488cc70).into(), + corner_radii: gpui::Corners::all(ScaledPixels(3.25)), + ..Default::default() + }); + } + // A zero-alpha anchor preserves an exact following painter slot when the + // only visible primitive moves; every resting-frame pixel is still clear. + scene.insert_primitive(Quad { + bounds: scaled(190.0, 90.0, 2.0, 2.0), + content_mask: full_mask(), + background: gpui::rgba(0x00000000).into(), + ..Default::default() + }); + scene.finish(); + scene + } + + #[test] + fn retained_transparent_clear_insert_move_erase_is_exact_rgba() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let target = size(DevicePixels(200), DevicePixels(400)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + full.update_transparency(true); + retained.update_transparency(true); + let mut revision = 0; + let mut previous = None; + for (index, row) in [None, None, Some(0), Some(0), Some(1), Some(1), None, None] + .into_iter() + .enumerate() + { + if index == 0 || row != previous { + revision += 1; + } + let image = assert_retained_rgba( + &mut full, + &mut retained, + &empty_transparent_hover_scene(row), + revision, + if index == 0 { + "full" + } else if row == previous { + "reuse" + } else { + "partial" + }, + &format!("empty transparent target, step {index}, row {row:?}"), + )?; + if row.is_none() { + assert!( + image.as_raw().iter().all(|byte| *byte == 0), + "initial clear and erased hover must leave transparent black RGBA" + ); + } else { + assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); + } + previous = row; + } + Ok(()) + } + + #[cfg(target_os = "windows")] + #[test] + fn retained_damage_clear_prewarm_precedes_partial_and_tracks_pipeline_generation() + -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let adapter = context.adapter.get_info(); + assert_eq!(adapter.backend, wgpu::Backend::Dx12); + let target = size(DevicePixels(201), DevicePixels(403)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.disable_for_tests(); + full.update_transparency(true); + retained.update_transparency(true); + let resting = empty_transparent_hover_scene(None); + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + 1, + "full", + "retained off", + )?; + assert!( + retained.resources().pipelines.damage_clear_quads.is_none(), + "full frames must not create the damage-clear PSO" + ); + retained.retained.enable_for_tests(); + { + let _scope = gpui::profiler::gpu::set_gpu_frame_context(false, 1); + assert_eq!( + retained.render_to_image(&resting)?, + full.render_to_image(&resting)? + ); + } + assert!( + retained.resources().pipelines.damage_clear_quads.is_none(), + "inactive-window fallback must not prewarm" + ); + { + let _budget = gpui_render::retained::reserve_retained_bytes( + "wgpu", + Arc::as_ptr(&context.device) as usize, + gpui_render::retained::RETAINED_DEVICE_LIMIT, + ) + .expect("the private device has no retained textures yet"); + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + 1, + "full", + "budget fallback", + )?; + assert!( + retained.resources().pipelines.damage_clear_quads.is_none(), + "budget fallback must not create a PSO for an unallocated retained texture" + ); + } + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + 1, + "full", + "qualified retained initialization", + )?; + let warmed = (**retained + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .expect("the qualified Full must create the clear PSO before any Partial")) + .clone(); + let hovered = empty_transparent_hover_scene(Some(0)); + for damage in ["partial", "reuse"] { + assert_retained_rgba( + &mut full, + &mut retained, + &hovered, + 2, + damage, + "warm first hover", + )?; + assert_eq!( + &**retained + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap(), + &warmed, + "Partial/Reuse must reuse the prewarmed pipeline handle" + ); + } + for renderer in [&mut full, &mut retained] { + renderer.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); + } + assert_retained_rgba( + &mut full, + &mut retained, + &hovered, + 2, + "full", + "same-format resize", + )?; + assert_eq!( + &**retained + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap(), + &warmed, + "same-format resize must not recreate the clear PSO" + ); + for renderer in [&mut full, &mut retained] { + renderer + .target + .set_format_for_tests(wgpu::TextureFormat::Rgba8Unorm); + renderer.rebuild_pipelines(); + } + assert!(retained.resources().pipelines.damage_clear_quads.is_none()); + assert_retained_rgba( + &mut full, + &mut retained, + &hovered, + 2, + "full", + "format generation rebuild", + )?; + let rebuilt = (**retained + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap()) + .clone(); + assert_ne!( + rebuilt, warmed, + "format rebuild must create a pipeline for the new target format" + ); + assert_retained_rgba( + &mut full, + &mut retained, + &empty_transparent_hover_scene(Some(1)), + 3, + "partial", + "new format first hover", + )?; + assert_eq!( + &**retained + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap(), + &rebuilt + ); + + let fresh_context = WgpuContext::new_headless(None)?; + let adapter = fresh_context.adapter.get_info(); + assert_eq!(adapter.backend, wgpu::Backend::Dx12); + let mut recreated = + WgpuRenderer::new_headless(&fresh_context, size(DevicePixels(203), DevicePixels(405)))?; + recreated.retained.enable_for_tests(); + recreated.update_transparency(true); + assert!( + recreated.resources().pipelines.damage_clear_quads.is_none(), + "a new device must not reuse a pipeline belonging to the previous device" + ); + assert_retained_rgba( + &mut full, + &mut recreated, + &resting, + 1, + "full", + "device recreation qualified Full", + )?; + let recreated_pipeline = (**recreated + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap()) + .clone(); + assert_ne!(recreated_pipeline, rebuilt); + assert_retained_rgba( + &mut full, + &mut recreated, + &hovered, + 2, + "partial", + "recreated device first hover", + )?; + assert_eq!( + &**recreated + .resources() + .pipelines + .damage_clear_quads + .as_ref() + .unwrap(), + &recreated_pipeline + ); + Ok(()) + } + + #[test] + fn replaced_retained_targets_leave_no_blit_bind_groups_behind() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let mut renderer = + WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; + renderer.retained.enable_for_tests(); + // A surface without COPY_DST blits on DX12 too. + renderer + .target + .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[]); + let mut scene = Scene::default(); + scene.finish(); + for revision in 1..=3 { + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); + renderer.render_to_image(&scene)?; + assert_eq!(renderer.last_retained_presentation, "blit"); + // A cached bind group would keep each released target alive. + renderer.release_retained_frame(); + } + assert_eq!(renderer.resources().blur_bind_group_count(), 0); + Ok(()) + } + + #[test] + fn retained_targets_return_their_budget_at_once() -> anyhow::Result<()> { + use gpui_render::retained::{RETAINED_DEVICE_LIMIT, reserve_retained_bytes}; + + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let device_key = Arc::as_ptr(&context.device) as usize; + let mut scene = Scene::default(); + scene.finish(); + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, 1); + let target_bytes = 64 * 64 * 4; + for leave in ["release", "unfocused repaint", "close"] { + let mut renderer = + WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; + renderer.retained.enable_for_tests(); + let target = renderer.create_offscreen_target(); + assert!(renderer.render_to_texture(&scene, &target.texture, &target.view)); + // Other windows hold the rest of the device budget. + let _others = + reserve_retained_bytes("wgpu", device_key, RETAINED_DEVICE_LIMIT - target_bytes) + .expect("the retained target fits beside the other windows"); + assert!(reserve_retained_bytes("wgpu", device_key, 1).is_none()); + match leave { + "release" => renderer.release_retained_frame(), + // The pointer left an unfocused window, which repaints once. + "unfocused repaint" => { + let _scope = gpui::profiler::gpu::set_gpu_frame_context(false, 2); + assert!(renderer.render_to_texture(&scene, &target.texture, &target.view)); + } + _ => drop(renderer), + } + // An idle or closed window renders and polls nothing more. + assert!( + reserve_retained_bytes("wgpu", device_key, target_bytes).is_some(), + "{leave}" + ); + } + Ok(()) + } + + #[cfg(target_os = "windows")] + #[test] + fn retained_copy_full_partial_reuse_preserves_exact_rgba_and_odd_extents() -> anyhow::Result<()> + { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let adapter = context.adapter.get_info(); + assert_eq!( + adapter.backend, + wgpu::Backend::Dx12, + "this fixture must exercise the native DX12 copy implementation" + ); + for format in [ + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureFormat::Rgba8Unorm, + ] { + for transparent in [false, true] { + let target = size(DevicePixels(201), DevicePixels(403)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + for renderer in [&mut full, &mut retained] { + renderer.target.set_format_for_tests(format); + renderer.rebuild_pipelines(); + renderer.update_transparency(transparent); + } + assert!(retained.target.retained_copy_enabled()); + let glyph = retained_glyph_tile(&full)?; + assert_eq!(glyph, retained_glyph_tile(&retained)?); + let mut revision = 0; + let mut previous = None; + for (index, row) in [None, None, Some(0), Some(0), Some(2), Some(1), None, None] + .into_iter() + .enumerate() + { + if index == 0 || row != previous { + revision += 1; + } + let scene = + transparent_composite_hover_scene(row, 0.25, 0.65, 0.45, glyph, Some(true)); + let label = format!( + "copy format={format:?} alpha={transparent}, step={index}, row={row:?}" + ); + let image = assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + if index == 0 { + "full" + } else if row == previous { + "reuse" + } else { + "partial" + }, + &label, + )?; + assert_eq!( + retained.last_retained_presentation, "copy", + "{label}: no silent shader fallback" + ); + if transparent { + assert_eq!( + *image.get_pixel(200, 102), + image::Rgba([0, 0, 0, 0]), + "{label}: untouched transparent corner" + ); + assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); + } + previous = row; + } + let scene = + transparent_composite_hover_scene(None, 0.25, 0.65, 0.45, glyph, Some(true)); + full.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); + retained.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); + for damage in ["full", "reuse"] { + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + damage, + "odd resize invalidation", + )?; + assert_eq!(retained.last_retained_presentation, "copy"); + } + full.update_transparency(!transparent); + retained.update_transparency(!transparent); + for damage in ["full", "reuse"] { + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + damage, + "alpha transition", + )?; + assert_eq!(retained.last_retained_presentation, "copy"); + } + retained.release_retained_frame(); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + "full", + "released texture rebuild", + )?; + assert_eq!(retained.last_retained_presentation, "copy"); + + let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); + // A texture made while the surface lacked COPY_DST. + let copy = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST; + retained + .target + .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[format]); + let target = retained.create_offscreen_target(); + retained.target.update_surface_copy_support(copy, &[format]); + assert!( + !target + .texture + .usage() + .contains(wgpu::TextureUsages::COPY_DST) + ); + let submission = super::super::frame::render_to_view( + &mut retained, + &scene, + &target.view, + &target.texture, + Some(target.readback_copy()), + ) + .unwrap(); + assert_eq!( + retained.read_offscreen_target(&target, submission)?, + full.render_to_image(&scene)? + ); + assert_eq!( + retained.last_retained_presentation, "blit", + "a texture without COPY_DST must not be copied" + ); + + // Surface replacement must drop unsupported COPY_DST usage and capability. + retained + .target + .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[format]); + assert!(!retained.target.retained_copy_enabled()); + assert!( + !retained + .target + .configuration() + .usage + .contains(wgpu::TextureUsages::COPY_DST) + ); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + "full", + "replacement without copy support", + )?; + assert_eq!(retained.last_retained_presentation, "blit"); + retained.target.update_surface_copy_support(copy, &[format]); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + "full", + "replacement restores copy support", + )?; + assert_eq!(retained.last_retained_presentation, "copy"); + } + } + Ok(()) + } + + #[test] + fn retained_raw_clear_endpoints_and_colored_fallback_match_full_rgba() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let target = size(DevicePixels(200), DevicePixels(400)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + for transparent in [false, true] { + full.update_transparency(transparent); + retained.update_transparency(transparent); + for (clear, supported, label) in [ + (wgpu::Color::TRANSPARENT, true, "transparent black"), + ( + wgpu::Color { + a: 0.25, + ..wgpu::Color::BLACK + }, + true, + "translucent black", + ), + (wgpu::Color::BLACK, true, "opaque black"), + (wgpu::Color::WHITE, true, "opaque white"), + ( + wgpu::Color { + r: 0.04, + g: 0.03, + b: 0.02, + a: 0.25, + }, + false, + "translucent colored clear", + ), + ( + wgpu::Color { + r: 0.04, + g: 0.03, + b: 0.02, + a: 0.0, + }, + false, + "zero-alpha colored raw clear", + ), + ( + wgpu::Color { + r: 0.04, + g: 0.03, + b: 0.02, + a: 1.0, + }, + false, + "opaque colored raw clear", + ), + ] { + full.target.clear_color = clear; + retained.target.clear_color = clear; + retained.retained.invalidate(); + for (index, row) in [None, Some(0), Some(1), None].into_iter().enumerate() { + let scene = empty_transparent_hover_scene(row); + let revision = index as u64 + 1; + let label = format!("alpha={transparent}, clear={label}, step={index}"); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + if supported && index != 0 { + "partial" + } else { + "full" + }, + &label, + )?; + // Unsupported clears must never retain faulty pixels, including + // an unchanged repeat immediately after an inserted/erased fill. + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + if supported { "reuse" } else { "full" }, + &format!("repeat: {label}"), + )?; + } + } + } + Ok(()) + } + + fn retained_glyph_tile(renderer: &WgpuRenderer) -> anyhow::Result { + let key = AtlasKey::Glyph { + params: RenderGlyphParams { + font_id: FontId(0), + glyph_id: GlyphId(65), + font_size: px(8.0), + subpixel_variant: point(0, 0), + scale_factor: 1.0, + raster_style: PreparedRasterStyle::independent(GlyphRenderMode::Grayscale), + }, + format: RasterizedGlyphFormat::AlphaMask, + }; + // Deterministic glyph-shaped coverage exercises the real monochrome atlas + // pipeline without depending on a machine's font rasterizer or font files. + let coverage = [ + 0, 0, 96, 255, 255, 96, 0, 0, 0, 64, 255, 96, 96, 255, 64, 0, 0, 128, 255, 0, 0, 255, + 128, 0, 32, 224, 160, 0, 0, 160, 224, 32, 96, 255, 255, 255, 255, 255, 255, 96, 160, + 224, 64, 0, 0, 64, 224, 160, 224, 160, 0, 0, 0, 0, 160, 224, 255, 96, 0, 0, 0, 0, 96, + 255, + ]; + renderer + .atlas + .get_or_insert_with(&key, &mut || { + Ok(Some(( + size(DevicePixels(8), DevicePixels(8)), + Cow::Borrowed(&coverage), + ))) + })? + .ok_or_else(|| anyhow::anyhow!("retained glyph fixture must produce an atlas tile")) + } + + fn retained_tiled_glyph( + renderer: &WgpuRenderer, + mode: GlyphRenderMode, + ) -> anyhow::Result { + let key = AtlasKey::Glyph { + params: RenderGlyphParams { + font_id: FontId(0), + glyph_id: GlyphId(90), + font_size: px(8.0), + subpixel_variant: point(0, 0), + scale_factor: 1.0, + raster_style: PreparedRasterStyle::independent(mode), + }, + format: mode.rasterized_format(), + }; + let coverage: Vec = (0..64) + .map(|index| match (index / 8, index % 8) { + (4, 1..=6) | (1..=6, 1 | 6) => 255, + (_, 2 | 5) => 96, + _ => 0, + }) + .collect(); + let bytes = if mode == GlyphRenderMode::Subpixel { + coverage + .iter() + .flat_map(|&value| [value / 2, value, value.saturating_add(24), 255]) + .collect() + } else { + coverage + }; + renderer + .atlas + .get_or_insert_with(&key, &mut || { + Ok(Some(( + size(DevicePixels(8), DevicePixels(8)), + Cow::Borrowed(&bytes), + ))) + })? + .ok_or_else(|| anyhow::anyhow!("tiled glyph fixture must allocate an atlas tile")) + } + + fn tiled_history_scene( + hover: Option, + fraction: f32, + glyph: AtlasTile, + mode: GlyphRenderMode, + ) -> Scene { + let mut scene = Scene::default(); + let pane = scaled(20.0, 12.0, 460.0, 840.0); + let mask = ContentMask { + bounds: pane, + ..Default::default() + }; + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(2.25), + bounds: pane, + corner_radii: gpui::Corners::all(ScaledPixels(8.25)), + content_mask: ContentMask { + bounds: scaled(0.0, 0.0, 512.0, 1024.0), + ..Default::default() + }, + color: gpui::rgba(0x17243850).into(), + element_bounds: pane, + element_corner_radii: gpui::Corners::all(ScaledPixels(8.25)), + inset: false.into(), + corner_smoothing: 0.65, + }); + scene.insert_primitive(Quad { + bounds: pane, + content_mask: mask, + background: gpui::rgba(0x223344bb).into(), + corner_radii: gpui::Corners::all(ScaledPixels(8.25)), + corner_smoothing: 0.65, + ..Default::default() + }); + // Stable graph raster groups precede ordinary bounds-tree row paints. + // No per-row push_layer/raise_order_floor masks the live rank permutation. + for row in [0, 5, 10, 19] { + scene.insert_primitive(triangle( + 24.25 + fraction, + 18.25 + row as f32 * 40.0, + 10.5, + mask, + )); + } + let paint_glyph = |scene: &mut Scene, x: f32, y: f32, color: gpui::Hsla| { + if mode == GlyphRenderMode::Subpixel { + scene.insert_primitive(gpui::SubpixelSprite { + order: 0, + padding: 0, + bounds: scaled(x, y, 8.5, 10.25), + content_mask: mask, + color: color.into(), + tile: glyph, + transformation: Default::default(), + }); + } else { + scene.insert_primitive(MonochromeSprite { + order: 0, + padding: 0, + bounds: scaled(x, y, 8.5, 10.25), + content_mask: mask, + color: color.into(), + tile: glyph, + transformation: Default::default(), + }); + } + }; + for row in 0..20 { + let y = 16.25 + fraction + row as f32 * 40.0; + if hover == Some(row) { + scene.insert_primitive(Quad { + bounds: scaled(20.0, y, 460.0, 28.5), + content_mask: mask, + background: gpui::rgba(0x55779944).into(), + ..Default::default() + }); + } + // Live graph cutouts have unchanged geometry but hover-dependent + // backing colors. Color changes must not consume structural edits. + for (x, width) in [(134.0, 66.0), (200.0, 4.0), (410.0, 24.0)] { + scene.insert_primitive(Quad { + bounds: scaled(x, y, width, 28.5), + content_mask: mask, + background: if hover == Some(row) { + gpui::rgba(0x55779999) + } else { + gpui::rgba(0x22334499) + } + .into(), + ..Default::default() + }); + } + for index in 0..20 { + paint_glyph( + &mut scene, + 32.25 + fraction + index as f32 * 18.5, + y + 3.25, + gpui::hsla(0.12, 0.6, 0.65, 0.85), + ); + } + if hover == Some(row) { + for index in 0..7 + row % 3 { + paint_glyph( + &mut scene, + 282.25 + fraction + index as f32 * 7.0, + y + 15.0, + gpui::hsla(0.6, 0.4, 0.6, 0.6), + ); + } + } + } + scene.insert_primitive(Quad { + bounds: scaled(250.25 + fraction, 14.5, 8.5, 832.25), + content_mask: mask, + background: gpui::rgba(0xda624d60).into(), + ..Default::default() + }); + scene.finish(); + scene + } + + #[test] + fn retained_tiled_history_bounds_tree_matches_exact_rgba_for_rank_permutations() + -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + for mode in [GlyphRenderMode::Grayscale, GlyphRenderMode::Subpixel] { + for (transparent, fraction) in [(false, 0.0), (true, 0.25), (true, 0.75)] { + let target = size(DevicePixels(512), DevicePixels(1024)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + full.update_transparency(transparent); + retained.update_transparency(transparent); + let glyph = retained_tiled_glyph(&full, mode)?; + assert_eq!(glyph, retained_tiled_glyph(&retained, mode)?); + let first = tiled_history_scene(Some(0), fraction, glyph, mode); + let last = tiled_history_scene(Some(1), fraction, glyph, mode); + let first_snapshot = + gpui_render::damage::SceneSnapshot::capture(&first, 1, 512, 1024, Vec::new()) + .unwrap(); + let last_snapshot = + gpui_render::damage::SceneSnapshot::capture(&last, 1, 512, 1024, Vec::new()) + .unwrap(); + assert!( + matches!( + last_snapshot.compare(&first_snapshot), + gpui_render::damage::Damage::Partial(_) + ), + "rank permutations must stay partial" + ); + let mut previous = None; + let mut revision = 0; + // One box spans every changed tile. A jump across the list repaints + // in full, and so does starting or ending a hover: that raises the + // draw order of the strip crossing every row, which moves it among + // paints it doesn't touch, so all its tiles repaint. + for (index, (row, damage)) in [ + (None, "full"), + (None, "reuse"), + (Some(0), "full"), + (Some(0), "reuse"), + (Some(1), "partial"), + (Some(2), "partial"), + (Some(19), "full"), + (Some(18), "partial"), + (None, "full"), + (None, "reuse"), + ] + .into_iter() + .enumerate() + { + if index == 0 || row != previous { + revision += 1; + } + let scene = tiled_history_scene(row, fraction, glyph, mode); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + damage, + &format!( + "tiled history mode={mode:?}, transparent={transparent}, fraction={fraction}, step={index}, row={row:?}" + ), + )?; + previous = row; + } + + // Identical overlapping descriptors around a third translucent + // paint must change pixels, rather than being mistaken for Reuse. + let mask = ContentMask { + bounds: scaled(0.0, 0.0, 512.0, 1024.0), + ..Default::default() + }; + let a = Quad { + bounds: scaled(62.25, 62.25, 24.5, 24.5), + content_mask: mask, + background: gpui::rgba(0xff000080).into(), + ..Default::default() + }; + let b = Quad { + background: gpui::rgba(0x0000ff80).into(), + ..a + }; + let mut overlap = Scene::default(); + for quad in [a, a, b] { + overlap.insert_primitive(quad); + } + overlap.finish(); + // Recreate targets to ensure this independent order proof starts + // with an initial Full rather than an unrelated previous scene. + retained.retained.invalidate(); + revision += 1; + let original = assert_retained_rgba( + &mut full, + &mut retained, + &overlap, + revision, + "full", + "tiled overlap initial", + )?; + overlap.quads.swap(1, 2); // Counts/pipelines stay unchanged. + revision += 1; + let changed = assert_retained_rgba( + &mut full, + &mut retained, + &overlap, + revision, + "partial", + "tiled overlap reordered", + )?; + assert_ne!( + original.as_raw(), + changed.as_raw(), + "overlapping painter reorder must visibly change RGBA" + ); + assert_retained_rgba( + &mut full, + &mut retained, + &overlap, + revision, + "reuse", + "tiled overlap repeated", + )?; + } + } + Ok(()) + } + + fn transparent_composite_hover_scene( + row: Option, + fraction: f32, + smoothing: f32, + backing_alpha: f32, + glyph: AtlasTile, + badge_before_graph: Option, + ) -> Scene { + let mut scene = Scene::default(); + let backing_bounds = scaled(4.25 + fraction, 3.5 + fraction, 190.5, 90.25); + let radii = gpui::Corners::all(ScaledPixels(9.25)); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(2.5), + bounds: backing_bounds, + corner_radii: radii, + content_mask: full_mask(), + color: gpui::rgba(0x17243850).into(), + element_bounds: backing_bounds, + element_corner_radii: radii, + inset: false.into(), + corner_smoothing: smoothing, + }); + scene.raise_order_floor(); + scene.insert_primitive(Quad { + bounds: backing_bounds, + content_mask: full_mask(), + background: solid_background(gpui::hsla(0.62, 0.3, 0.35, backing_alpha)), + corner_radii: radii, + corner_smoothing: smoothing, + ..Default::default() + }); + scene.raise_order_floor(); + // A shifted/shrunk inset hole is far from the first hovered row. Its + // actual raster geometry is the whole element, so pruning by the hole + // bounds would lose static inset pixels when that row is damage-cleared. + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(0.5), + bounds: scaled(140.25 + fraction, 62.5, 26.5, 14.0), + corner_radii: gpui::Corners::all(ScaledPixels(2.0)), + content_mask: full_mask(), + color: gpui::rgba(0x10172870).into(), + element_bounds: backing_bounds, + element_corner_radii: radii, + inset: true.into(), + corner_smoothing: smoothing, + }); + scene.raise_order_floor(); + if let Some(row) = row { + scene.insert_primitive(Quad { + bounds: scaled( + 10.25 + fraction, + 12.5 + row as f32 * 24.0, + 165.5 - row as f32 * 0.75, + 13.25, + ), + content_mask: ContentMask { + bounds: scaled( + 11.5 + fraction, + 12.75 + row as f32 * 24.0, + 162.5 - row as f32 * 0.25, + 12.5, + ), + fade_out: gpui::Edges { + left: ScaledPixels(1.25), + right: ScaledPixels(1.75), + ..Default::default() + }, + }, + background: gpui::rgba(0x4f98df70).into(), + corner_radii: gpui::Corners::all(ScaledPixels(3.5 + row as f32 * 0.25)), + corner_smoothing: smoothing, + ..Default::default() + }); + } + let paint_badge = |scene: &mut Scene| { + if let Some(row) = row { + // The live history row adds an entire lane-name badge only while + // hovered. Variable run lengths and distant row moves must repaint + // both the old and new badge without losing the static text anchor. + for index in 0..7 + row { + scene.insert_primitive(MonochromeSprite { + order: 0, + padding: 0, + bounds: scaled( + 112.25 + fraction + index as f32 * 6.5, + 14.5 + row as f32 * 24.0, + 6.5, + 10.75, + ), + content_mask: full_mask(), + color: gpui::hsla(0.6, 0.4, 0.6, 0.6).into(), + tile: glyph, + transformation: Default::default(), + }); + } + } + }; + scene.raise_order_floor(); + if badge_before_graph == Some(true) { + paint_badge(&mut scene); + scene.raise_order_floor(); + } + scene.insert_primitive(triangle( + 17.25 + fraction, + 10.5, + 17.5, + ContentMask { + bounds: scaled(19.5 + fraction, 13.25, 13.75, 12.5), + fade_out: gpui::Edges::all(ScaledPixels(0.75)), + }, + )); + scene.raise_order_floor(); + if badge_before_graph == Some(false) { + paint_badge(&mut scene); + } + scene.raise_order_floor(); + scene.insert_primitive(MonochromeSprite { + order: 0, + padding: 0, + bounds: scaled(44.25 + fraction, 14.5, 12.5, 10.75), + content_mask: ContentMask { + bounds: scaled(45.0 + fraction, 14.25, 11.5, 11.5), + fade_out: gpui::Edges::all(ScaledPixels(0.75)), + }, + color: gpui::hsla(0.12, 0.8, 0.65, 0.85).into(), + tile: glyph, + transformation: Default::default(), + }); + scene.raise_order_floor(); + scene.insert_primitive(Quad { + bounds: scaled(80.25 + fraction, 6.5, 16.5, 78.25), + content_mask: full_mask(), + background: gpui::rgba(0xda624d60).into(), + corner_radii: gpui::Corners::all(ScaledPixels(4.25)), + corner_smoothing: smoothing, + ..Default::default() + }); + scene.finish(); + scene + } + + #[test] + fn retained_transparent_composite_hover_matches_exact_rgba_and_invalidates_appearance() + -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + for badge_before_graph in [None, Some(false), Some(true)] { + for (fraction, smoothing, backing_alpha) in [ + (0.0, 0.0, 0.45), + (0.25, 0.65, 1.0), + (0.5, 0.0, 1.0), + (0.75, 0.65, 0.45), + ] { + let target = size(DevicePixels(200), DevicePixels(400)); + let mut full = WgpuRenderer::new_headless(&context, target)?; + let mut retained = WgpuRenderer::new_headless(&context, target)?; + full.retained.disable_for_tests(); + retained.retained.enable_for_tests(); + full.update_transparency(true); + retained.update_transparency(true); + let glyph = retained_glyph_tile(&full)?; + assert_eq!(glyph, retained_glyph_tile(&retained)?); + let label = format!( + "fraction={fraction}, smoothing={smoothing}, backing={backing_alpha}, badge_before_graph={badge_before_graph:?}" + ); + let mut revision = 0; + let mut previous = None; + let resting = transparent_composite_hover_scene( + None, + fraction, + smoothing, + backing_alpha, + glyph, + badge_before_graph, + ); + let mut resting_pixels = None; + for (index, row) in [ + None, + None, + Some(0), + Some(0), + Some(2), + Some(0), + Some(1), + Some(2), + None, + None, + ] + .into_iter() + .enumerate() + { + if index == 0 || row != previous { + revision += 1; + } + let scene = transparent_composite_hover_scene( + row, + fraction, + smoothing, + backing_alpha, + glyph, + badge_before_graph, + ); + if badge_before_graph == Some(true) + && let Some(row) = row + { + assert!(scene.render_commands().windows(2).any(|commands| + matches!((&commands[0], &commands[1]), + (gpui::RenderCommand::Batch(gpui::PrimitiveBatch::Quads { range: quad, .. }), + gpui::RenderCommand::Batch(gpui::PrimitiveBatch::MonochromeSprites { range: glyphs, .. })) + if quad.len() == 1 && glyphs.len() == 7 + row)), + "fixture must prove fill directly precedes the changed badge: {label}"); + } + let image = assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + if index == 0 { + "full" + } else if row == previous { + "reuse" + } else { + "partial" + }, + &format!("{label}, step={index}, row={row:?}"), + )?; + assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); + assert!( + image.get_pixel(0, 0)[3] < 255, + "rounded outside corner: {label}" + ); + if backing_alpha == 1.0 { + assert_eq!(image.get_pixel(150, 85)[3], 255, "opaque interior: {label}"); + } + if index == 0 { + resting_pixels = Some(image.clone()); + } else if row.is_none() { + assert_eq!( + Some(&image), + resting_pixels.as_ref(), + "erase restores every byte: {label}" + ); + } else { + assert_ne!( + Some(&image), + resting_pixels.as_ref(), + "hover must visibly alter the fixture: {label}" + ); + } + previous = row; + } + // Invalidate independently of scene revision: resize, alpha mode, + // clear color, and atlas generation must never reuse stale pixels. + let resized = size(DevicePixels(193), DevicePixels(97)); + full.update_drawable_size(resized); + retained.update_drawable_size(resized); + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "full", + &format!("resize: {label}"), + )?; + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "reuse", + &format!("resized reuse: {label}"), + )?; + for transparent in [false, true] { + full.update_transparency(transparent); + retained.update_transparency(transparent); + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "full", + &format!("alpha transition to {transparent}: {label}"), + )?; + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "reuse", + &format!("alpha reuse {transparent}: {label}"), + )?; + } + // A direct clear-color change must be detected by the retained + // appearance key even when no public alpha toggle fires. + full.target.clear_color = wgpu::Color { + r: 0.04, + g: 0.03, + b: 0.02, + a: 0.25, + }; + retained.target.clear_color = full.target.clear_color; + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "full", + &format!("clear-color transition: {label}"), + )?; + assert_retained_rgba( + &mut full, + &mut retained, + &resting, + revision, + "full", + &format!("unsupported clear-color fallback: {label}"), + )?; + // Restore an exact endpoint so atlas invalidation still tests + // a qualified retained target rather than the unsupported clear. + full.target.clear_color = wgpu::Color::TRANSPARENT; + retained.target.clear_color = full.target.clear_color; + full.atlas.clear(); + retained.atlas.clear(); + let glyph = retained_glyph_tile(&full)?; + assert_eq!(glyph, retained_glyph_tile(&retained)?); + let scene = transparent_composite_hover_scene( + None, + fraction, + smoothing, + backing_alpha, + glyph, + badge_before_graph, + ); + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + "full", + &format!("atlas invalidation: {label}"), + )?; + assert_retained_rgba( + &mut full, + &mut retained, + &scene, + revision, + "reuse", + &format!("atlas reuse: {label}"), + )?; + } + } + Ok(()) + } + + #[test] + fn cropped_path_targets_match_full_viewport_pixels_when_growing_and_resizing() { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None).expect("hardware or software GPU"); + let target = size(DevicePixels(512), DevicePixels(320)); + let mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(1024.0), ScaledPixels(640.0)), + ), + ..Default::default() + }; + let mut full = WgpuRenderer::new_headless(&context, target).unwrap(); + let mut cropped = WgpuRenderer::new_headless(&context, target).unwrap(); + for (x, y, target) in [ + (16.0, 16.0, target), + (180.0, 120.0, target), + (16.0, 16.0, target), + (40.0, 35.0, size(DevicePixels(360), DevicePixels(240))), + ] { + full.update_drawable_size(target); + cropped.update_drawable_size(target); + let mut force_full = Scene::default(); + force_full.paths.push(triangle( + 0.0, + 0.0, + target.width.0.max(target.height.0) as f32, + mask, + )); + full.ensure_path_textures(&force_full); + + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds: mask.bounds, + content_mask: mask, + background: solid_background(gpui::black()), + ..Default::default() + }); + scene.insert_primitive(triangle(x, y, 47.5, mask)); + // A later path beyond the viewport makes the combined sprite span + // empty space past the cropped target. That space must stay black. + scene.insert_primitive(Quad { + bounds: Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(1.0), ScaledPixels(1.0)), + ), + content_mask: mask, + background: solid_background(gpui::black()), + ..Default::default() + }); + scene.insert_primitive(triangle(800.0, 10.0, 40.0, mask)); + // Negative geometry and a clipped curve exercise coordinate/clip + // preservation independently of the viewport's dimensions. + let mut curve = PathBuilder::stroke(px(1.6)); + curve.move_to(point(px(-10.0), px(30.0))); + curve.cubic_bezier_to( + point(px(40.0), px(70.0)), + point(px(24.0), px(22.0)), + point(px(5.0), px(60.0)), + ); + let mut curve = curve.build().unwrap().scale(1.0); + curve.content_mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(30.0), ScaledPixels(60.0)), + ), + ..Default::default() + }; + curve.color = solid_background(gpui::white()); + scene.insert_primitive(curve); + scene.finish(); + let expected = full.render_to_image(&scene).expect("full target frame"); + let actual = cropped + .render_to_image(&scene) + .expect("cropped target frame"); + assert!( + actual.get_pixel(x as u32 + 8, y as u32 + 8)[0] > 100, + "triangle must render" + ); + assert_eq!( + &actual.get_pixel(300, 200).0[..3], + &[0, 0, 0], + "no clamped edge smear" + ); + let max_diff = actual + .as_raw() + .iter() + .zip(expected.as_raw()) + .map(|(a, b)| a.abs_diff(*b)) + .max() + .unwrap(); + assert!( + max_diff <= 1, + "cropped rendering changed a pixel by {max_diff}" + ); + let texture = cropped + .resources() + .path_intermediate_texture + .as_ref() + .unwrap(); + assert!( + texture.width() < target.width.0 as u32, + "narrow scene must keep a narrow target" + ); + assert!(texture.height() < target.height.0 as u32); + if let Ok(directory) = std::env::var("GPUI_PATH_TEST_IMAGES") { + actual + .save( + std::path::Path::new(&directory) + .join(format!("cropped-{x}-{y}-{}.png", target.width.0)), + ) + .unwrap(); + expected + .save( + std::path::Path::new(&directory) + .join(format!("full-{x}-{y}-{}.png", target.width.0)), + ) + .unwrap(); + } + } + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs b/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs new file mode 100644 index 000000000..e4553e400 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs @@ -0,0 +1,227 @@ +use super::{WgpuRenderer, pipelines::SmoothingRequirements}; +use crate::WgpuContext; +use gpui::{ + AtlasKey, AtlasTile, BackdropFilter, Bounds, ContentMask, Corners, DevicePixels, + FilterBoundary, ImageId, PlatformAtlas, PolychromeSprite, Quad, RenderImageParams, + ScaledFilter, ScaledPixels, Scene, Shadow, point, size, solid_background, +}; +use std::borrow::Cow; + +fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new( + point(ScaledPixels(x), ScaledPixels(y)), + size(ScaledPixels(width), ScaledPixels(height)), + ) +} + +fn handles(renderer: &WgpuRenderer) -> [Option; 4] { + let pipelines = &renderer.resources().pipelines; + [ + &pipelines.smoothed_quads, + &pipelines.smoothed_shadows, + &pipelines.smoothed_polychrome_sprites, + &pipelines.smoothed_blur_composite, + ] + .map(|pipeline| { + pipeline + .as_ref() + .map(|pipeline| std::ops::Deref::deref(pipeline).clone()) + }) +} + +fn image_tile(renderer: &WgpuRenderer) -> anyhow::Result { + renderer + .atlas + .get_or_insert_with( + &AtlasKey::Image(RenderImageParams { + image_id: ImageId(9081), + frame_index: 0, + }), + &mut || { + Ok(Some(( + size(DevicePixels(8), DevicePixels(8)), + Cow::Owned(vec![255; 8 * 8 * 4]), + ))) + }, + )? + .ok_or_else(|| anyhow::anyhow!("test image must have pixels")) +} + +fn scene(kind: usize, tile: AtlasTile) -> Scene { + let mut scene = Scene::default(); + let shape = bounds(20.0, 20.0, 56.0, 56.0); + let content_mask = ContentMask { + bounds: bounds(0.0, 0.0, 96.0, 96.0), + ..Default::default() + }; + let corner_radii = Corners::all(ScaledPixels(18.0)); + match kind { + 0 => scene.insert_primitive(Quad { + bounds: shape, + content_mask, + corner_radii, + corner_smoothing: 0.75, + background: solid_background(gpui::white()), + ..Default::default() + }), + 1 => scene.insert_primitive(Shadow { + order: 0, + bounds: shape, + element_bounds: shape, + content_mask, + corner_radii, + element_corner_radii: corner_radii, + corner_smoothing: 0.75, + color: solid_background(gpui::white()), + blur_radius: ScaledPixels(5.0), + inset: false.into(), + }), + 2 => scene.insert_primitive(PolychromeSprite { + order: 0, + bounds: shape, + content_mask, + corner_radii, + corner_smoothing: 0.75, + grayscale: false.into(), + opacity: 1.0, + tile, + }), + 3 => { + scene.insert_primitive(Quad { + bounds: shape, + content_mask, + background: gpui::checkerboard(gpui::white(), 2.0), + ..Default::default() + }); + scene.raise_order_floor(); + scene.insert_primitive(BackdropFilter { + order: 0, + bounds: shape, + content_mask, + corner_radii, + corner_smoothing: 0.75, + filters: smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(5.0))], + opacity: 1.0, + }); + } + 4 | 5 => { + let boundary = FilterBoundary { + order: 0, + bounds: shape, + content_mask, + corner_radii, + corner_smoothing: 0.75, + filters: if kind == 4 { + smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(5.0))] + } else { + // Upstream also composites zero-blur opacity groups. + smallvec::smallvec![] + }, + opacity: 1.0, + is_start: true, + }; + scene.insert_primitive(boundary.clone()); + scene.raise_order_floor(); + scene.insert_primitive(Quad { + bounds: shape, + content_mask, + background: solid_background(gpui::white()), + ..Default::default() + }); + scene.raise_order_floor(); + scene.insert_primitive(FilterBoundary { + is_start: false, + ..boundary + }); + } + _ => unreachable!(), + } + scene.finish(); + scene +} + +#[test] +fn smoothing_pipelines_are_absent_until_first_use_and_match_prepared_pixels() -> anyhow::Result<()> +{ + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let target = size(DevicePixels(96), DevicePixels(96)); + let mut lazy = WgpuRenderer::new_headless(&context, target)?; + let mut prepared = WgpuRenderer::new_headless(&context, target)?; + lazy.retained.disable_for_tests(); + prepared.retained.disable_for_tests(); + let tile = image_tile(&lazy)?; + assert_eq!(tile, image_tile(&prepared)?); + assert!(handles(&lazy).iter().all(Option::is_none)); + + let mut ordinary = Scene::default(); + ordinary.insert_primitive(Quad { + bounds: bounds(0.0, 0.0, 96.0, 96.0), + content_mask: ContentMask { + bounds: bounds(0.0, 0.0, 96.0, 96.0), + ..Default::default() + }, + background: solid_background(gpui::white()), + ..Default::default() + }); + ordinary.finish(); + assert_eq!( + lazy.render_to_image(&ordinary)?.get_pixel(48, 48).0, + [255; 4] + ); + assert!( + handles(&lazy).iter().all(Option::is_none), + "ordinary frames need no smoothing" + ); + + prepared.resources_mut().pipelines.prepare_smoothing( + &context.device, + SmoothingRequirements { + quads: true, + shadows: true, + polychrome_sprites: true, + blur_composite: true, + }, + )?; + for kind in 0..6 { + if kind >= 4 { + // Target/device resource rebuilds must not retain old pipeline handles. + lazy.rebuild_pipelines(); + assert!(handles(&lazy).iter().all(Option::is_none)); + } + let scene = scene(kind, tile); + let first_use = lazy.render_to_image(&scene)?; + let reference = prepared.render_to_image(&scene)?; + assert_eq!( + first_use, reference, + "first-use pixels for smoothing kind {kind}" + ); + assert!( + first_use.pixels().any(|pixel| pixel.0[..3] != [0; 3]), + "kind {kind} must draw" + ); + if kind == 0 || kind == 2 { + assert_eq!(first_use.get_pixel(48, 48).0, [255; 4]); + assert_eq!( + first_use.get_pixel(20, 20).0, + [0, 0, 0, 255], + "the first smoothed fill/image must exclude its corner" + ); + } + let created = handles(&lazy); + let expected = if kind >= 4 { + [false, false, false, true] + } else { + std::array::from_fn(|index| index <= kind) + }; + assert_eq!(created.each_ref().map(Option::is_some), expected); + assert_eq!(lazy.render_to_image(&scene)?, first_use); + assert_eq!( + handles(&lazy), + created, + "second use must reuse pipeline handles" + ); + } + assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); + Ok(()) +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs b/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs new file mode 100644 index 000000000..83d0ce543 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs @@ -0,0 +1,6 @@ +#[cfg(test)] +pub(super) use gpui_render::path_types::STORAGE_ABI; +pub(super) use gpui_render::path_types::{ + PathRasterizationVertex, PathSprite, rasterization_vertex_count, rasterization_vertices, + sprite_count, sprites, +}; diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs b/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs new file mode 100644 index 000000000..b87662e34 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs @@ -0,0 +1,1077 @@ +use crate::RendererTier; +use gpui_render::{ + artifacts::{ + BASE_DOWNLEVEL_WGSL, BASE_WGSL, BLUR_BINDINGS, DOWNLEVEL_BLUR_BINDINGS, + DOWNLEVEL_INSTANCE_BINDINGS, DOWNLEVEL_RANGE_BINDING, DOWNLEVEL_SURFACE_BINDINGS, + DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, GLOBAL_BINDINGS, GeneratedBinding, + GeneratedBindingKind, INSTANCE_BINDINGS, MONOCHROME_INSTANCE_BINDINGS, + SUBPIXEL_DUAL_SOURCE_WGSL, SUBPIXEL_INSTANCE_BINDINGS, SURFACE_BINDINGS, + TEXTURED_INSTANCE_BINDINGS, + }, + shaders::interface as shader, +}; +use std::num::NonZeroU64; + +/// Group-1 payload: a storage buffer on modern tiers, a data texture plus per-batch +/// range uniform on downlevel. +pub(super) enum InstanceBindingSource<'a> { + Buffer(wgpu::BufferBinding<'a>), + DataTexture { + texture: &'a wgpu::TextureView, + range_uniforms: wgpu::BufferBinding<'a>, + }, +} + +fn instance_binding_entries(source: InstanceBindingSource<'_>) -> Vec> { + match source { + InstanceBindingSource::Buffer(instances) => { + vec![wgpu::BindGroupEntry { + binding: shader::DATA_BUFFER_BINDING, + resource: wgpu::BindingResource::Buffer(instances), + }] + } + InstanceBindingSource::DataTexture { + texture, + range_uniforms, + } => { + vec![ + wgpu::BindGroupEntry { + binding: shader::DATA_BUFFER_BINDING, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: DOWNLEVEL_RANGE_BINDING, + resource: wgpu::BindingResource::Buffer(range_uniforms), + }, + ] + } + } +} + +pub(super) struct WgpuPipelines { + pub(super) quads: WgpuRenderPipeline, + pub(super) damage_clear_quads: Option, + // Handles already used by the ordinary Quad pipelines; retaining their references + // creates no additional GPU allocation or shader compilation at startup. + base_shader: wgpu::ShaderModule, + instance_layout: wgpu::PipelineLayout, + damage_clear_format: wgpu::TextureFormat, + pub(super) smoothed_quads: Option, + pub(super) shadows: WgpuRenderPipeline, + pub(super) smoothed_shadows: Option, + pub(super) path_rasterization: WgpuRenderPipeline, + pub(super) paths: WgpuRenderPipeline, + pub(super) underlines: WgpuRenderPipeline, + pub(super) monochrome_sprites: WgpuRenderPipeline, + pub(super) subpixel_sprites: Option, + pub(super) polychrome_sprites: WgpuRenderPipeline, + pub(super) smoothed_polychrome_sprites: Option, + #[cfg_attr( + not(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + )), + allow(dead_code) + )] + pub(super) surfaces: WgpuRenderPipeline, + pub(super) blur_downsample: WgpuRenderPipeline, + pub(super) blur: WgpuRenderPipeline, + pub(super) blur_composite: WgpuRenderPipeline, + pub(super) smoothed_blur_composite: Option, + // Retain handles already used by the eager pipelines, so first use only + // compiles the requested smoothing variant. Rebuilding this collection for + // a new device/target also replaces these descriptors and empty slots. + smoothing_textured_layout: wgpu::PipelineLayout, + smoothing_blur_layout: wgpu::PipelineLayout, + smoothing_scene_target: wgpu::ColorTargetState, + smoothing_composite_target: wgpu::ColorTargetState, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct SmoothingRequirements { + pub(super) quads: bool, + pub(super) shadows: bool, + pub(super) polychrome_sprites: bool, + pub(super) blur_composite: bool, +} + +impl SmoothingRequirements { + fn is_empty(self) -> bool { + self == Self::default() + } +} + +pub(super) struct WgpuRenderPipeline { + raw: wgpu::RenderPipeline, + specification: shader::Pipeline, +} + +impl WgpuRenderPipeline { + pub(super) fn data_layout(&self) -> shader::DataLayout { + self.specification.data_layout + } + + pub(super) fn fixed_vertex_count(&self) -> u32 { + self.specification.vertex_count.fixed().unwrap_or_else(|| { + panic!( + "pipeline {} uses a workload-defined vertex count", + self.specification.label + ) + }) + } +} + +impl std::ops::Deref for WgpuRenderPipeline { + type Target = wgpu::RenderPipeline; + + fn deref(&self) -> &Self::Target { + &self.raw + } +} + +pub(super) struct WgpuBindGroupLayouts { + pub(super) globals: wgpu::BindGroupLayout, + pub(super) instances: wgpu::BindGroupLayout, + monochrome_sprites: wgpu::BindGroupLayout, + subpixel_sprites: wgpu::BindGroupLayout, + textured_instances: wgpu::BindGroupLayout, + pub(super) surfaces: wgpu::BindGroupLayout, + pub(super) blur: wgpu::BindGroupLayout, +} + +impl WgpuBindGroupLayouts { + pub(super) fn new(device: &wgpu::Device, tier: RendererTier) -> Self { + let ( + instance_table, + monochrome_table, + subpixel_table, + textured_table, + surface_table, + blur_table, + instance_dynamic, + ) = match tier { + RendererTier::Modern => ( + INSTANCE_BINDINGS, + MONOCHROME_INSTANCE_BINDINGS, + SUBPIXEL_INSTANCE_BINDINGS, + TEXTURED_INSTANCE_BINDINGS, + SURFACE_BINDINGS, + BLUR_BINDINGS, + None, + ), + RendererTier::WebGl2 => ( + DOWNLEVEL_INSTANCE_BINDINGS, + DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, + DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, + DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, + DOWNLEVEL_SURFACE_BINDINGS, + DOWNLEVEL_BLUR_BINDINGS, + Some(DOWNLEVEL_RANGE_BINDING), + ), + }; + let globals = generated_bind_group_layout(device, "globals_layout", GLOBAL_BINDINGS, None); + let instances = generated_bind_group_layout( + device, + "instances_layout", + instance_table, + instance_dynamic, + ); + let monochrome_sprites = generated_bind_group_layout( + device, + "monochrome_sprites_layout", + monochrome_table, + instance_dynamic, + ); + let subpixel_sprites = generated_bind_group_layout( + device, + "subpixel_sprites_layout", + subpixel_table, + instance_dynamic, + ); + let textured_instances = generated_bind_group_layout( + device, + "textured_instances_layout", + textured_table, + instance_dynamic, + ); + let surfaces = generated_bind_group_layout( + device, + "surfaces_layout", + surface_table, + Some(shader::DATA_BUFFER_BINDING), + ); + let blur = generated_bind_group_layout( + device, + "blur_layout", + blur_table, + Some(shader::DATA_BUFFER_BINDING), + ); + Self { + globals, + instances, + monochrome_sprites, + subpixel_sprites, + textured_instances, + surfaces, + blur, + } + } + + pub(super) fn create_globals( + &self, + device: &wgpu::Device, + label: &str, + globals: wgpu::BufferBinding, + font_rasterization: wgpu::BufferBinding, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout: &self.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: shader::GLOBAL_UNIFORMS_BINDING, + resource: wgpu::BindingResource::Buffer(globals), + }, + wgpu::BindGroupEntry { + binding: shader::FONT_RASTERIZATION_BINDING, + resource: wgpu::BindingResource::Buffer(font_rasterization), + }, + ], + }) + } + + /// Creates the group-1 data bind group for the tier's binding source. + pub(super) fn create_instances( + &self, + device: &wgpu::Device, + source: InstanceBindingSource<'_>, + ) -> wgpu::BindGroup { + let entries = instance_binding_entries(source); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("instances"), + layout: &self.instances, + entries: &entries, + }) + } + + pub(super) fn create_textured_instances( + &self, + device: &wgpu::Device, + data_layout: shader::DataLayout, + source: InstanceBindingSource<'_>, + texture: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + let mut entries = instance_binding_entries(source); + entries.push(wgpu::BindGroupEntry { + binding: shader::PRIMARY_TEXTURE_BINDING, + resource: wgpu::BindingResource::TextureView(texture), + }); + entries.push(wgpu::BindGroupEntry { + binding: shader::PRIMARY_SAMPLER_BINDING, + resource: wgpu::BindingResource::Sampler(sampler), + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("textured_instances"), + layout: match data_layout { + shader::DataLayout::MonochromeSprites => &self.monochrome_sprites, + shader::DataLayout::SubpixelSprites => &self.subpixel_sprites, + shader::DataLayout::TexturedInstances => &self.textured_instances, + _ => panic!("{data_layout:?} does not use a textured instance layout"), + }, + entries: &entries, + }) + } + + #[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + ))] + pub(super) fn create_surface( + &self, + device: &wgpu::Device, + uniforms: wgpu::BufferBinding, + color: &wgpu::TextureView, + chroma: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("surface"), + layout: &self.surfaces, + entries: &[ + wgpu::BindGroupEntry { + binding: shader::DATA_BUFFER_BINDING, + resource: wgpu::BindingResource::Buffer(uniforms), + }, + wgpu::BindGroupEntry { + binding: shader::PRIMARY_TEXTURE_BINDING, + resource: wgpu::BindingResource::TextureView(color), + }, + wgpu::BindGroupEntry { + binding: shader::SECONDARY_TEXTURE_BINDING, + resource: wgpu::BindingResource::TextureView(chroma), + }, + wgpu::BindGroupEntry { + binding: shader::SURFACE_SAMPLER_BINDING, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) + } + + pub(super) fn create_blur( + &self, + device: &wgpu::Device, + uniforms: wgpu::BufferBinding, + texture: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("blur"), + layout: &self.blur, + entries: &[ + wgpu::BindGroupEntry { + binding: shader::DATA_BUFFER_BINDING, + resource: wgpu::BindingResource::Buffer(uniforms), + }, + wgpu::BindGroupEntry { + binding: shader::PRIMARY_TEXTURE_BINDING, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: shader::PRIMARY_SAMPLER_BINDING, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) + } +} + +impl WgpuPipelines { + /// Prepare before encoding, while the renderer can still mutably borrow its + /// resources. Ordinary frames do no GPU work here. Native failures discard + /// only this attempt's pipelines; the caller applies the frame retry policy. + pub(super) fn prepare_smoothing( + &mut self, + device: &wgpu::Device, + required: SmoothingRequirements, + ) -> Result<(), wgpu::Error> { + let missing = SmoothingRequirements { + quads: required.quads && self.smoothed_quads.is_none(), + shadows: required.shadows && self.smoothed_shadows.is_none(), + polychrome_sprites: required.polychrome_sprites + && self.smoothed_polychrome_sprites.is_none(), + blur_composite: required.blur_composite && self.smoothed_blur_composite.is_none(), + }; + if missing.is_empty() { + return Ok(()); + } + + // WebGPU reports validation asynchronously through the existing device + // callback. Native backends can reject failed creation before encoding. + #[cfg(not(target_family = "wasm"))] + let scopes = [ + device.push_error_scope(wgpu::ErrorFilter::Validation), + device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), + device.push_error_scope(wgpu::ErrorFilter::Internal), + ]; + if missing.quads { + self.smoothed_quads = Some(create_render_pipeline( + device, + shader::SMOOTHED_QUADS, + &self.instance_layout, + &self.smoothing_scene_target, + 1, + &self.base_shader, + )); + } + if missing.shadows { + self.smoothed_shadows = Some(create_render_pipeline( + device, + shader::SMOOTHED_SHADOWS, + &self.instance_layout, + &self.smoothing_scene_target, + 1, + &self.base_shader, + )); + } + if missing.polychrome_sprites { + self.smoothed_polychrome_sprites = Some(create_render_pipeline( + device, + shader::SMOOTHED_POLYCHROME_SPRITES, + &self.smoothing_textured_layout, + &self.smoothing_scene_target, + 1, + &self.base_shader, + )); + } + if missing.blur_composite { + self.smoothed_blur_composite = Some(create_render_pipeline( + device, + shader::SMOOTHED_BLUR_COMPOSITE, + &self.smoothing_blur_layout, + &self.smoothing_composite_target, + 1, + &self.base_shader, + )); + } + #[cfg(not(target_family = "wasm"))] + { + let mut creation_error = None; + // Preserve fatal errors over validation errors, and always pop all + // scopes even when several pipeline creations failed. + for scope in scopes.into_iter().rev() { + if let Some(error) = gpui::block_on(scope.pop()) { + creation_error.get_or_insert(error); + } + } + if let Some(error) = creation_error { + if missing.quads { + self.smoothed_quads = None; + } + if missing.shadows { + self.smoothed_shadows = None; + } + if missing.polychrome_sprites { + self.smoothed_polychrome_sprites = None; + } + if missing.blur_composite { + self.smoothed_blur_composite = None; + } + return Err(error); + } + } + Ok(()) + } + + pub(super) fn ensure_damage_clear(&mut self, device: &wgpu::Device) { + if self.damage_clear_quads.is_none() { + self.damage_clear_quads = Some(create_render_pipeline( + device, + shader::QUADS, + &self.instance_layout, + &color_target(self.damage_clear_format, None), + 1, + &self.base_shader, + )); + } + } + + pub(super) fn new( + device: &wgpu::Device, + bind_group_layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + tier: RendererTier, + ) -> Self { + let dual_source_blending = tier == RendererTier::Modern + && supported_dual_source_blending(device, dual_source_blending); + let base_source = match tier { + RendererTier::Modern => BASE_WGSL, + // GLES lacks vertex-stage storage; the dialect reads the data texture. + RendererTier::WebGl2 => BASE_DOWNLEVEL_WGSL, + }; + let shader_module = create_shader_module(device, "gpui_shaders", base_source); + let subpixel_shader_module = dual_source_blending.then(|| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_subpixel_shaders"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( + SUBPIXEL_DUAL_SOURCE_WGSL, + )), + }) + }); + + let instance_layout = create_pipeline_layout( + device, + "instance_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.instances, + ); + let monochrome_layout = create_pipeline_layout( + device, + "monochrome_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.monochrome_sprites, + ); + let subpixel_layout = create_pipeline_layout( + device, + "subpixel_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.subpixel_sprites, + ); + let textured_layout = create_pipeline_layout( + device, + "textured_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.textured_instances, + ); + let surface_layout = create_pipeline_layout( + device, + "surface_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.surfaces, + ); + let blur_layout = create_pipeline_layout( + device, + "blur_pipeline_layout", + bind_group_layouts, + &bind_group_layouts.blur, + ); + + let scene_target = color_target(surface_format, Some(scene_blend_state(alpha_mode))); + let path_rasterization_target = color_target( + surface_format, + Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + ); + let path_target = color_target(surface_format, Some(path_blend_state())); + let overwrite_target = color_target(surface_format, None); + let composite_target = + color_target(surface_format, Some(premultiplied_composite_blend_state())); + + let layout_for = |data_layout| match data_layout { + shader::DataLayout::Instances => &instance_layout, + shader::DataLayout::TexturedInstances => &textured_layout, + shader::DataLayout::MonochromeSprites => &monochrome_layout, + shader::DataLayout::SubpixelSprites => &subpixel_layout, + shader::DataLayout::NativeOnly => { + panic!("native-only shader pipeline cannot be created by wgpu") + } + shader::DataLayout::Surface => &surface_layout, + shader::DataLayout::Blur => &blur_layout, + }; + let create = + |specification: shader::Pipeline, target: &wgpu::ColorTargetState, samples, module| { + create_render_pipeline( + device, + specification, + layout_for(specification.data_layout), + target, + samples, + module, + ) + }; + + Self { + quads: create(shader::QUADS, &scene_target, 1, &shader_module), + damage_clear_quads: None, + smoothed_quads: None, + shadows: create(shader::SHADOWS, &scene_target, 1, &shader_module), + smoothed_shadows: None, + path_rasterization: create( + shader::PATH_RASTERIZATION, + &path_rasterization_target, + path_sample_count, + &shader_module, + ), + paths: create(shader::PATHS, &path_target, 1, &shader_module), + underlines: create(shader::UNDERLINES, &scene_target, 1, &shader_module), + monochrome_sprites: create( + shader::MONOCHROME_SPRITES, + &scene_target, + 1, + &shader_module, + ), + subpixel_sprites: subpixel_shader_module.as_ref().map(|module| { + create( + shader::SUBPIXEL_SPRITES, + &wgpu::ColorTargetState { + write_mask: wgpu::ColorWrites::COLOR, + ..color_target(surface_format, Some(subpixel_blend_state())) + }, + 1, + module, + ) + }), + polychrome_sprites: create( + shader::POLYCHROME_SPRITES, + &scene_target, + 1, + &shader_module, + ), + smoothed_polychrome_sprites: None, + surfaces: create(shader::SURFACES, &composite_target, 1, &shader_module), + blur_downsample: create( + shader::BLUR_DOWNSAMPLE, + &overwrite_target, + 1, + &shader_module, + ), + blur: create(shader::BLUR, &overwrite_target, 1, &shader_module), + blur_composite: create(shader::BLUR_COMPOSITE, &composite_target, 1, &shader_module), + smoothed_blur_composite: None, + smoothing_textured_layout: textured_layout, + smoothing_blur_layout: blur_layout, + smoothing_scene_target: scene_target, + smoothing_composite_target: composite_target, + base_shader: shader_module, + instance_layout: instance_layout, + damage_clear_format: surface_format, + } + } +} + +fn generated_bind_group_layout( + device: &wgpu::Device, + label: &str, + bindings: &[GeneratedBinding], + dynamic_uniform_binding: Option, +) -> wgpu::BindGroupLayout { + let entries = bindings + .iter() + .map(|binding| wgpu::BindGroupLayoutEntry { + binding: binding.binding, + visibility: wgpu::ShaderStages::from_bits_retain(binding.visibility), + ty: match binding.kind { + GeneratedBindingKind::Uniform(min_size) => wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: dynamic_uniform_binding == Some(binding.binding), + min_binding_size: NonZeroU64::new(min_size), + }, + GeneratedBindingKind::StorageRead(min_size) => wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(min_size), + }, + GeneratedBindingKind::Texture2dFloat => wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + GeneratedBindingKind::FilteringSampler => { + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering) + } + GeneratedBindingKind::DataTexture => wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Uint, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + GeneratedBindingKind::RangeUniform => wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + // The generated `DATA_RANGE` batch base, padded to satisfy + // browser/WebGL downlevel uniform-binding alignment. + min_binding_size: NonZeroU64::new(16), + }, + }, + count: None, + }) + .collect::>(); + + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries: &entries, + }) +} + +fn create_shader_module( + device: &wgpu::Device, + label: &str, + source: &'static str, +) -> wgpu::ShaderModule { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(source)), + }) +} + +fn create_pipeline_layout( + device: &wgpu::Device, + label: &str, + layouts: &WgpuBindGroupLayouts, + data_layout: &wgpu::BindGroupLayout, +) -> wgpu::PipelineLayout { + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[Some(&layouts.globals), Some(data_layout)], + immediate_size: 0, + }) +} + +fn create_render_pipeline( + device: &wgpu::Device, + specification: shader::Pipeline, + layout: &wgpu::PipelineLayout, + color_target: &wgpu::ColorTargetState, + sample_count: u32, + module: &wgpu::ShaderModule, +) -> WgpuRenderPipeline { + let raw = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(specification.label), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(specification.vertex_entry), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(specification.fragment_entry), + targets: &[Some(color_target.clone())], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: match specification.topology { + shader::PrimitiveTopology::TriangleList => wgpu::PrimitiveTopology::TriangleList, + shader::PrimitiveTopology::TriangleStrip => wgpu::PrimitiveTopology::TriangleStrip, + }, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: sample_count, + ..Default::default() + }, + multiview_mask: None, + cache: None, + }); + WgpuRenderPipeline { raw, specification } +} + +fn color_target( + format: wgpu::TextureFormat, + blend: Option, +) -> wgpu::ColorTargetState { + wgpu::ColorTargetState { + format, + blend, + write_mask: wgpu::ColorWrites::ALL, + } +} + +fn supported_dual_source_blending(device: &wgpu::Device, requested: bool) -> bool { + let supported = device + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + if requested && !supported { + log::error!( + "dual-source blending was requested but is absent from device features {:?}; falling \ + back to monochrome text rendering", + device.features(), + ); + } + requested && supported +} + +fn scene_blend_state(alpha_mode: wgpu::CompositeAlphaMode) -> wgpu::BlendState { + let source_color_factor = if alpha_mode == wgpu::CompositeAlphaMode::PreMultiplied { + wgpu::BlendFactor::One + } else { + wgpu::BlendFactor::SrcAlpha + }; + source_over_blend_state(source_color_factor) +} + +fn premultiplied_composite_blend_state() -> wgpu::BlendState { + source_over_blend_state(wgpu::BlendFactor::One) +} + +fn source_over_blend_state(source_color_factor: wgpu::BlendFactor) -> wgpu::BlendState { + wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: source_color_factor, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + } +} + +fn path_blend_state() -> wgpu::BlendState { + source_over_blend_state(wgpu::BlendFactor::One) +} + +fn subpixel_blend_state() -> wgpu::BlendState { + wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Src1, + dst_factor: wgpu::BlendFactor::OneMinusSrc1, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + } +} + +#[cfg(all(test, any(target_os = "macos", target_os = "windows")))] +pub(super) fn desktop_scene_blend_state(alpha_mode: wgpu::CompositeAlphaMode) -> wgpu::BlendState { + scene_blend_state(alpha_mode) +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use crate::WgpuContext; + + #[test] + fn smoothing_creation_failure_rolls_back_only_new_pipelines() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let tier = context.renderer_tier(); + let layouts = WgpuBindGroupLayouts::new(&context.device, tier); + let mut pipelines = WgpuPipelines::new( + &context.device, + &layouts, + context.color_texture_format(), + wgpu::CompositeAlphaMode::Opaque, + 1, + false, + tier, + ); + pipelines.prepare_smoothing( + &context.device, + SmoothingRequirements { + quads: true, + ..Default::default() + }, + )?; + let quads = pipelines.smoothed_quads.as_ref().unwrap().raw.clone(); + let target = pipelines.smoothing_scene_target.clone(); + pipelines.smoothing_scene_target.format = wgpu::TextureFormat::Depth24Plus; + let requirements = SmoothingRequirements { + shadows: true, + ..Default::default() + }; + let error = pipelines + .prepare_smoothing(&context.device, requirements) + .unwrap_err(); + assert!(matches!(error, wgpu::Error::Validation { .. })); + assert!(pipelines.smoothed_shadows.is_none()); + assert_eq!(pipelines.smoothed_quads.as_ref().unwrap().raw, quads); + assert!( + context.uncaptured_error_slot().lock().unwrap().is_none(), + "checked failure must not leak into an unrelated frame" + ); + pipelines.smoothing_scene_target = target; + pipelines.prepare_smoothing(&context.device, requirements)?; + assert!( + pipelines.smoothed_shadows.is_some(), + "a failed first use can retry" + ); + Ok(()) + } + + #[cfg(all(feature = "test-support", target_os = "windows"))] + #[test] + fn lazy_smoothing_failures_follow_dx12_frame_retry_policy() -> anyhow::Result<()> { + use super::super::WgpuRenderer; + use gpui::{Bounds, ContentMask, DevicePixels, Quad, ScaledPixels, Scene, point, size}; + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let mut renderer = + WgpuRenderer::new_headless(&context, size(DevicePixels(32), DevicePixels(32)))?; + renderer.native_backend = Some(crate::NativeBackend::Dx12); + renderer + .resources_mut() + .pipelines + .smoothing_scene_target + .format = wgpu::TextureFormat::Depth24Plus; + let bounds = Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(32.0), ScaledPixels(32.0)), + ); + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds, + content_mask: ContentMask { + bounds, + ..Default::default() + }, + background: gpui::white().into(), + corner_smoothing: 0.65, + ..Default::default() + }); + scene.finish(); + assert!(renderer.render_to_image(&scene).is_err()); + assert!(renderer.resources().pipelines.smoothed_quads.is_none()); + assert!(renderer.faults.pending_error.lock().unwrap().is_some()); + assert!(renderer.needs_redraw()); + for _ in 0..20 { + assert!(renderer.render_to_image(&scene).is_err()); + if renderer.terminal_error().is_some() { + break; + } + } + assert!( + renderer + .terminal_error() + .is_some_and(|message| message.contains("smoothing pipelines")), + "repeated creation failure must reach the existing terminal policy" + ); + assert!(renderer.resources().pipelines.smoothed_quads.is_none()); + Ok(()) + } + + #[test] + fn generated_layouts_create_every_pipeline_and_bind_group() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + let device = &context.device; + let tier = context.renderer_tier(); + let layouts = WgpuBindGroupLayouts::new(device, tier); + let mut pipelines = WgpuPipelines::new( + device, + &layouts, + context.color_texture_format(), + wgpu::CompositeAlphaMode::Opaque, + 1, + context.supports_dual_source_blending(), + tier, + ); + pipelines.prepare_smoothing( + device, + SmoothingRequirements { + quads: true, + shadows: true, + polychrome_sprites: true, + blur_composite: true, + }, + )?; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("shader_interface_test_buffer"), + size: 256, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("shader_interface_test_texture"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + let binding = || wgpu::BufferBinding { + buffer: &buffer, + offset: 0, + size: NonZeroU64::new(256), + }; + + let _globals = layouts.create_globals(device, "test_globals", binding(), binding()); + let _instances = layouts.create_instances(device, InstanceBindingSource::Buffer(binding())); + let _textured = layouts.create_textured_instances( + device, + shader::DataLayout::MonochromeSprites, + InstanceBindingSource::Buffer(binding()), + &view, + &sampler, + ); + #[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + ))] + let _surface = layouts.create_surface(device, binding(), &view, &view, &sampler); + let _blur = layouts.create_blur(device, binding(), &view, &sampler); + Ok(()) + } + + /// The downlevel artifacts must also build and bind against a real device. + #[test] + fn downlevel_tier_builds_pipelines_and_data_texture_arena() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + use super::super::buffers::{InstanceBufferArena, InstanceTransport}; + use gpui::Quad; + + let context = WgpuContext::new_headless(None)?; + let device = &context.device; + let tier = crate::RendererTier::WebGl2; + let layouts = WgpuBindGroupLayouts::new(device, tier); + let mut pipelines = WgpuPipelines::new( + device, + &layouts, + context.color_texture_format(), + wgpu::CompositeAlphaMode::Opaque, + 1, + false, + tier, + ); + assert!(pipelines.quads.fixed_vertex_count() > 0); + pipelines.prepare_smoothing( + device, + SmoothingRequirements { + quads: true, + shadows: true, + polychrome_sprites: true, + blur_composite: true, + }, + )?; + + let mut arena = InstanceBufferArena::new(device, &layouts, tier); + assert_eq!(arena.transport(), InstanceTransport::DataTexture); + arena + .ensure_capacity(device, &layouts, 4096, 8) + .then_some(()) + .expect("small downlevel capacities must always be available"); + let mut upload = arena + .begin_upload(&context.queue, 4096, 8) + .expect("mapped downlevel staging"); + + let custom_dash = Quad { + border_dashed_length: 4.0, + border_dashed_gap: 0.5, + ..Quad::default() + }; + let slice = upload + .write(&[custom_dash, Quad::default()]) + .expect("small batch must fit"); + // Downlevel draws start at instance zero; the base travels in the range offset. + assert_eq!(slice.range(), 0..2); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("downlevel_test_texture"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + let _textured = layouts.create_textured_instances( + device, + shader::DataLayout::MonochromeSprites, + arena.binding_source(), + &view, + &sampler, + ); + + let mut encoder = + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + upload.finish(&mut encoder); + let _commands = encoder.finish(); + Ok(()) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs b/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs new file mode 100644 index 000000000..9386961d8 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs @@ -0,0 +1,715 @@ +#[cfg(not(target_family = "wasm"))] +use std::rc::Rc; +use std::sync::Arc; + +use gpui::Scene; + +#[cfg(not(target_family = "wasm"))] +use crate::{CompositorGpuHint, NativeBackend, SoftwareAdapterPolicy, WgpuDeviceRequirements}; +use crate::{WgpuAtlas, WgpuContext}; + +#[cfg(not(target_family = "wasm"))] +use super::GpuContext; +use super::{WgpuRenderer, WgpuSurfaceConfig}; + +/// Recovery is waiting for the driver to stabilize; no recreation was attempted. +#[derive(Debug)] +pub struct RecoveryPending; +impl std::fmt::Display for RecoveryPending { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("waiting for the GPU driver to stabilize before recovery") + } +} +impl std::error::Error for RecoveryPending {} + +#[cfg(not(target_family = "wasm"))] +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; + +#[cfg(all(test, target_os = "linux"))] +#[path = "surface_tests.rs"] +mod tests; + +impl WgpuRenderer { + /// Creates a renderer whose surface and GPU context follow the native window lifetime. + #[cfg(not(target_family = "wasm"))] + pub fn new( + gpu_context: GpuContext, + window: &W, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + extra_requirements: Option, + ) -> anyhow::Result + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let window_handle = window + .window_handle() + .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; + Self::new_for_target( + gpu_context, + &|| Some(Box::new(window.clone())), + NativeSurfaceTarget::Window(window_handle.as_raw()), + config, + compositor_gpu, + extra_requirements, + None, + ) + } + + /// Creates a Windows renderer using hardware DX12 only, including during recovery. + #[cfg(target_os = "windows")] + pub fn new_dx12( + gpu_context: GpuContext, + window: &W, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + extra_requirements: Option, + ) -> anyhow::Result + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let handle = window + .window_handle() + .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; + Self::new_for_target( + gpu_context, + &|| Some(Box::new(window.clone())), + NativeSurfaceTarget::Window(handle.as_raw()), + config, + compositor_gpu, + extra_requirements, + Some(NativeBackend::Dx12), + ) + } + + /// Creates a renderer presenting to a `CAMetalLayer`, such as the backing + /// layer of a layer-backed `NSView`. The surface retains the layer. + #[cfg(target_os = "macos")] + pub fn new_for_metal_layer( + gpu_context: GpuContext, + layer: &metal::MetalLayerRef, + config: WgpuSurfaceConfig, + extra_requirements: Option, + ) -> anyhow::Result { + Self::new_for_target( + gpu_context, + &|| None, + NativeSurfaceTarget::metal_layer(layer), + config, + None, + extra_requirements, + None, + ) + } + + #[cfg(not(target_family = "wasm"))] + fn new_for_target( + gpu_context: GpuContext, + display: DisplayHandleSource<'_>, + surface_target: NativeSurfaceTarget, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + extra_requirements: Option, + backend: Option, + ) -> anyhow::Result { + let mut context_slot = gpu_context.borrow_mut(); + let (context, surface) = match context_slot.as_mut() { + Some(context) => { + let surface = create_surface(&context.instance, surface_target)?; + context.check_compatible_with_surface(&surface)?; + (context, surface) + } + None => { + let (context, surface) = initialize_context_and_surface( + display, + surface_target, + compositor_gpu, + if backend.is_some() { + SoftwareAdapterPolicy::Reject + } else { + SoftwareAdapterPolicy::Allow + }, + extra_requirements.as_ref(), + backend, + )?; + (context_slot.insert(context), surface) + } + }; + let mut renderer = initialize_checked(context, backend, || { + Self::new_internal( + Some(Rc::clone(&gpu_context)), + context, + Some(surface), + config, + compositor_gpu, + extra_requirements, + Arc::new(WgpuAtlas::from_context(context)), + ) + })?; + renderer.native_backend = backend; + Ok(renderer) + } + + #[cfg(target_family = "wasm")] + #[allow(clippy::arc_with_non_send_sync)] + pub fn new_from_surface( + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { + Self::new_internal( + None, + context, + Some(surface), + config, + None, + None, + Arc::new(WgpuAtlas::from_context(context)), + ) + } + + /// Acquires, renders, and presents one native or canvas surface frame. + pub fn draw(&mut self, scene: &Scene) -> bool { + if !self.target.is_configured() { + return false; + } + let frame = match self + .resources() + .surface + .as_ref() + .expect("draw requires a configured surface") + .get_current_texture() + { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + drop(frame); + self.reconfigure_surface(); + return false; + } + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + self.reconfigure_surface(); + return false; + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + self.target.request_redraw(); + return false; + } + wgpu::CurrentSurfaceTexture::Validation => { + self.target.request_redraw(); + // This frame never reaches render_to_view; account for its error here so + // persistent acquisition failures cannot bypass the terminal-error limit. + if self.reports_terminal_errors() { + super::frame::handle_gpu_error(self, "surface texture validation error".into()); + } else { + *self.faults.pending_error.lock().unwrap() = + Some("surface texture validation error".into()); + } + return false; + } + }; + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let rendered = self.render_to_texture(scene, &frame.texture, &view); + if rendered { + frame.present(); + } else { + // Vulkan's native swapchain cannot release an acquired image just + // by dropping it. Repeated failed frames would exhaust its images + // and make subsequent acquisitions time out until a resize. + drop(view); + drop(frame); + self.reconfigure_surface(); + } + rendered + } + + fn reconfigure_surface(&mut self) { + self.target.request_redraw(); + let config = self.target.configuration().clone(); + let resources = self.resources_mut(); + resources + .surface + .as_ref() + .expect("draw requires a configured surface") + .configure(&resources.device, &config); + } + + /// Keeps device resources alive while detaching a destroyed native surface. + pub fn unconfigure_surface(&mut self) { + self.release_retained_frame(); + self.target.set_configured(false); + if let Some(resources) = self.resources.as_mut() { + resources.invalidate_intermediate_textures(); + } + } + + #[cfg(not(target_family = "wasm"))] + pub fn replace_surface( + &mut self, + window: &W, + config: WgpuSurfaceConfig, + instance: &wgpu::Instance, + ) -> anyhow::Result<()> { + let window_handle = window + .window_handle() + .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; + let surface = create_surface( + instance, + NativeSurfaceTarget::Window(window_handle.as_raw()), + )?; + // A replacement surface can expose different present modes (for example when a + // window moves between displays or Wayland/X11 surfaces). Query the new surface, + // rather than reusing capabilities from the old target. + let capabilities = { + let context_slot = self.context.as_ref().ok_or_else(|| { + anyhow::anyhow!("native surface replacement requires a GPU context") + })?; + let context_slot = context_slot.borrow(); + let context = context_slot.as_ref().ok_or_else(|| { + anyhow::anyhow!("native surface replacement requires an initialized GPU context") + })?; + let capabilities = surface.get_capabilities(&context.adapter); + if capabilities.formats.is_empty() { + let info = context.adapter.get_info(); + anyhow::bail!( + "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the display surface for this window.", + info.name, + info.backend, + info.device, + ); + } + capabilities + }; + if self.target.apply(config, &capabilities)? { + self.rebuild_pipelines(); + } + let target_config = self.target.configuration().clone(); + + let resources = self + .resources + .as_mut() + .expect("GPU resources not available"); + surface.configure(&resources.device, &target_config); + resources.surface = Some(surface); + resources.invalidate_intermediate_textures(); + self.target.set_configured(true); + Ok(()) + } + + pub fn destroy(&mut self) { + self.retained.invalidate(); + self.resources.take(); + } + + pub fn device_lost(&self) -> bool { + self.faults + .device_lost + .load(std::sync::atomic::Ordering::Relaxed) + } + + pub fn needs_redraw(&mut self) -> bool { + self.target.take_redraw_request() + } + + #[cfg(not(target_family = "wasm"))] + pub fn recover(&mut self, window: &W) -> anyhow::Result<()> + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let window_handle = window + .window_handle() + .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; + self.recover_target( + &|| Some(Box::new(window.clone())), + NativeSurfaceTarget::Window(window_handle.as_raw()), + ) + } + + /// Recovers a renderer made by [`Self::new_for_metal_layer`]. + #[cfg(target_os = "macos")] + pub fn recover_metal_layer(&mut self, layer: &metal::MetalLayerRef) -> anyhow::Result<()> { + self.recover_target(&|| None, NativeSurfaceTarget::metal_layer(layer)) + } + + #[cfg(not(target_family = "wasm"))] + fn recover_target( + &mut self, + display: DisplayHandleSource<'_>, + surface_target: NativeSurfaceTarget, + ) -> anyhow::Result<()> { + let gpu_context = self.context.as_ref().expect("recover requires gpu_context"); + let needs_new_context = gpu_context + .borrow() + .as_ref() + .is_none_or(WgpuContext::device_lost); + if needs_new_context { + let now = std::time::Instant::now(); + match self.faults.recovery_not_before { + None => { + self.faults.recovery_not_before = + Some(now + std::time::Duration::from_millis(350)); + return Err(RecoveryPending.into()); + } + Some(not_before) if now < not_before => { + return Err(RecoveryPending.into()); + } + Some(_) => self.faults.recovery_not_before = None, + } + } else { + self.faults.recovery_not_before = None; + } + + let surface = if needs_new_context { + log::warn!("GPU device lost, recreating context..."); + self.resources = None; + *gpu_context.borrow_mut() = None; + let (new_context, surface) = match initialize_context_and_surface( + display, + surface_target, + self.compositor_gpu, + SoftwareAdapterPolicy::Reject, + self.extra_requirements.as_ref(), + self.native_backend, + ) { + Ok(result) => result, + Err(error) => { + self.faults.recovery_not_before = + Some(std::time::Instant::now() + std::time::Duration::from_millis(350)); + return Err(error); + } + }; + *gpu_context.borrow_mut() = Some(new_context); + surface + } else { + let context_slot = gpu_context.borrow(); + let instance = &context_slot + .as_ref() + .expect("a recovered context must exist") + .instance; + create_surface(instance, surface_target)? + }; + + let config = self.target.recovery_config(); + let gpu_context = Rc::clone(gpu_context); + let context_slot = gpu_context.borrow(); + let context = context_slot.as_ref().expect("context should exist"); + self.resources = None; + + let font_rasterization = self.rendering_params.font_rasterization; + let subpixel_order = self.subpixel_order; + let mut recovered = initialize_checked(context, self.native_backend, || { + self.atlas.handle_device_lost(context); + Self::new_internal( + Some(Rc::clone(&gpu_context)), + context, + Some(surface), + config, + self.compositor_gpu, + self.extra_requirements.clone(), + Arc::clone(&self.atlas), + ) + })?; + recovered.native_backend = self.native_backend; + recovered.set_font_rasterization_settings(font_rasterization); + recovered.set_subpixel_order(subpixel_order); + *self = recovered; + log::info!("GPU recovery complete"); + Ok(()) + } + + #[cfg(target_os = "macos")] + pub fn set_presents_with_transaction(&self, enabled: bool) { + let Some(surface) = self + .resources + .as_ref() + .and_then(|resources| resources.surface.as_ref()) + else { + return; + }; + if let Some(surface) = unsafe { surface.as_hal::() } { + surface + .render_layer() + .lock() + .setPresentsWithTransaction(enabled); + } + } +} + +/// Where a native renderer presents its frames. +#[cfg(not(target_family = "wasm"))] +#[derive(Clone, Copy)] +enum NativeSurfaceTarget { + Window(raw_window_handle::RawWindowHandle), + /// A live `CAMetalLayer`, which the surface retains. + #[cfg(target_os = "macos")] + MetalLayer(*mut std::ffi::c_void), +} + +#[cfg(target_os = "macos")] +impl NativeSurfaceTarget { + fn metal_layer(layer: &metal::MetalLayerRef) -> Self { + use metal::foreign_types::ForeignTypeRef as _; + Self::MetalLayer(layer.as_ptr().cast()) + } +} + +/// The display handle a new wgpu instance is created with, if any; called once +/// per backend attempted. +#[cfg(not(target_family = "wasm"))] +type DisplayHandleSource<'a> = &'a dyn Fn() -> Option>; + +#[cfg(not(target_family = "wasm"))] +// Startup and recovery both reject invalid resources before declaring the renderer usable. +fn initialize_checked( + context: &WgpuContext, + backend: Option, + initialize: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + if backend.is_none() { + return initialize(); + } + let scopes = [ + context + .device + .push_error_scope(wgpu::ErrorFilter::Validation), + context + .device + .push_error_scope(wgpu::ErrorFilter::OutOfMemory), + context.device.push_error_scope(wgpu::ErrorFilter::Internal), + ]; + let result = initialize(); + let mut initialization_error = None; + for scope in scopes.into_iter().rev() { + if let Some(error) = gpui::block_on(scope.pop()) { + initialization_error.get_or_insert_with(|| error.to_string()); + } + } + if let Some(error) = + initialization_error.or_else(|| context.fatal_error_slot().lock().unwrap().clone()) + { + anyhow::bail!("DX12 renderer initialization failed: {error}"); + } + result +} + +#[cfg(all(test, feature = "test-support", target_os = "windows"))] +mod initialization_tests { + use super::*; + use std::{cell::Cell, rc::Rc}; + + #[test] + fn invalid_pipeline_is_rejected_and_partial_resources_drop_before_failure_returns() { + struct PartialResources { + _pipeline: wgpu::RenderPipeline, + dropped: Rc>, + } + impl Drop for PartialResources { + fn drop(&mut self) { + self.dropped.set(true); + } + } + let context = WgpuContext::new_headless(None).unwrap(); + let dropped = Rc::new(Cell::new(false)); + let result = initialize_checked(&context, Some(NativeBackend::Dx12), || { + let shader = context + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("recovery regression shader"), + source: wgpu::ShaderSource::Wgsl( + "@vertex fn main() -> @builtin(position) vec4 { return vec4(0.0); }" + .into(), + ), + }); + let pipeline = context + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("injected invalid recovery pipeline"), + layout: None, + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("missing_entry_point"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: None, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + Ok(PartialResources { + _pipeline: pipeline, + dropped: dropped.clone(), + }) + }); + let Err(error) = result else { + panic!("invalid DX12 pipeline was accepted") + }; + assert!( + error + .to_string() + .contains("DX12 renderer initialization failed") + ); + assert!(dropped.get()); + assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); + // All scopes are drained, so a later healthy recreation can succeed. + assert_eq!( + initialize_checked(&context, Some(NativeBackend::Dx12), || Ok(12)).unwrap(), + 12 + ); + } + + #[test] + fn fatal_callbacks_are_checked_only_for_explicit_dx12_initialization() { + let context = WgpuContext::new_headless(None).unwrap(); + let fatal = context.fatal_error_slot(); + *fatal.lock().unwrap() = Some("injected out of memory".into()); + assert_eq!(initialize_checked(&context, None, || Ok(11)).unwrap(), 11); + let error = initialize_checked(&context, Some(NativeBackend::Dx12), || Ok(12)).unwrap_err(); + assert!(error.to_string().contains("out of memory")); + assert!(fatal.try_lock().is_ok()); + } +} + +#[cfg(not(target_family = "wasm"))] +fn initialize_context_and_surface( + display: DisplayHandleSource<'_>, + surface_target: NativeSurfaceTarget, + compositor_gpu: Option, + adapter_policy: SoftwareAdapterPolicy, + extra_requirements: Option<&WgpuDeviceRequirements>, + backend: Option, +) -> anyhow::Result<(WgpuContext, wgpu::Surface<'static>)> { + let attempt = |backend: NativeBackend| { + let instance = backend.instance(display()); + let surface = create_surface(&instance.raw, surface_target)?; + let context = WgpuContext::new_with_adapter_policy( + instance, + &surface, + compositor_gpu, + adapter_policy, + extra_requirements, + )?; + Ok((context, surface)) + }; + match backend { + Some(backend) => attempt(backend), + None => NativeBackend::try_in_preference_order("a GPU context for the window", attempt), + } +} + +#[cfg(not(target_family = "wasm"))] +fn create_surface( + instance: &wgpu::Instance, + surface_target: NativeSurfaceTarget, +) -> anyhow::Result> { + let target = match surface_target { + NativeSurfaceTarget::Window(raw_window_handle) => wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: None, + raw_window_handle, + }, + #[cfg(target_os = "macos")] + NativeSurfaceTarget::MetalLayer(layer) => { + wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer) + } + }; + // SAFETY: window handles come from live windows. Metal layers come from a + // `MetalLayerRef` borrowed for this call, and WGPU retains the layer. + unsafe { + instance + .create_surface_unsafe(target) + .map_err(|error| anyhow::anyhow!("failed to create surface: {error}")) + } +} + +#[cfg(all(test, target_os = "macos", feature = "test-support"))] +mod tests { + use std::sync::atomic::Ordering; + use std::time::Duration; + + use gpui::{Bounds, DevicePixels, Point, Quad, ScaledPixels, Size}; + + use super::*; + + fn renderer( + context: &GpuContext, + layer: &metal::MetalLayerRef, + ) -> anyhow::Result { + WgpuRenderer::new_for_metal_layer( + Rc::clone(context), + layer, + WgpuSurfaceConfig { + size: Size { + width: DevicePixels(2), + height: DevicePixels(2), + }, + transparent: false, + preferred_present_mode: None, + }, + None, + ) + } + + fn red_scene() -> Scene { + let bounds = Bounds { + origin: Point { + x: ScaledPixels(0.0), + y: ScaledPixels(0.0), + }, + size: Size { + width: ScaledPixels(2.0), + height: ScaledPixels(2.0), + }, + }; + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds, + content_mask: gpui::ContentMask { + bounds, + ..Default::default() + }, + background: gpui::solid_background(gpui::red()), + ..Default::default() + }); + scene.finish(); + scene + } + + #[test] + fn metal_layer_renderers_recover_onto_one_new_device() -> anyhow::Result<()> { + let context = GpuContext::default(); + let (first_layer, second_layer) = (metal::MetalLayer::new(), metal::MetalLayer::new()); + let mut first = renderer(&context, &first_layer)?; + let mut second = renderer(&context, &second_layer)?; + let (lost_device, _) = first.gpu_context(); + assert!(Arc::ptr_eq(&lost_device, &second.gpu_context().0)); + + context + .borrow() + .as_ref() + .expect("the renderers' context") + .device_lost_flag() + .store(true, Ordering::Relaxed); + assert!(first.device_lost() && second.device_lost()); + + // The driver is given time to settle before the context is recreated. + assert!(first.recover_metal_layer(&first_layer).is_err()); + assert!(first.device_lost()); + std::thread::sleep(Duration::from_millis(400)); + first.recover_metal_layer(&first_layer)?; + // The other window joins the context the first one recreated. + second.recover_metal_layer(&second_layer)?; + + let (device, _) = first.gpu_context(); + assert!(!Arc::ptr_eq(&device, &lost_device)); + assert!(Arc::ptr_eq(&device, &second.gpu_context().0)); + assert!(!first.device_lost() && !second.device_lost()); + for renderer in [&mut first, &mut second] { + let image = renderer.render_to_image(&red_scene())?; + assert_eq!(image.get_pixel(1, 1).0, [255, 0, 0, 255]); + } + Ok(()) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs b/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs new file mode 100644 index 000000000..c012094b2 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs @@ -0,0 +1,433 @@ +use std::{ + cell::RefCell, + num::NonZeroU64, + sync::{Arc, Mutex}, +}; + +use collections::FxHashMap; +use gpui::ScenePlanRequirements; + +use crate::WgpuContext; +use gpui_render::blur::downsampled_dimension; +use gpui_render::path_types::path_target_extent; +use gpui_render::shaders::{ + blur::BlurUniforms, + common::{FontRasterizationUniforms, GlobalUniforms}, + surface::SurfaceUniforms, +}; + +use super::{ + WgpuRenderer, + buffers::{DynamicUniformBuffer, InstanceBufferArena}, + filters::FrameUniformRequirements, + pipelines::{WgpuBindGroupLayouts, WgpuPipelines}, + settings::RenderingParameters, + surfaces::SurfaceCache, +}; + +const INITIAL_FILTER_UNIFORM_CAPACITY: u64 = 16; +const INITIAL_SURFACE_UNIFORM_CAPACITY: u64 = 8; + +/// Device-owned state that is replaced atomically during GPU recovery. +pub(super) struct WgpuResources { + pub(super) device: Arc, + pub(super) queue: Arc, + pub(super) renderer_tier: crate::RendererTier, + pub(super) surface: Option>, + pub(super) pipelines: WgpuPipelines, + pub(super) bind_group_layouts: WgpuBindGroupLayouts, + pub(super) atlas_sampler: wgpu::Sampler, + pub(super) surface_sampler: wgpu::Sampler, + pub(super) surface_uniforms: DynamicUniformBuffer, + #[cfg_attr( + not(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + )), + allow(dead_code) + )] + pub(super) surface_cache: RefCell, + pub(super) filter_uniforms: DynamicUniformBuffer, + blur_bind_groups: RefCell, + pub(super) globals_buffer: wgpu::Buffer, + pub(super) globals_bind_group: wgpu::BindGroup, + pub(super) path_globals_bind_group: wgpu::BindGroup, + pub(super) instances: InstanceBufferArena, + pub(super) path_intermediate_texture: Option, + pub(super) path_intermediate_view: Option, + pub(super) path_msaa_texture: Option, + pub(super) path_msaa_view: Option, + pub(super) scene_color_texture: Option, + pub(super) scene_color_view: Option, + pub(super) backdrop_snapshot_texture: Option, + pub(super) backdrop_snapshot_view: Option, + pub(super) blur_ping_texture: Option, + pub(super) blur_ping_view: Option, + pub(super) blur_pong_texture: Option, + pub(super) blur_pong_view: Option, + pub(super) filter_group_textures: Vec, + pub(super) filter_group_views: Vec, +} + +#[derive(Default)] +struct BlurBindGroups { + uniform_generation: u64, + groups: FxHashMap, +} + +pub(super) struct ResourceMetadata { + pub(super) globals: GlobalBufferLayout, + pub(super) last_error: Arc>>, +} + +pub(super) struct GlobalBufferLayout { + pub(super) path_offset: u64, + pub(super) font_offset: u64, + pub(super) maximum_uniform_buffer_size: u64, +} + +impl WgpuResources { + pub(super) fn new( + context: &WgpuContext, + surface: Option>, + surface_config: &wgpu::SurfaceConfiguration, + rendering: &RenderingParameters, + dual_source_blending: bool, + ) -> anyhow::Result<(Self, ResourceMetadata)> { + let device = Arc::clone(&context.device); + let queue = Arc::clone(&context.queue); + let renderer_tier = context.renderer_tier(); + let bind_group_layouts = WgpuBindGroupLayouts::new(&device, renderer_tier); + let pipelines = WgpuPipelines::new( + &device, + &bind_group_layouts, + surface_config.format, + surface_config.alpha_mode, + rendering.path_sample_count, + dual_source_blending, + renderer_tier, + ); + let linear_sampler = |label| { + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some(label), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }) + }; + let atlas_sampler = linear_sampler("atlas_sampler"); + let surface_sampler = linear_sampler("surface_sampler"); + + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let surface_uniforms = DynamicUniformBuffer::new( + &device, + "surface_uniforms", + INITIAL_SURFACE_UNIFORM_CAPACITY, + uniform_alignment, + ); + let filter_uniforms = DynamicUniformBuffer::new( + &device, + "filter_uniforms", + INITIAL_FILTER_UNIFORM_CAPACITY, + uniform_alignment, + ); + let globals_size = std::mem::size_of::() as u64; + let gamma_size = std::mem::size_of::() as u64; + let path_globals_offset = globals_size.next_multiple_of(uniform_alignment); + let gamma_offset = (path_globals_offset + globals_size).next_multiple_of(uniform_alignment); + let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("globals_buffer"), + size: gamma_offset + gamma_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let create_globals = |label, offset| { + bind_group_layouts.create_globals( + &device, + label, + wgpu::BufferBinding { + buffer: &globals_buffer, + offset, + size: NonZeroU64::new(globals_size), + }, + wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: NonZeroU64::new(gamma_size), + }, + ) + }; + let globals_bind_group = create_globals("globals_bind_group", 0); + let path_globals_bind_group = + create_globals("path_globals_bind_group", path_globals_offset); + let last_error = context.uncaptured_error_slot(); + let surface_cache = SurfaceCache::new(&device)?; + + let metadata = ResourceMetadata { + globals: GlobalBufferLayout { + path_offset: path_globals_offset, + font_offset: gamma_offset, + maximum_uniform_buffer_size: device.limits().max_buffer_size.min(u32::MAX as u64), + }, + last_error, + }; + let resources = Self { + instances: InstanceBufferArena::new(&device, &bind_group_layouts, renderer_tier), + renderer_tier, + device, + queue, + surface, + pipelines, + bind_group_layouts, + atlas_sampler, + surface_sampler, + surface_uniforms, + surface_cache: RefCell::new(surface_cache), + filter_uniforms, + blur_bind_groups: RefCell::default(), + globals_buffer, + globals_bind_group, + path_globals_bind_group, + path_intermediate_texture: None, + path_intermediate_view: None, + path_msaa_texture: None, + path_msaa_view: None, + scene_color_texture: None, + scene_color_view: None, + backdrop_snapshot_texture: None, + backdrop_snapshot_view: None, + blur_ping_texture: None, + blur_ping_view: None, + blur_pong_texture: None, + blur_pong_view: None, + filter_group_textures: Vec::new(), + filter_group_views: Vec::new(), + }; + Ok((resources, metadata)) + } + + pub(super) fn invalidate_intermediate_textures(&mut self) { + self.instances.invalidate_texture_bindings(); + self.blur_bind_groups.get_mut().groups.clear(); + self.path_intermediate_texture = None; + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; + self.scene_color_texture = None; + self.scene_color_view = None; + self.backdrop_snapshot_texture = None; + self.backdrop_snapshot_view = None; + self.blur_ping_texture = None; + self.blur_ping_view = None; + self.blur_pong_texture = None; + self.blur_pong_view = None; + self.filter_group_textures.clear(); + self.filter_group_views.clear(); + } + + pub(super) fn finish_frame_uploads(&self) { + self.filter_uniforms.finish_upload(); + self.surface_uniforms.finish_upload(); + } + + #[cfg(test)] + pub(super) fn blur_bind_group_count(&self) -> usize { + self.blur_bind_groups.borrow().groups.len() + } + + pub(super) fn blur_bind_group(&self, source: &wgpu::TextureView) -> wgpu::BindGroup { + let mut cache = self.blur_bind_groups.borrow_mut(); + let uniform_generation = self.filter_uniforms.generation(); + if cache.uniform_generation != uniform_generation { + cache.groups.clear(); + cache.uniform_generation = uniform_generation; + } + cache + .groups + .entry(source.clone()) + .or_insert_with(|| { + self.bind_group_layouts.create_blur( + &self.device, + wgpu::BufferBinding { + buffer: &self.filter_uniforms.buffer, + offset: 0, + size: NonZeroU64::new(std::mem::size_of::() as u64), + }, + source, + &self.surface_sampler, + ) + }) + .clone() + } +} + +impl WgpuRenderer { + pub(super) fn ensure_path_textures(&mut self, scene: &gpui::Scene) { + // Path targets cover only the paths' bounds. + let (mut width, mut height) = path_target_extent( + scene.paths.iter().map(|path| path.clipped_bounds()), + self.target.width(), + self.target.height(), + ); + if let Some(texture) = self.resources().path_intermediate_texture.as_ref() { + // Grow in coarse blocks to avoid reallocating for each small graph + // change. Window resize invalidates these targets as before. + if texture.width() >= width && texture.height() >= height { + return; + } + width = width.max(texture.width()); + height = height.max(texture.height()); + } + if width == 0 || height == 0 { + return; + } + let format = self.target.format(); + let sample_count = self.rendering_params.path_sample_count; + let resources = self.resources_mut(); + resources.instances.invalidate_texture_bindings(); + let (texture, view) = sampled_render_texture(&resources.device, format, width, height); + resources.path_intermediate_texture = Some(texture); + resources.path_intermediate_view = Some(view); + if let Some((texture, view)) = + msaa_texture(&resources.device, format, width, height, sample_count) + { + resources.path_msaa_texture = Some(texture); + resources.path_msaa_view = Some(view); + } + } + + pub(super) fn ensure_filter_textures(&mut self, requirements: &ScenePlanRequirements) { + let format = self.target.format(); + let width = self.target.width(); + let height = self.target.height(); + let blur_width = downsampled_dimension(width); + let blur_height = downsampled_dimension(height); + let resources = self.resources_mut(); + + if resources.scene_color_texture.is_none() { + let (texture, view) = sampled_render_texture(&resources.device, format, width, height); + resources.scene_color_texture = Some(texture); + resources.scene_color_view = Some(view); + } + + if requirements.uses_blur_target && resources.blur_ping_texture.is_none() { + let (texture, view) = + sampled_render_texture(&resources.device, format, blur_width, blur_height); + resources.blur_ping_texture = Some(texture); + resources.blur_ping_view = Some(view); + let (texture, view) = + sampled_render_texture(&resources.device, format, blur_width, blur_height); + resources.blur_pong_texture = Some(texture); + resources.blur_pong_view = Some(view); + } + + if requirements.backdrop_filter_count > 0 + && requirements.isolated_target_count > 0 + && resources.backdrop_snapshot_texture.is_none() + { + let (texture, view) = sampled_render_texture(&resources.device, format, width, height); + resources.backdrop_snapshot_texture = Some(texture); + resources.backdrop_snapshot_view = Some(view); + } + + while resources.filter_group_views.len() < requirements.isolated_target_count { + let (texture, view) = sampled_render_texture(&resources.device, format, width, height); + resources.filter_group_textures.push(texture); + resources.filter_group_views.push(view); + } + } + + pub(super) fn ensure_uniform_capacity( + &mut self, + requirements: FrameUniformRequirements, + ) -> bool { + let maximum_buffer_size = self.globals.maximum_uniform_buffer_size; + let resources = self.resources_mut(); + let filters = resources.filter_uniforms.ensure_capacity( + &resources.device, + requirements.filter_count, + maximum_buffer_size, + ); + let surfaces = resources.surface_uniforms.ensure_capacity( + &resources.device, + requirements.surface_count, + maximum_buffer_size, + ); + let capacity_available = filters && surfaces; + if !capacity_available { + log::error!( + "scene uniform data exceeds the GPU buffer limit: {} filter uniforms and {} surface uniforms", + requirements.filter_count, + requirements.surface_count, + ); + return false; + } + let filters = resources + .filter_uniforms + .begin_upload(&resources.queue, requirements.filter_count); + let surfaces = resources + .surface_uniforms + .begin_upload(&resources.queue, requirements.surface_count); + if !(filters && surfaces) { + resources.finish_frame_uploads(); + log::error!("failed to map frame uniform staging memory"); + return false; + } + true + } +} + +fn sampled_render_texture( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sampled_render_texture"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} + +fn msaa_texture( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + sample_count: u32, +) -> Option<(wgpu::Texture, wgpu::TextureView)> { + if sample_count <= 1 { + return None; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_msaa"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Some((texture, view)) +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs b/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs new file mode 100644 index 000000000..053a422c1 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs @@ -0,0 +1,318 @@ +use super::resources::WgpuResources; +use gpui::Scene; +use gpui::profiler::gpu; +use gpui_render::damage::{Damage, SceneSnapshot}; +use gpui_render::retained::{GpuMemoryReservation, RetainedScene, reserve_retained_bytes}; +use gpui_render::shaders::{blur::BlurUniforms, interface::bytes_of}; +use std::sync::Arc; + +pub(super) struct RetainedFrame { + enabled: bool, + target: Option, + scene: RetainedScene, + #[cfg(test)] + last_damage: &'static str, +} +struct Target { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, + format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + clear_color: wgpu::Color, + copy_source: bool, + /// Samples the target onto the presentation texture when it isn't copied. + /// Owned here: a shared cache would keep the texture alive after a release. + blit: wgpu::BindGroup, + // Held for its drop, which releases the device budget. + _reservation: GpuMemoryReservation, +} +pub(super) struct RetainedPlan { + pub view: Option, + pub copy_texture: Option, + pub blit: Option, + pub damage: Damage, + pub snapshot: Option, + pub scene_revision: u64, +} +impl RetainedFrame { + pub fn new() -> Self { + Self { + // Measured on Windows and Linux; the web and macOS builds draw in full. + enabled: cfg!(any( + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )), + target: None, + scene: RetainedScene::default(), + #[cfg(test)] + last_damage: "full", + } + } + pub fn enabled(&self) -> bool { + self.enabled + } + #[cfg(test)] + pub fn enable_for_tests(&mut self) { + self.enabled = true; + } + #[cfg(test)] + pub fn disable_for_tests(&mut self) { + self.enabled = false; + self.invalidate(); + } + #[cfg(test)] + pub fn last_damage(&self) -> &'static str { + self.last_damage + } + /// Forget the retained frame and free its target and budget at once: wgpu + /// keeps a texture alive while submitted work uses it, and an idle or closed + /// window would never poll a completion callback. + pub fn invalidate(&mut self) { + self.scene.forget(); + self.target = None; + } + pub fn prepare( + &mut self, + scene: &Scene, + atlas: u64, + width: u32, + height: u32, + copy_source: bool, + resources: &WgpuResources, + format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + clear_color: wgpu::Color, + ) -> RetainedPlan { + let context = gpu::gpu_frame_context(); + let device = &resources.device; + let full = || RetainedPlan { + view: None, + copy_texture: None, + blit: None, + damage: Damage::Full, + snapshot: None, + scene_revision: context.scene_revision, + }; + if !self.enabled { + return full(); + } + let bytes = retained_bytes(format, width, height); + if !context.interactive + || !alpha_supported(alpha_mode) + // The damage-clear Quad must reproduce the raw target clear exactly. + // Colored translucent clears would be premultiplied again by its shader; + // arbitrary opaque colors also lack an exact HSL round-trip proof. + || !retained_clear_supported(clear_color) + || bytes.is_none() + { + self.invalidate(); + return full(); + } + if self.target.as_ref().is_some_and(|target| { + target.width != width + || target.height != height + || target.format != format + || target.alpha_mode != alpha_mode + || target.clear_color != clear_color + || target.copy_source != copy_source + }) { + self.invalidate(); + } + // Reserve before hashing, so a window the budget can't hold doesn't + // capture its scene every frame only to draw in full. + let reservation = if self.target.is_none() { + let reservation = bytes.and_then(|bytes| { + reserve_retained_bytes("wgpu", Arc::as_ptr(device) as usize, bytes) + }); + if reservation.is_none() { + self.invalidate(); + return full(); + } + reservation + } else { + None + }; + let Some((mut damage, snapshot)) = + self.scene + .plan(scene, context.scene_revision, atlas, width, height) + else { + self.invalidate(); + return full(); + }; + if let Some(reservation) = reservation { + #[cfg(not(target_family = "wasm"))] + let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("gpui_retained_frame"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | if copy_source { + wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::empty() + }, + view_formats: &[], + }); + // The target is optional: without memory for it, draw in full, as + // with an exhausted budget, instead of failing the device. The web + // never retains frames. + #[cfg(not(target_family = "wasm"))] + if gpui::block_on(scope.pop()).is_some() { + self.invalidate(); + return full(); + } + let view = texture.create_view(&Default::default()); + let uniforms = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpui_retained_blit"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let size = [width as f32, height as f32]; + resources + .queue + .write_buffer(&uniforms, 0, bytes_of(&BlurUniforms::copy(size))); + let blit = resources.bind_group_layouts.create_blur( + device, + uniforms.as_entire_buffer_binding(), + &view, + &resources.surface_sampler, + ); + self.target = Some(Target { + texture, + view, + width, + height, + format, + alpha_mode, + clear_color, + copy_source, + blit, + _reservation: reservation, + }); + // A new target holds nothing yet. + damage = Damage::Full; + } + let target = self.target.as_ref().unwrap(); + RetainedPlan { + view: Some(target.view.clone()), + copy_texture: copy_source.then(|| target.texture.clone()), + blit: Some(target.blit.clone()), + damage, + snapshot, + scene_revision: context.scene_revision, + } + } + pub fn complete(&mut self, plan: RetainedPlan, success: bool) { + #[cfg(test)] + { + self.last_damage = plan.damage.kind(); + } + if success { + self.scene.complete(plan.snapshot, plan.scene_revision); + } else { + self.invalidate(); + } + } +} + +fn retained_clear_supported(clear: wgpu::Color) -> bool { + // Both linear and sRGB targets preserve the black/white endpoints exactly. + // Black RGB stays black through either premultiplied or straight-alpha output. + let black = clear.r == 0.0 && clear.g == 0.0 && clear.b == 0.0; + let opaque_white = clear.r == 1.0 && clear.g == 1.0 && clear.b == 1.0 && clear.a == 1.0; + (black && clear.a.is_finite() && (0.0..=1.0).contains(&clear.a)) || opaque_white +} + +/// The alpha modes partial redraw is verified with, copied or blitted. +pub(super) fn alpha_supported(alpha: wgpu::CompositeAlphaMode) -> bool { + matches!( + alpha, + wgpu::CompositeAlphaMode::Opaque | wgpu::CompositeAlphaMode::PreMultiplied + ) +} + +fn retained_bytes(format: wgpu::TextureFormat, width: u32, height: u32) -> Option { + if width == 0 + || height == 0 + || !matches!( + format, + wgpu::TextureFormat::Bgra8Unorm + | wgpu::TextureFormat::Bgra8UnormSrgb + | wgpu::TextureFormat::Rgba8Unorm + | wgpu::TextureFormat::Rgba8UnormSrgb + ) + { + return None; + } + (width as u64).checked_mul(height as u64)?.checked_mul(4) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retained_clear_requires_exact_shader_endpoint_representation() { + assert!(retained_clear_supported(wgpu::Color::TRANSPARENT)); + assert!(retained_clear_supported(wgpu::Color::BLACK)); + assert!(retained_clear_supported(wgpu::Color::WHITE)); + assert!(retained_clear_supported(wgpu::Color { + a: 0.25, + ..wgpu::Color::BLACK + })); + for alpha in [0.0, 0.25, 1.0] { + assert!(!retained_clear_supported(wgpu::Color { + r: 0.04, + g: 0.03, + b: 0.02, + a: alpha, + })); + } + assert!(!retained_clear_supported(wgpu::Color { + a: f64::NAN, + ..wgpu::Color::BLACK + })); + assert!(!retained_clear_supported(wgpu::Color { + a: -0.25, + ..wgpu::Color::BLACK + })); + } + + #[test] + fn only_opaque_and_premultiplied_targets_retain() { + assert!(alpha_supported(wgpu::CompositeAlphaMode::Opaque)); + assert!(alpha_supported(wgpu::CompositeAlphaMode::PreMultiplied)); + assert!(!alpha_supported(wgpu::CompositeAlphaMode::PostMultiplied)); + assert!(!alpha_supported(wgpu::CompositeAlphaMode::Inherit)); + } + + #[test] + fn retained_budget_rejects_hdr_unknown_formats_zero_extent_and_overflow() { + assert_eq!( + retained_bytes(wgpu::TextureFormat::Bgra8Unorm, 4096, 2048), + Some(4096 * 2048 * 4) + ); + assert_eq!( + retained_bytes(wgpu::TextureFormat::Rgba16Float, 4096, 2048), + None + ); + assert_eq!(retained_bytes(wgpu::TextureFormat::Rgba8Unorm, 0, 8), None); + assert_eq!( + retained_bytes(wgpu::TextureFormat::Rgba8Unorm, u32::MAX, u32::MAX), + None + ); + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs b/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs new file mode 100644 index 000000000..4d23b1345 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs @@ -0,0 +1,97 @@ +use gpui::{DevicePixels, Size, get_gamma_correction_ratios}; + +pub struct WgpuSurfaceConfig { + pub size: Size, + pub transparent: bool, + /// Preferred presentation mode, falling back to FIFO when unsupported. + pub preferred_present_mode: Option, +} + +/// Physical order of the color components in an LCD subpixel layout. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SubpixelOrder { + RedGreenBlue, + BlueGreenRed, +} + +/// Platform text-rasterization settings consumed by the glyph shaders. +#[derive(Clone, Copy, Debug)] +pub struct FontRasterizationSettings { + pub(super) gamma_ratios: [f32; 4], + pub(super) grayscale_enhanced_contrast: f32, + pub(super) subpixel_enhanced_contrast: f32, + pub(super) subpixel_order: SubpixelOrder, +} + +impl FontRasterizationSettings { + /// Creates settings equivalent to the retired DirectWrite renderer's parameters. + pub fn new( + gamma: f32, + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + subpixel_order: SubpixelOrder, + ) -> Self { + Self { + gamma_ratios: get_gamma_correction_ratios(gamma.clamp(1.0, 2.2)), + grayscale_enhanced_contrast: finite_contrast(grayscale_enhanced_contrast), + subpixel_enhanced_contrast: finite_contrast(subpixel_enhanced_contrast), + subpixel_order, + } + } + + /// Disables contrast and gamma correction, like the retired macOS Metal glyph shader. + pub fn uncorrected() -> Self { + Self::new(1.0, 0.0, 0.0, SubpixelOrder::RedGreenBlue) + } + + fn from_environment() -> Self { + let gamma = environment_f32("ZED_FONTS_GAMMA", 1.8).clamp(1.0, 2.2); + let grayscale_enhanced_contrast = + environment_f32("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST", 1.0).max(0.0); + let subpixel_enhanced_contrast = + environment_f32("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST", 0.5).max(0.0); + Self::new( + gamma, + grayscale_enhanced_contrast, + subpixel_enhanced_contrast, + SubpixelOrder::RedGreenBlue, + ) + } +} + +pub(super) struct RenderingParameters { + pub(super) path_sample_count: u32, + pub(super) font_rasterization: FontRasterizationSettings, +} + +impl RenderingParameters { + pub(super) fn new(adapter: &wgpu::Adapter, surface_format: wgpu::TextureFormat) -> Self { + let format_features = adapter.get_texture_format_features(surface_format); + let path_sample_count = [4, 2, 1] + .into_iter() + .find(|&sample_count| format_features.flags.sample_count_supported(sample_count)) + .unwrap_or(1); + Self { + path_sample_count, + font_rasterization: FontRasterizationSettings::from_environment(), + } + } +} + +fn environment_f32(name: &str, default: f32) -> f32 { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite()) + .unwrap_or(default) +} + +fn finite_contrast(contrast: f32) -> f32 { + // Infinity survives max(0), then produces inf/inf (or inf*0) in the coverage + // shader. Fall back to neutral correction instead of making every glyph NaN. + if contrast.is_finite() { + contrast.max(0.0) + } else { + 0.0 + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs new file mode 100644 index 000000000..725753709 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs @@ -0,0 +1,153 @@ +//! Run inside a Wayland session with Vulkan available: +//! `cargo test -p gpui_ce_wgpu --lib failed_frames_release_surface_images -- --ignored` +use std::{ptr::NonNull, sync::Arc}; + +use gpui::{DevicePixels, Scene, Size}; +use raw_window_handle::{RawWindowHandle, WaylandWindowHandle}; +use wayland_client::{ + Connection, Dispatch, Proxy, QueueHandle, delegate_noop, + globals::{GlobalListContents, registry_queue_init}, + protocol::{wl_compositor, wl_registry, wl_surface}, +}; +use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; + +use super::{ + NativeBackend, NativeSurfaceTarget, SoftwareAdapterPolicy, WgpuAtlas, WgpuContext, + create_surface, +}; +use crate::{WgpuRenderer, WgpuSurfaceConfig}; + +#[derive(Default)] +struct WindowState { + configured: bool, +} + +delegate_noop!(WindowState: ignore wl_compositor::WlCompositor); +delegate_noop!(WindowState: ignore wl_surface::WlSurface); +delegate_noop!(WindowState: ignore xdg_toplevel::XdgToplevel); + +impl Dispatch for WindowState { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WindowState { + fn event( + _: &mut Self, + shell: &xdg_wm_base::XdgWmBase, + event: xdg_wm_base::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let xdg_wm_base::Event::Ping { serial } = event { + shell.pong(serial); + } + } +} + +impl Dispatch for WindowState { + fn event( + state: &mut Self, + surface: &xdg_surface::XdgSurface, + event: xdg_surface::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let xdg_surface::Event::Configure { serial } = event { + surface.ack_configure(serial); + state.configured = true; + } + } +} + +#[test] +#[ignore = "requires a Wayland compositor and Vulkan; opens a temporary test window"] +fn failed_frames_release_surface_images() { + let connection = Connection::connect_to_env().expect("Wayland connection"); + let (globals, mut events) = registry_queue_init::(&connection).unwrap(); + let queue = events.handle(); + let compositor: wl_compositor::WlCompositor = globals.bind(&queue, 1..=4, ()).unwrap(); + let shell: xdg_wm_base::XdgWmBase = globals.bind(&queue, 1..=1, ()).unwrap(); + let surface = compositor.create_surface(&queue, ()); + let shell_surface = shell.get_xdg_surface(&surface, &queue, ()); + let toplevel = shell_surface.get_toplevel(&queue, ()); + toplevel.set_title("GPUI surface recovery test".into()); + surface.commit(); + let mut state = WindowState::default(); + events.roundtrip(&mut state).unwrap(); + assert!( + state.configured, + "compositor must configure the test window" + ); + connection.flush().unwrap(); + + // Use a real Vulkan swapchain: a headless texture cannot expose exhausted + // presentation images. Keep the Wayland objects alive until after rendering. + let instance = NativeBackend::Vulkan.instance(Some(Box::new(connection.backend()))); + let handle = WaylandWindowHandle::new(NonNull::new(surface.id().as_ptr().cast()).unwrap()); + let gpu_surface = create_surface( + &instance.raw, + NativeSurfaceTarget::Window(RawWindowHandle::Wayland(handle)), + ) + .unwrap(); + let context = WgpuContext::new_with_adapter_policy( + instance, + &gpu_surface, + None, + SoftwareAdapterPolicy::Allow, + None, + ) + .unwrap(); + let mut renderer = WgpuRenderer::new_internal( + None, + &context, + Some(gpu_surface), + WgpuSurfaceConfig { + size: Size { + width: DevicePixels(128), + height: DevicePixels(128), + }, + transparent: false, + preferred_present_mode: None, + }, + None, + None, + Arc::new(WgpuAtlas::from_context(&context)), + ) + .unwrap(); + let mut scene = Scene::default(); + scene.finish(); + assert!(renderer.draw(&scene), "initial frame must present"); + events.roundtrip(&mut state).unwrap(); + + for attempt in 0..8 { + // Exercise the existing failure path in begin_frame, after draw has + // acquired an image. Do not mock acquisition or call recovery ourselves. + renderer.faults.consecutive_failed_frames = 5; + *renderer.faults.pending_error.lock().unwrap() = Some("injected frame error".into()); + assert!(!renderer.draw(&scene), "injected frame {attempt} must fail"); + assert!( + renderer.faults.pending_error.lock().unwrap().is_none(), + "frame {attempt} never reached rendering: surface images were exhausted" + ); + events.roundtrip(&mut state).unwrap(); + } + assert!( + renderer.draw(&scene), + "healthy frame must present after repeated failures" + ); + drop(renderer); + toplevel.destroy(); + shell_surface.destroy(); + surface.destroy(); + connection.flush().unwrap(); +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs new file mode 100644 index 000000000..499ceb131 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs @@ -0,0 +1,163 @@ +use gpui::PaintSurface; +#[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") +))] +use gpui_render::shaders::{ + common::SurfaceColorFormat, interface as shader_interface, surface::SurfaceUniforms, +}; + +use super::{WgpuRenderer, frame}; + +#[cfg(target_os = "macos")] +#[path = "surfaces/macos.rs"] +mod platform; +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + all(target_family = "wasm", feature = "custom-gpu") +))] +#[path = "surfaces/texture.rs"] +mod platform; +#[cfg(all(target_os = "windows", feature = "wgpu-surfaces"))] +#[path = "surfaces/windows.rs"] +mod platform; +#[cfg(not(any( + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_family = "wasm", feature = "custom-gpu"), + all(target_os = "windows", feature = "wgpu-surfaces") +)))] +#[path = "surfaces/unsupported.rs"] +mod platform; + +pub(super) use platform::SurfaceCache; + +#[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") +))] +struct SurfaceBinding { + color_view: wgpu::TextureView, + chroma_view: wgpu::TextureView, + uniform_generation: u64, + bind_group: wgpu::BindGroup, +} + +#[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") +))] +impl SurfaceBinding { + fn new( + renderer: &WgpuRenderer, + color_view: wgpu::TextureView, + chroma_view: wgpu::TextureView, + ) -> Self { + Self { + bind_group: renderer.create_surface_bind_group(&color_view, &chroma_view), + uniform_generation: renderer.resources().surface_uniforms.generation(), + color_view, + chroma_view, + } + } + + fn refresh(&mut self, renderer: &WgpuRenderer) { + let generation = renderer.resources().surface_uniforms.generation(); + if self.uniform_generation != generation { + self.bind_group = + renderer.create_surface_bind_group(&self.color_view, &self.chroma_view); + self.uniform_generation = generation; + } + } +} + +impl WgpuRenderer { + pub(super) fn retain_surface_cache(&self, surfaces: &[PaintSurface]) { + platform::retain_surface_cache(self, surfaces); + } + + #[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + ))] + fn create_surface_bind_group( + &self, + color_view: &wgpu::TextureView, + chroma_view: &wgpu::TextureView, + ) -> wgpu::BindGroup { + let resources = self.resources(); + resources.bind_group_layouts.create_surface( + &resources.device, + wgpu::BufferBinding { + buffer: &resources.surface_uniforms.buffer, + offset: 0, + size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), + }, + color_view, + chroma_view, + &resources.surface_sampler, + ) + } + + #[cfg(any( + all(target_family = "wasm", feature = "custom-gpu"), + target_os = "macos", + target_os = "linux", + target_os = "freebsd", + all(target_os = "windows", feature = "wgpu-surfaces") + ))] + fn draw_surface_binding( + &self, + surface: &PaintSurface, + color_format: SurfaceColorFormat, + binding: &mut SurfaceBinding, + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + binding.refresh(self); + let uniforms = SurfaceUniforms { + bounds: surface.bounds.into(), + content_mask: surface.content_mask.into(), + color_format, + opacity: 1.0, + padding0: 0, + padding1: 0, + padding2: 0, + padding3: 0, + padding4: 0, + padding5: 0, + }; + let resources = self.resources(); + let uniform_offset = resources.surface_uniforms.write(&uniforms); + + pass.set_pipeline(&resources.pipelines.surfaces); + pass.set_bind_group( + shader_interface::DATA_BIND_GROUP, + &binding.bind_group, + &[uniform_offset], + ); + pass.draw(0..resources.pipelines.surfaces.fixed_vertex_count(), 0..1); + Ok(()) + } + + pub(super) fn draw_surfaces( + &self, + surfaces: &[PaintSurface], + pass: &mut wgpu::RenderPass<'_>, + ) -> frame::DrawResult { + platform::draw_surfaces(self, surfaces, pass) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs new file mode 100644 index 000000000..de3dbc01d --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs @@ -0,0 +1,405 @@ +use super::*; +use collections::FxHashMap; + +pub(in crate::wgpu_renderer) struct SurfaceCache { + texture_cache: core_video::metal_texture_cache::CVMetalTextureCache, + surfaces: FxHashMap, + /// Bindings for application-rendered WGPU textures. + #[cfg(feature = "custom-gpu")] + textures: FxHashMap, +} + +impl SurfaceCache { + pub(in crate::wgpu_renderer) fn new(device: &wgpu::Device) -> anyhow::Result { + use metal::foreign_types::ForeignTypeRef as _; + + let hal_device = unsafe { device.as_hal::() } + .ok_or_else(|| anyhow::anyhow!("macOS WGPU device did not expose the Metal HAL"))?; + let raw_device = objc2::rc::Retained::as_ptr(hal_device.raw_device()) + .cast_mut() + .cast(); + let metal_device = unsafe { metal::DeviceRef::from_ptr(raw_device) }.to_owned(); + let texture_cache = + core_video::metal_texture_cache::CVMetalTextureCache::new(None, metal_device, None) + .map_err(|error| { + anyhow::anyhow!("failed to create CoreVideo Metal texture cache: {error}") + })?; + Ok(Self { + texture_cache, + surfaces: FxHashMap::default(), + #[cfg(feature = "custom-gpu")] + textures: FxHashMap::default(), + }) + } +} + +struct CachedSurface { + _luma_texture: wgpu::Texture, + _chroma_texture: wgpu::Texture, + binding: SurfaceBinding, +} + +pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { + let active_keys = surfaces + .iter() + .filter_map(|surface| { + let gpui::SurfaceSource::Surface(image_buffer) = &surface.source else { + return None; + }; + core_video_surface_key(image_buffer).ok() + }) + .collect::>(); + let mut cache = renderer.resources().surface_cache.borrow_mut(); + cache.surfaces.retain(|key, _| active_keys.contains(key)); + #[cfg(feature = "custom-gpu")] + { + let active_textures = surfaces + .iter() + .filter_map(|surface| match &surface.source { + gpui::SurfaceSource::Texture { texture, .. } => { + texture.downcast_ref::() + } + _ => None, + }) + .collect::>(); + cache + .textures + .retain(|texture, _| active_textures.contains(&texture)); + } +} + +pub(super) fn draw_surfaces( + renderer: &WgpuRenderer, + surfaces: &[PaintSurface], + pass: &mut wgpu::RenderPass<'_>, +) -> frame::DrawResult { + use core_video::pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange; + + let resources = renderer.resources(); + let mut cache = resources.surface_cache.borrow_mut(); + + for surface in surfaces { + match &surface.source { + gpui::SurfaceSource::Surface(image_buffer) => { + if image_buffer.get_pixel_format() != kCVPixelFormatType_420YpCbCr8BiPlanarFullRange + { + log::error!("unsupported CoreVideo surface pixel format"); + return Err(frame::DrawError::ExternalSurface); + } + let key = core_video_surface_key(image_buffer)?; + let mut imported = cache.surfaces.remove(&key).map(Ok).unwrap_or_else(|| { + create_core_video_surface(renderer, &cache.texture_cache, image_buffer) + })?; + renderer.draw_surface_binding( + surface, + SurfaceColorFormat::Yuv, + &mut imported.binding, + pass, + )?; + cache.surfaces.insert(key, imported); + } + #[cfg(feature = "custom-gpu")] + gpui::SurfaceSource::Texture { texture, .. } => { + let Some(texture) = texture.downcast_ref::() else { + log::error!("surface source is not a WGPU texture"); + return Err(frame::DrawError::ExternalSurface); + }; + let binding = cache.textures.entry(texture.clone()).or_insert_with(|| { + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + SurfaceBinding::new(renderer, view.clone(), view) + }); + renderer.draw_surface_binding(surface, SurfaceColorFormat::Rgba, binding, pass)?; + } + _ => { + log::error!("surface source cannot be imported by the macOS renderer"); + return Err(frame::DrawError::ExternalSurface); + } + } + } + Ok(()) +} + +fn core_video_surface_key( + image_buffer: &core_video::pixel_buffer::CVPixelBuffer, +) -> Result { + use core_foundation::base::TCFType as _; + + let io_surface = unsafe { + core_video::pixel_buffer_io_surface::CVPixelBufferGetIOSurface( + image_buffer.as_concrete_TypeRef(), + ) + }; + if io_surface.is_null() { + log::error!( + "CoreVideo surface is not IOSurface-backed; allocate it with \ + kCVPixelBufferIOSurfacePropertiesKey and \ + kCVPixelBufferMetalCompatibilityKey" + ); + return Err(frame::DrawError::ExternalSurface); + } + Ok(io_surface as usize) +} + +fn create_core_video_surface( + renderer: &WgpuRenderer, + texture_cache: &core_video::metal_texture_cache::CVMetalTextureCache, + image_buffer: &core_video::pixel_buffer::CVPixelBuffer, +) -> Result { + use core_foundation::base::TCFType as _; + use core_video::image_buffer::TCVImageBuffer as _; + use core_video::metal_texture::CVMetalTextureGetTexture; + + let resources = renderer.resources(); + let luma = texture_cache + .create_texture_from_image( + &image_buffer.as_image_buffer(), + None, + metal::MTLPixelFormat::R8Unorm, + image_buffer.get_width_of_plane(0), + image_buffer.get_height_of_plane(0), + 0, + ) + .map_err(|error| { + log::error!("failed to create CoreVideo luma texture: {error}"); + frame::DrawError::ExternalSurface + })?; + let chroma = texture_cache + .create_texture_from_image( + &image_buffer.as_image_buffer(), + None, + metal::MTLPixelFormat::RG8Unorm, + image_buffer.get_width_of_plane(1), + image_buffer.get_height_of_plane(1), + 1, + ) + .map_err(|error| { + log::error!("failed to create CoreVideo chroma texture: {error}"); + frame::DrawError::ExternalSurface + })?; + let luma_texture = unsafe { + import_core_video_texture( + &resources.device, + CVMetalTextureGetTexture(luma.as_concrete_TypeRef()).cast(), + wgpu::TextureFormat::R8Unorm, + plane_size(image_buffer, 0), + ) + } + .ok_or(frame::DrawError::ExternalSurface)?; + let chroma_texture = unsafe { + import_core_video_texture( + &resources.device, + CVMetalTextureGetTexture(chroma.as_concrete_TypeRef()).cast(), + wgpu::TextureFormat::Rg8Unorm, + plane_size(image_buffer, 1), + ) + } + .ok_or(frame::DrawError::ExternalSurface)?; + let luma_view = luma_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let chroma_view = chroma_texture.create_view(&wgpu::TextureViewDescriptor::default()); + Ok(CachedSurface { + _luma_texture: luma_texture, + _chroma_texture: chroma_texture, + binding: SurfaceBinding::new(renderer, luma_view, chroma_view), + }) +} + +fn plane_size( + image_buffer: &core_video::pixel_buffer::CVPixelBuffer, + plane: usize, +) -> wgpu::Extent3d { + wgpu::Extent3d { + width: image_buffer.get_width_of_plane(plane) as u32, + height: image_buffer.get_height_of_plane(plane) as u32, + depth_or_array_layers: 1, + } +} + +unsafe fn import_core_video_texture( + device: &wgpu::Device, + raw_texture: *mut objc2::runtime::AnyObject, + format: wgpu::TextureFormat, + size: wgpu::Extent3d, +) -> Option { + use objc2::{rc::Retained, runtime::ProtocolObject}; + + // SAFETY: CoreVideo returned a live MTLTexture; the retain count transfers into the + // HAL texture so it outlives the CVMetalTexture wrapper. + let object = unsafe { Retained::retain(raw_texture) }?; + let raw = + unsafe { Retained::cast_unchecked::>(object) }; + let hal_texture = unsafe { + wgpu::hal::metal::Device::texture_from_raw( + raw, + format, + objc2_metal::MTLTextureType::Type2D, + 1, + 1, + size.into(), + ) + }; + Some(unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("core_video_surface_plane"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }) +} + +#[cfg(all(test, feature = "test-support", feature = "custom-gpu"))] +mod tests { + use std::sync::Arc; + + use gpui::{ + Bounds, ContentMask, DevicePixels, Point, ScaledPixels, Scene, Size, SurfaceSource, + }; + + use super::*; + use crate::WgpuContext; + + const RED: [u8; 4] = [255, 0, 0, 255]; + + /// A 4x2 renderer: a surface over its left half leaves the right half clear. + fn renderer() -> anyhow::Result { + let context = WgpuContext::new_headless(None)?; + WgpuRenderer::new_headless( + &context, + Size { + width: DevicePixels(4), + height: DevicePixels(2), + }, + ) + } + + fn solid_texture(renderer: &WgpuRenderer, color: [u8; 4]) -> Arc { + let (device, queue) = renderer.gpu_context(); + let size = wgpu::Extent3d { + width: 2, + height: 2, + depth_or_array_layers: 1, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("application_texture"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + &color.repeat(4), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(8), + rows_per_image: Some(2), + }, + size, + ); + Arc::new(texture) + } + + /// A scene drawing each source over the renderer's left half. + fn scene(sources: impl IntoIterator) -> Scene { + let bounds = |width| Bounds { + origin: Point { + x: ScaledPixels(0.0), + y: ScaledPixels(0.0), + }, + size: Size { + width: ScaledPixels(width), + height: ScaledPixels(2.0), + }, + }; + let mut scene = Scene::default(); + for (order, source) in sources.into_iter().enumerate() { + scene.insert_primitive(PaintSurface { + order: order as u32, + bounds: bounds(2.0), + content_mask: ContentMask { + bounds: bounds(4.0), + ..Default::default() + }, + source, + }); + } + scene.finish(); + scene + } + + fn texture_source(texture: &Arc) -> SurfaceSource { + SurfaceSource::Texture { + texture: texture.clone(), + size: Size { + width: DevicePixels(2), + height: DevicePixels(2), + }, + } + } + + fn cached_textures(renderer: &WgpuRenderer) -> Vec { + let cache = renderer.resources().surface_cache.borrow(); + cache.textures.keys().cloned().collect() + } + + #[test] + fn application_textures_composite_within_their_bounds() -> anyhow::Result<()> { + let mut renderer = renderer()?; + let clear = renderer.render_to_image(&scene([]))?.get_pixel(3, 1).0; + assert_ne!(clear, RED); + let texture = solid_texture(&renderer, RED); + + let image = renderer.render_to_image(&scene([texture_source(&texture)]))?; + + assert_eq!(image.get_pixel(0, 0).0, RED); + assert_eq!(image.get_pixel(1, 1).0, RED); + assert_eq!(image.get_pixel(2, 0).0, clear); + assert_eq!(image.get_pixel(3, 1).0, clear); + Ok(()) + } + + #[test] + fn texture_bindings_last_only_while_their_texture_is_drawn() -> anyhow::Result<()> { + let mut renderer = renderer()?; + let first = solid_texture(&renderer, RED); + let second = solid_texture(&renderer, RED); + + // One binding per texture, reused across frames. + renderer.render_to_image(&scene([texture_source(&first)]))?; + renderer.render_to_image(&scene([texture_source(&first)]))?; + assert_eq!(cached_textures(&renderer), [(*first).clone()]); + + // A texture the scene stops drawing is released, so an application + // that replaces its target (on resize) doesn't accumulate them. + renderer.render_to_image(&scene([texture_source(&second)]))?; + assert_eq!(cached_textures(&renderer), [(*second).clone()]); + renderer.render_to_image(&scene([]))?; + assert_eq!(cached_textures(&renderer), []); + Ok(()) + } + + #[test] + fn sources_that_are_not_wgpu_textures_fail_the_frame() -> anyhow::Result<()> { + let mut renderer = renderer()?; + let not_a_texture = SurfaceSource::Texture { + texture: Arc::new("not a texture"), + size: Size { + width: DevicePixels(2), + height: DevicePixels(2), + }, + }; + + assert!(renderer.render_to_image(&scene([not_a_texture])).is_err()); + Ok(()) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs new file mode 100644 index 000000000..62d210347 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs @@ -0,0 +1,70 @@ +use super::*; +use collections::FxHashMap; + +pub(in crate::wgpu_renderer) struct SurfaceCache { + textures: FxHashMap, +} + +impl SurfaceCache { + pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> anyhow::Result { + Ok(Self { + textures: FxHashMap::default(), + }) + } +} + +pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { + let mut textures = smallvec::SmallVec::<[&wgpu::Texture; 4]>::new(); + for surface in surfaces { + let gpui::SurfaceSource::Texture { texture, .. } = &surface.source else { + continue; + }; + if let Some(texture) = texture.downcast_ref::() { + textures.push(texture); + } + } + renderer + .resources() + .surface_cache + .borrow_mut() + .textures + .retain(|texture, _| textures.contains(&texture)); +} + +pub(super) fn draw_surfaces( + renderer: &WgpuRenderer, + surfaces: &[PaintSurface], + pass: &mut wgpu::RenderPass<'_>, +) -> frame::DrawResult { + let mut textures = smallvec::SmallVec::<[(&PaintSurface, &wgpu::Texture); 4]>::new(); + for surface in surfaces { + let gpui::SurfaceSource::Texture { texture, .. } = &surface.source else { + log::error!("surface source cannot be imported by the WGPU texture renderer"); + return Err(frame::DrawError::ExternalSurface); + }; + let Some(texture) = texture.downcast_ref::() else { + log::error!("surface source is not a WGPU texture"); + return Err(frame::DrawError::ExternalSurface); + }; + textures.push((surface, texture)); + } + + let resources = renderer.resources(); + let mut cache = resources.surface_cache.borrow_mut(); + + for (surface, texture) in textures { + if !cache.textures.contains_key(texture) { + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + cache.textures.insert( + texture.clone(), + SurfaceBinding::new(renderer, view.clone(), view), + ); + } + let Some(cached) = cache.textures.get_mut(texture) else { + log::error!("surface texture cache insertion failed"); + return Err(frame::DrawError::ExternalSurface); + }; + renderer.draw_surface_binding(surface, SurfaceColorFormat::Rgba, cached, pass)?; + } + Ok(()) +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs new file mode 100644 index 000000000..e391a0936 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs @@ -0,0 +1,24 @@ +use super::*; + +pub(in crate::wgpu_renderer) struct SurfaceCache; + +impl SurfaceCache { + pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> anyhow::Result { + Ok(Self) + } +} + +pub(super) fn retain_surface_cache(_renderer: &WgpuRenderer, _surfaces: &[PaintSurface]) {} + +pub(super) fn draw_surfaces( + _renderer: &WgpuRenderer, + surfaces: &[PaintSurface], + _pass: &mut wgpu::RenderPass<'_>, +) -> frame::DrawResult { + if surfaces.is_empty() { + Ok(()) + } else { + log::error!("native surface primitives are unsupported on this render target"); + Err(frame::DrawError::ExternalSurface) + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs new file mode 100644 index 000000000..fdf74119f --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs @@ -0,0 +1,300 @@ +use super::*; +use anyhow::Result; +use collections::FxHashMap; +use windows_core::Interface as _; + +#[path = "windows/bindings.rs"] +mod bindings; +use bindings::Windows::Win32::{D3D11_TEXTURE2D_DESC, ID3D11Texture2D}; + +#[path = "windows/shared.rs"] +mod shared; +#[path = "windows/upload.rs"] +mod upload; + +use shared::SharedTexture; +use upload::UploadedTexture; + +pub(in crate::wgpu_renderer) struct SurfaceCache { + textures: FxHashMap, +} + +impl SurfaceCache { + pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> Result { + Ok(Self { + textures: FxHashMap::default(), + }) + } +} + +pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { + let active_keys = surfaces + .iter() + .filter_map(|surface| capture_frame(surface).map(CaptureFrameKey::from_frame)) + .collect::>(); + renderer + .resources() + .surface_cache + .borrow_mut() + .textures + .retain(|key, _| active_keys.contains(key)); +} + +pub(super) fn draw_surfaces( + renderer: &WgpuRenderer, + surfaces: &[PaintSurface], + pass: &mut wgpu::RenderPass<'_>, +) -> frame::DrawResult { + let resources = renderer.resources(); + let mut cache = resources.surface_cache.borrow_mut(); + for surface in surfaces { + let Some(frame) = capture_frame(surface) else { + log::error!("surface source cannot be imported by the Windows renderer"); + return Err(frame::DrawError::ExternalSurface); + }; + let raw_source = frame.texture().as_raw(); + // The frame owns this ID3D11Texture2D for the entire draw. Borrow its + // ABI-stable COM pointer using this renderer's generated bindings. + let source = unsafe { ID3D11Texture2D::from_raw_borrowed(&raw_source) } + .expect("capture texture is non-null"); + let key = CaptureFrameKey::from_frame(frame); + let size = source_size(source); + if cache + .textures + .get(&key) + .is_none_or(|cached| cached.size != size) + { + cache.textures.insert( + key, + CachedTexture::new(renderer, source).map_err(surface_error)?, + ); + } + + let Some(cached) = cache.textures.get_mut(&key) else { + log::error!("capture texture cache insertion failed"); + return Err(frame::DrawError::ExternalSurface); + }; + // A frame can be painted more than once (for clipping, filters, or repeated content). + // The cache key includes the capture timestamp, so each frame gets its own destination; + // `updated` prevents duplicate GPU copies/uploads for repeated uses of that frame. + if !cached.updated { + if let Err(error) = cached.update(&resources.queue, source) { + if !cached.is_shared() { + return Err(surface_error(error)); + } + log::warn!("shared capture update failed, switching to CPU upload: {error:#}"); + *cached = CachedTexture::new_uploaded(renderer, source) + .and_then(|mut cached| { + cached.update(&resources.queue, source)?; + Ok(cached) + }) + .map_err(surface_error)?; + } + cached.updated = true; + } + + renderer.draw_surface_binding( + surface, + SurfaceColorFormat::Rgba, + &mut cached.binding, + pass, + )?; + } + Ok(()) +} + +fn capture_frame(surface: &PaintSurface) -> Option<&gpui::WindowsScreenCaptureFrame> { + let gpui::SurfaceSource::WindowsCapture(frame) = &surface.source else { + return None; + }; + Some(frame) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct CaptureFrameKey { + texture: usize, + display_time: u64, +} + +impl CaptureFrameKey { + fn from_frame(frame: &gpui::WindowsScreenCaptureFrame) -> Self { + Self { + texture: frame.texture().as_raw() as usize, + display_time: frame.display_time(), + } + } +} + +fn surface_error(error: anyhow::Error) -> frame::DrawError { + log::error!("failed to import Windows capture frame: {error:#}"); + frame::DrawError::ExternalSurface +} + +struct CachedTexture { + size: wgpu::Extent3d, + updated: bool, + backend: CaptureBackend, + _texture: wgpu::Texture, + binding: SurfaceBinding, +} + +impl CachedTexture { + fn new(renderer: &WgpuRenderer, source: &ID3D11Texture2D) -> Result { + let device = &renderer.resources().device; + if let Some(hal_device) = unsafe { device.as_hal::() } { + match SharedTexture::new(device, &hal_device, source) { + Ok((backend, texture)) => { + return Ok(Self::from_backend(renderer, backend.into(), texture)); + } + Err(error) => { + log::warn!("D3D12 capture sharing is unavailable, using CPU upload: {error:#}"); + } + } + } + Self::new_uploaded(renderer, source) + } + + fn new_uploaded(renderer: &WgpuRenderer, source: &ID3D11Texture2D) -> Result { + let (backend, texture) = UploadedTexture::new(&renderer.resources().device, source)?; + Ok(Self::from_backend(renderer, backend.into(), texture)) + } + + fn from_backend( + renderer: &WgpuRenderer, + backend: CaptureBackend, + texture: wgpu::Texture, + ) -> Self { + let size = backend.size(); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Self { + size, + updated: false, + backend, + binding: SurfaceBinding::new(renderer, view.clone(), view), + _texture: texture, + } + } + + fn update(&mut self, queue: &wgpu::Queue, source: &ID3D11Texture2D) -> Result<()> { + match &mut self.backend { + CaptureBackend::Shared(texture) => texture.update(source), + CaptureBackend::Uploaded(texture) => texture.update(queue, source), + } + } + + fn is_shared(&self) -> bool { + matches!(self.backend, CaptureBackend::Shared(_)) + } +} + +enum CaptureBackend { + Shared(SharedTexture), + Uploaded(UploadedTexture), +} + +impl CaptureBackend { + fn size(&self) -> wgpu::Extent3d { + match self { + Self::Shared(texture) => texture.size, + Self::Uploaded(texture) => texture.size, + } + } +} + +impl From for CaptureBackend { + fn from(value: SharedTexture) -> Self { + Self::Shared(value) + } +} + +impl From for CaptureBackend { + fn from(value: UploadedTexture) -> Self { + Self::Uploaded(value) + } +} + +fn source_descriptor(source: &ID3D11Texture2D) -> D3D11_TEXTURE2D_DESC { + let mut descriptor = D3D11_TEXTURE2D_DESC::default(); + unsafe { source.GetDesc(&mut descriptor) }; + descriptor +} + +fn source_size(source: &ID3D11Texture2D) -> wgpu::Extent3d { + capture_size(&source_descriptor(source)) +} + +fn validate_capture_descriptor(descriptor: &D3D11_TEXTURE2D_DESC) -> Result<()> { + use bindings::Windows::Win32::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB}; + + anyhow::ensure!( + descriptor.Width > 0 && descriptor.Height > 0, + "capture texture has empty dimensions" + ); + anyhow::ensure!( + descriptor.MipLevels == 1 && descriptor.ArraySize == 1 && descriptor.SampleDesc.Count == 1, + "capture texture layout is unsupported" + ); + anyhow::ensure!( + descriptor.Format == DXGI_FORMAT_B8G8R8A8_UNORM + || descriptor.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, + "capture texture format {} is not BGRA8", + descriptor.Format + ); + Ok(()) +} + +fn capture_size(descriptor: &D3D11_TEXTURE2D_DESC) -> wgpu::Extent3d { + wgpu::Extent3d { + width: descriptor.Width, + height: descriptor.Height, + depth_or_array_layers: 1, + } +} + +fn capture_texture_descriptor( + label: &'static str, + size: wgpu::Extent3d, +) -> wgpu::TextureDescriptor<'static> { + wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + // Windows Graphics Capture produces DXGI_FORMAT_B8G8R8A8_UNORM. Importing it as + // sRGB applies a second decode during sampling and visibly darkens captured content. + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + } +} + +#[cfg(test)] +mod tests { + use collections::FxHashMap; + + use super::CaptureFrameKey; + + #[test] + fn capture_frame_key_controls_cache_identity_and_pruning() { + let first = CaptureFrameKey { + texture: 42, + display_time: 1, + }; + let second = CaptureFrameKey { + texture: 42, + display_time: 2, + }; + assert_ne!(first, second); + + let mut cache = FxHashMap::default(); + assert_eq!(cache.insert(first, 1), None); + assert_eq!(cache.insert(second, 2), None); + assert_eq!(cache.insert(first, 3), Some(1)); + assert_eq!(cache.len(), 2); + + cache.retain(|key, _| *key == second); + assert_eq!(cache.len(), 1); + assert_eq!(cache.get(&second), Some(&2)); + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs new file mode 100644 index 000000000..0000e810d --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs @@ -0,0 +1,1180 @@ +// Generated by windows-bindgen 0.100.0. +// Regenerate with `cargo run -p gpui_ce_windows_bindings`. + +#![allow( + non_snake_case, + non_upper_case_globals, + non_camel_case_types, + dead_code, + clippy::all +)] + +pub mod Windows { + pub mod Win32 { + #[inline] + pub unsafe fn CloseHandle(hobject: HANDLE) -> windows_core::BOOL { + windows_core::link!("kernel32.dll" "system" fn CloseHandle(hobject : HANDLE) -> windows_core::BOOL); + unsafe { CloseHandle(hobject) } + } + pub type D3D11_BIND_FLAG = i32; + pub const D3D11_BIND_SHADER_RESOURCE: D3D11_BIND_FLAG = 8; + pub type D3D11_CPU_ACCESS_FLAG = i32; + pub const D3D11_CPU_ACCESS_READ: D3D11_CPU_ACCESS_FLAG = 131072; + pub type D3D11_FENCE_FLAG = u32; + pub const D3D11_FENCE_FLAG_SHARED: D3D11_FENCE_FLAG = 2; + pub type D3D11_MAP = i32; + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct D3D11_MAPPED_SUBRESOURCE { + pub pData: *mut core::ffi::c_void, + pub RowPitch: u32, + pub DepthPitch: u32, + } + pub const D3D11_MAP_READ: D3D11_MAP = 1; + pub type D3D11_RESOURCE_MISC_FLAG = i32; + pub const D3D11_RESOURCE_MISC_SHARED_NTHANDLE: D3D11_RESOURCE_MISC_FLAG = 2048; + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct D3D11_SUBRESOURCE_DATA { + pub pSysMem: *const core::ffi::c_void, + pub SysMemPitch: u32, + pub SysMemSlicePitch: u32, + } + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct D3D11_TEXTURE2D_DESC { + pub Width: u32, + pub Height: u32, + pub MipLevels: u32, + pub ArraySize: u32, + pub Format: DXGI_FORMAT, + pub SampleDesc: DXGI_SAMPLE_DESC, + pub Usage: D3D11_USAGE, + pub BindFlags: u32, + pub CPUAccessFlags: u32, + pub MiscFlags: u32, + } + pub type D3D11_USAGE = i32; + pub const D3D11_USAGE_DEFAULT: D3D11_USAGE = 0; + pub const D3D11_USAGE_STAGING: D3D11_USAGE = 3; + pub type DXGI_FORMAT = i32; + pub const DXGI_FORMAT_B8G8R8A8_TYPELESS: DXGI_FORMAT = 90; + pub const DXGI_FORMAT_B8G8R8A8_UNORM: DXGI_FORMAT = 87; + pub const DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: DXGI_FORMAT = 91; + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct DXGI_SAMPLE_DESC { + pub Count: u32, + pub Quality: u32, + } + pub const GENERIC_ALL: i32 = 268435456; + #[repr(transparent)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] + pub struct HANDLE(pub *mut core::ffi::c_void); + windows_core::imp::define_interface!( + ID3D11Device, + ID3D11Device_Vtbl, + 0xdb6f6ddb_ac77_4e88_8253_819df9bbf140 + ); + windows_core::imp::interface_hierarchy!(ID3D11Device, windows_core::IUnknown); + impl ID3D11Device { + pub unsafe fn CreateTexture2D( + &self, + pdesc: *const D3D11_TEXTURE2D_DESC, + pinitialdata: Option<*const D3D11_SUBRESOURCE_DATA>, + pptexture2d: Option<*mut Option>, + ) -> windows_core::HRESULT { + unsafe { + (windows_core::Interface::vtable(self).CreateTexture2D)( + windows_core::Interface::as_raw(self), + pdesc, + pinitialdata.unwrap_or(core::mem::zeroed()) as _, + pptexture2d.unwrap_or(core::mem::zeroed()) as _, + ) + } + } + pub unsafe fn GetImmediateContext(&self) -> windows_core::Result { + unsafe { + let mut result__ = core::mem::zeroed(); + (windows_core::Interface::vtable(self).GetImmediateContext)( + windows_core::Interface::as_raw(self), + &mut result__, + ); + windows_core::imp::Type::from_abi(result__) + } + } + } + #[repr(C)] + pub struct ID3D11Device_Vtbl { + pub base__: windows_core::IUnknown_Vtbl, + CreateBuffer: usize, + CreateTexture1D: usize, + pub CreateTexture2D: unsafe extern "system" fn( + *mut core::ffi::c_void, + *const D3D11_TEXTURE2D_DESC, + *const D3D11_SUBRESOURCE_DATA, + *mut *mut core::ffi::c_void, + ) -> windows_core::HRESULT, + CreateTexture3D: usize, + CreateShaderResourceView: usize, + CreateUnorderedAccessView: usize, + CreateRenderTargetView: usize, + CreateDepthStencilView: usize, + CreateInputLayout: usize, + CreateVertexShader: usize, + CreateGeometryShader: usize, + CreateGeometryShaderWithStreamOutput: usize, + CreatePixelShader: usize, + CreateHullShader: usize, + CreateDomainShader: usize, + CreateComputeShader: usize, + CreateClassLinkage: usize, + CreateBlendState: usize, + CreateDepthStencilState: usize, + CreateRasterizerState: usize, + CreateSamplerState: usize, + CreateQuery: usize, + CreatePredicate: usize, + CreateCounter: usize, + CreateDeferredContext: usize, + OpenSharedResource: usize, + CheckFormatSupport: usize, + CheckMultisampleQualityLevels: usize, + CheckCounterInfo: usize, + CheckCounter: usize, + CheckFeatureSupport: usize, + GetPrivateData: usize, + SetPrivateData: usize, + SetPrivateDataInterface: usize, + GetFeatureLevel: usize, + GetCreationFlags: usize, + GetDeviceRemovedReason: usize, + pub GetImmediateContext: + unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void), + SetExceptionMode: usize, + GetExceptionMode: usize, + } + impl windows_core::RuntimeName for ID3D11Device {} + windows_core::imp::define_interface!( + ID3D11Device1, + ID3D11Device1_Vtbl, + 0xa04bfb29_08ef_43d6_a49c_a9bdbdcbe686 + ); + impl core::ops::Deref for ID3D11Device1 { + type Target = ID3D11Device; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Device1, + windows_core::IUnknown, + ID3D11Device + ); + #[repr(C)] + pub struct ID3D11Device1_Vtbl { + pub base__: ID3D11Device_Vtbl, + GetImmediateContext1: usize, + CreateDeferredContext1: usize, + CreateBlendState1: usize, + CreateRasterizerState1: usize, + CreateDeviceContextState: usize, + OpenSharedResource1: usize, + OpenSharedResourceByName: usize, + } + impl windows_core::RuntimeName for ID3D11Device1 {} + windows_core::imp::define_interface!( + ID3D11Device2, + ID3D11Device2_Vtbl, + 0x9d06dffa_d1e5_4d07_83a8_1bb123f2f841 + ); + impl core::ops::Deref for ID3D11Device2 { + type Target = ID3D11Device1; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Device2, + windows_core::IUnknown, + ID3D11Device, + ID3D11Device1 + ); + #[repr(C)] + pub struct ID3D11Device2_Vtbl { + pub base__: ID3D11Device1_Vtbl, + GetImmediateContext2: usize, + CreateDeferredContext2: usize, + GetResourceTiling: usize, + CheckMultisampleQualityLevels1: usize, + } + impl windows_core::RuntimeName for ID3D11Device2 {} + windows_core::imp::define_interface!( + ID3D11Device3, + ID3D11Device3_Vtbl, + 0xa05c8c37_d2c6_4732_b3a0_9ce0b0dc9ae6 + ); + impl core::ops::Deref for ID3D11Device3 { + type Target = ID3D11Device2; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Device3, + windows_core::IUnknown, + ID3D11Device, + ID3D11Device1, + ID3D11Device2 + ); + #[repr(C)] + pub struct ID3D11Device3_Vtbl { + pub base__: ID3D11Device2_Vtbl, + CreateTexture2D1: usize, + CreateTexture3D1: usize, + CreateRasterizerState2: usize, + CreateShaderResourceView1: usize, + CreateUnorderedAccessView1: usize, + CreateRenderTargetView1: usize, + CreateQuery1: usize, + GetImmediateContext3: usize, + CreateDeferredContext3: usize, + WriteToSubresource: usize, + ReadFromSubresource: usize, + } + impl windows_core::RuntimeName for ID3D11Device3 {} + windows_core::imp::define_interface!( + ID3D11Device4, + ID3D11Device4_Vtbl, + 0x8992ab71_02e6_4b8d_ba48_b056dcda42c4 + ); + impl core::ops::Deref for ID3D11Device4 { + type Target = ID3D11Device3; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Device4, + windows_core::IUnknown, + ID3D11Device, + ID3D11Device1, + ID3D11Device2, + ID3D11Device3 + ); + #[repr(C)] + pub struct ID3D11Device4_Vtbl { + pub base__: ID3D11Device3_Vtbl, + RegisterDeviceRemovedEvent: usize, + UnregisterDeviceRemoved: usize, + } + impl windows_core::RuntimeName for ID3D11Device4 {} + windows_core::imp::define_interface!( + ID3D11Device5, + ID3D11Device5_Vtbl, + 0x8ffde202_a0e7_45df_9e01_e837801b5ea0 + ); + impl core::ops::Deref for ID3D11Device5 { + type Target = ID3D11Device4; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Device5, + windows_core::IUnknown, + ID3D11Device, + ID3D11Device1, + ID3D11Device2, + ID3D11Device3, + ID3D11Device4 + ); + impl ID3D11Device5 { + pub unsafe fn CreateFence( + &self, + initialvalue: u64, + flags: D3D11_FENCE_FLAG, + result__: *mut Option, + ) -> windows_core::Result<()> + where + T: windows_core::Interface, + { + unsafe { + (windows_core::Interface::vtable(self).CreateFence)( + windows_core::Interface::as_raw(self), + initialvalue, + flags, + &T::IID, + result__ as *mut _ as *mut _, + ) + .ok() + } + } + } + #[repr(C)] + pub struct ID3D11Device5_Vtbl { + pub base__: ID3D11Device4_Vtbl, + OpenSharedFence: usize, + pub CreateFence: unsafe extern "system" fn( + *mut core::ffi::c_void, + u64, + D3D11_FENCE_FLAG, + *const windows_core::GUID, + *mut *mut core::ffi::c_void, + ) -> windows_core::HRESULT, + } + impl windows_core::RuntimeName for ID3D11Device5 {} + windows_core::imp::define_interface!( + ID3D11DeviceChild, + ID3D11DeviceChild_Vtbl, + 0x1841e5c8_16b0_489b_bcc8_44cfb0d5deae + ); + windows_core::imp::interface_hierarchy!(ID3D11DeviceChild, windows_core::IUnknown); + impl ID3D11DeviceChild { + pub unsafe fn GetDevice(&self) -> windows_core::Result { + unsafe { + let mut result__ = core::mem::zeroed(); + (windows_core::Interface::vtable(self).GetDevice)( + windows_core::Interface::as_raw(self), + &mut result__, + ); + windows_core::imp::Type::from_abi(result__) + } + } + } + #[repr(C)] + pub struct ID3D11DeviceChild_Vtbl { + pub base__: windows_core::IUnknown_Vtbl, + pub GetDevice: + unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void), + GetPrivateData: usize, + SetPrivateData: usize, + SetPrivateDataInterface: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceChild {} + windows_core::imp::define_interface!( + ID3D11DeviceContext, + ID3D11DeviceContext_Vtbl, + 0xc0bfa96c_e089_44fb_8eaf_26f8796190da + ); + impl core::ops::Deref for ID3D11DeviceContext { + type Target = ID3D11DeviceChild; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11DeviceContext, + windows_core::IUnknown, + ID3D11DeviceChild + ); + impl ID3D11DeviceContext { + pub unsafe fn Map( + &self, + presource: P0, + subresource: u32, + maptype: D3D11_MAP, + mapflags: u32, + pmappedresource: Option<*mut D3D11_MAPPED_SUBRESOURCE>, + ) -> windows_core::HRESULT + where + P0: windows_core::Param, + { + unsafe { + (windows_core::Interface::vtable(self).Map)( + windows_core::Interface::as_raw(self), + presource.param().abi(), + subresource, + maptype, + mapflags, + pmappedresource.unwrap_or(core::mem::zeroed()) as _, + ) + } + } + pub unsafe fn Unmap(&self, presource: P0, subresource: u32) + where + P0: windows_core::Param, + { + unsafe { + (windows_core::Interface::vtable(self).Unmap)( + windows_core::Interface::as_raw(self), + presource.param().abi(), + subresource, + ); + } + } + pub unsafe fn CopyResource(&self, pdstresource: P0, psrcresource: P1) + where + P0: windows_core::Param, + P1: windows_core::Param, + { + unsafe { + (windows_core::Interface::vtable(self).CopyResource)( + windows_core::Interface::as_raw(self), + pdstresource.param().abi(), + psrcresource.param().abi(), + ); + } + } + pub unsafe fn Flush(&self) { + unsafe { + (windows_core::Interface::vtable(self).Flush)(windows_core::Interface::as_raw( + self, + )); + } + } + } + #[repr(C)] + pub struct ID3D11DeviceContext_Vtbl { + pub base__: ID3D11DeviceChild_Vtbl, + VSSetConstantBuffers: usize, + PSSetShaderResources: usize, + PSSetShader: usize, + PSSetSamplers: usize, + VSSetShader: usize, + DrawIndexed: usize, + Draw: usize, + pub Map: unsafe extern "system" fn( + *mut core::ffi::c_void, + *mut core::ffi::c_void, + u32, + D3D11_MAP, + u32, + *mut D3D11_MAPPED_SUBRESOURCE, + ) -> windows_core::HRESULT, + pub Unmap: + unsafe extern "system" fn(*mut core::ffi::c_void, *mut core::ffi::c_void, u32), + PSSetConstantBuffers: usize, + IASetInputLayout: usize, + IASetVertexBuffers: usize, + IASetIndexBuffer: usize, + DrawIndexedInstanced: usize, + DrawInstanced: usize, + GSSetConstantBuffers: usize, + GSSetShader: usize, + IASetPrimitiveTopology: usize, + VSSetShaderResources: usize, + VSSetSamplers: usize, + Begin: usize, + End: usize, + GetData: usize, + SetPredication: usize, + GSSetShaderResources: usize, + GSSetSamplers: usize, + OMSetRenderTargets: usize, + OMSetRenderTargetsAndUnorderedAccessViews: usize, + OMSetBlendState: usize, + OMSetDepthStencilState: usize, + SOSetTargets: usize, + DrawAuto: usize, + DrawIndexedInstancedIndirect: usize, + DrawInstancedIndirect: usize, + Dispatch: usize, + DispatchIndirect: usize, + RSSetState: usize, + RSSetViewports: usize, + RSSetScissorRects: usize, + CopySubresourceRegion: usize, + pub CopyResource: unsafe extern "system" fn( + *mut core::ffi::c_void, + *mut core::ffi::c_void, + *mut core::ffi::c_void, + ), + UpdateSubresource: usize, + CopyStructureCount: usize, + ClearRenderTargetView: usize, + ClearUnorderedAccessViewUint: usize, + ClearUnorderedAccessViewFloat: usize, + ClearDepthStencilView: usize, + GenerateMips: usize, + SetResourceMinLOD: usize, + GetResourceMinLOD: usize, + ResolveSubresource: usize, + ExecuteCommandList: usize, + HSSetShaderResources: usize, + HSSetShader: usize, + HSSetSamplers: usize, + HSSetConstantBuffers: usize, + DSSetShaderResources: usize, + DSSetShader: usize, + DSSetSamplers: usize, + DSSetConstantBuffers: usize, + CSSetShaderResources: usize, + CSSetUnorderedAccessViews: usize, + CSSetShader: usize, + CSSetSamplers: usize, + CSSetConstantBuffers: usize, + VSGetConstantBuffers: usize, + PSGetShaderResources: usize, + PSGetShader: usize, + PSGetSamplers: usize, + VSGetShader: usize, + PSGetConstantBuffers: usize, + IAGetInputLayout: usize, + IAGetVertexBuffers: usize, + IAGetIndexBuffer: usize, + GSGetConstantBuffers: usize, + GSGetShader: usize, + IAGetPrimitiveTopology: usize, + VSGetShaderResources: usize, + VSGetSamplers: usize, + GetPredication: usize, + GSGetShaderResources: usize, + GSGetSamplers: usize, + OMGetRenderTargets: usize, + OMGetRenderTargetsAndUnorderedAccessViews: usize, + OMGetBlendState: usize, + OMGetDepthStencilState: usize, + SOGetTargets: usize, + RSGetState: usize, + RSGetViewports: usize, + RSGetScissorRects: usize, + HSGetShaderResources: usize, + HSGetShader: usize, + HSGetSamplers: usize, + HSGetConstantBuffers: usize, + DSGetShaderResources: usize, + DSGetShader: usize, + DSGetSamplers: usize, + DSGetConstantBuffers: usize, + CSGetShaderResources: usize, + CSGetUnorderedAccessViews: usize, + CSGetShader: usize, + CSGetSamplers: usize, + CSGetConstantBuffers: usize, + ClearState: usize, + pub Flush: unsafe extern "system" fn(*mut core::ffi::c_void), + GetType: usize, + GetContextFlags: usize, + FinishCommandList: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceContext {} + windows_core::imp::define_interface!( + ID3D11DeviceContext1, + ID3D11DeviceContext1_Vtbl, + 0xbb2c6faa_b5fb_4082_8e6b_388b8cfa90e1 + ); + impl core::ops::Deref for ID3D11DeviceContext1 { + type Target = ID3D11DeviceContext; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11DeviceContext1, + windows_core::IUnknown, + ID3D11DeviceChild, + ID3D11DeviceContext + ); + #[repr(C)] + pub struct ID3D11DeviceContext1_Vtbl { + pub base__: ID3D11DeviceContext_Vtbl, + CopySubresourceRegion1: usize, + UpdateSubresource1: usize, + DiscardResource: usize, + DiscardView: usize, + VSSetConstantBuffers1: usize, + HSSetConstantBuffers1: usize, + DSSetConstantBuffers1: usize, + GSSetConstantBuffers1: usize, + PSSetConstantBuffers1: usize, + CSSetConstantBuffers1: usize, + VSGetConstantBuffers1: usize, + HSGetConstantBuffers1: usize, + DSGetConstantBuffers1: usize, + GSGetConstantBuffers1: usize, + PSGetConstantBuffers1: usize, + CSGetConstantBuffers1: usize, + SwapDeviceContextState: usize, + ClearView: usize, + DiscardView1: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceContext1 {} + windows_core::imp::define_interface!( + ID3D11DeviceContext2, + ID3D11DeviceContext2_Vtbl, + 0x420d5b32_b90c_4da4_bef0_359f6a24a83a + ); + impl core::ops::Deref for ID3D11DeviceContext2 { + type Target = ID3D11DeviceContext1; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11DeviceContext2, + windows_core::IUnknown, + ID3D11DeviceChild, + ID3D11DeviceContext, + ID3D11DeviceContext1 + ); + #[repr(C)] + pub struct ID3D11DeviceContext2_Vtbl { + pub base__: ID3D11DeviceContext1_Vtbl, + UpdateTileMappings: usize, + CopyTileMappings: usize, + CopyTiles: usize, + UpdateTiles: usize, + ResizeTilePool: usize, + TiledResourceBarrier: usize, + IsAnnotationEnabled: usize, + SetMarkerInt: usize, + BeginEventInt: usize, + EndEvent: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceContext2 {} + windows_core::imp::define_interface!( + ID3D11DeviceContext3, + ID3D11DeviceContext3_Vtbl, + 0xb4e3c01d_e79e_4637_91b2_510e9f4c9b8f + ); + impl core::ops::Deref for ID3D11DeviceContext3 { + type Target = ID3D11DeviceContext2; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11DeviceContext3, + windows_core::IUnknown, + ID3D11DeviceChild, + ID3D11DeviceContext, + ID3D11DeviceContext1, + ID3D11DeviceContext2 + ); + #[repr(C)] + pub struct ID3D11DeviceContext3_Vtbl { + pub base__: ID3D11DeviceContext2_Vtbl, + Flush1: usize, + SetHardwareProtectionState: usize, + GetHardwareProtectionState: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceContext3 {} + windows_core::imp::define_interface!( + ID3D11DeviceContext4, + ID3D11DeviceContext4_Vtbl, + 0x917600da_f58c_4c33_98d8_3e15b390fa24 + ); + impl core::ops::Deref for ID3D11DeviceContext4 { + type Target = ID3D11DeviceContext3; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11DeviceContext4, + windows_core::IUnknown, + ID3D11DeviceChild, + ID3D11DeviceContext, + ID3D11DeviceContext1, + ID3D11DeviceContext2, + ID3D11DeviceContext3 + ); + impl ID3D11DeviceContext4 { + pub unsafe fn Signal(&self, pfence: P0, value: u64) -> windows_core::HRESULT + where + P0: windows_core::Param, + { + unsafe { + (windows_core::Interface::vtable(self).Signal)( + windows_core::Interface::as_raw(self), + pfence.param().abi(), + value, + ) + } + } + } + #[repr(C)] + pub struct ID3D11DeviceContext4_Vtbl { + pub base__: ID3D11DeviceContext3_Vtbl, + pub Signal: unsafe extern "system" fn( + *mut core::ffi::c_void, + *mut core::ffi::c_void, + u64, + ) -> windows_core::HRESULT, + Wait: usize, + } + impl windows_core::RuntimeName for ID3D11DeviceContext4 {} + windows_core::imp::define_interface!( + ID3D11Fence, + ID3D11Fence_Vtbl, + 0xaffde9d1_1df7_4bb7_8a34_0f46251dab80 + ); + impl core::ops::Deref for ID3D11Fence { + type Target = ID3D11DeviceChild; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Fence, + windows_core::IUnknown, + ID3D11DeviceChild + ); + impl ID3D11Fence { + pub unsafe fn CreateSharedHandle( + &self, + pattributes: Option<*const SECURITY_ATTRIBUTES>, + dwaccess: u32, + lpname: P2, + ) -> windows_core::Result + where + P2: windows_core::Param, + { + unsafe { + let mut result__ = core::mem::zeroed(); + (windows_core::Interface::vtable(self).CreateSharedHandle)( + windows_core::Interface::as_raw(self), + pattributes.unwrap_or(core::mem::zeroed()) as _, + dwaccess, + lpname.param().abi(), + &mut result__, + ) + .map(|| result__) + } + } + } + #[repr(C)] + pub struct ID3D11Fence_Vtbl { + pub base__: ID3D11DeviceChild_Vtbl, + pub CreateSharedHandle: unsafe extern "system" fn( + *mut core::ffi::c_void, + *const SECURITY_ATTRIBUTES, + u32, + windows_core::PCWSTR, + *mut HANDLE, + ) -> windows_core::HRESULT, + GetCompletedValue: usize, + SetEventOnCompletion: usize, + } + impl windows_core::RuntimeName for ID3D11Fence {} + windows_core::imp::define_interface!( + ID3D11Resource, + ID3D11Resource_Vtbl, + 0xdc8e63f3_d12b_4952_b47b_5e45026a862d + ); + impl core::ops::Deref for ID3D11Resource { + type Target = ID3D11DeviceChild; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Resource, + windows_core::IUnknown, + ID3D11DeviceChild + ); + #[repr(C)] + pub struct ID3D11Resource_Vtbl { + pub base__: ID3D11DeviceChild_Vtbl, + GetType: usize, + SetEvictionPriority: usize, + GetEvictionPriority: usize, + } + impl windows_core::RuntimeName for ID3D11Resource {} + windows_core::imp::define_interface!( + ID3D11Texture2D, + ID3D11Texture2D_Vtbl, + 0x6f15aaf2_d208_4e89_9ab4_489535d34f9c + ); + impl core::ops::Deref for ID3D11Texture2D { + type Target = ID3D11Resource; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D11Texture2D, + windows_core::IUnknown, + ID3D11DeviceChild, + ID3D11Resource + ); + impl ID3D11Texture2D { + pub unsafe fn GetDesc(&self, pdesc: *mut D3D11_TEXTURE2D_DESC) { + unsafe { + (windows_core::Interface::vtable(self).GetDesc)( + windows_core::Interface::as_raw(self), + pdesc as _, + ); + } + } + } + #[repr(C)] + pub struct ID3D11Texture2D_Vtbl { + pub base__: ID3D11Resource_Vtbl, + pub GetDesc: + unsafe extern "system" fn(*mut core::ffi::c_void, *mut D3D11_TEXTURE2D_DESC), + } + impl windows_core::RuntimeName for ID3D11Texture2D {} + windows_core::imp::define_interface!( + ID3D12CommandQueue, + ID3D12CommandQueue_Vtbl, + 0x0ec870a6_5d7e_4c22_8cfc_5baae07616ed + ); + impl core::ops::Deref for ID3D12CommandQueue { + type Target = ID3D12Pageable; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D12CommandQueue, + windows_core::IUnknown, + ID3D12Object, + ID3D12DeviceChild, + ID3D12Pageable + ); + impl ID3D12CommandQueue { + pub unsafe fn Wait(&self, pfence: P0, value: u64) -> windows_core::HRESULT + where + P0: windows_core::Param, + { + unsafe { + (windows_core::Interface::vtable(self).Wait)( + windows_core::Interface::as_raw(self), + pfence.param().abi(), + value, + ) + } + } + } + #[repr(C)] + pub struct ID3D12CommandQueue_Vtbl { + pub base__: ID3D12Pageable_Vtbl, + UpdateTileMappings: usize, + CopyTileMappings: usize, + ExecuteCommandLists: usize, + SetMarker: usize, + BeginEvent: usize, + EndEvent: usize, + Signal: usize, + pub Wait: unsafe extern "system" fn( + *mut core::ffi::c_void, + *mut core::ffi::c_void, + u64, + ) -> windows_core::HRESULT, + GetTimestampFrequency: usize, + GetClockCalibration: usize, + GetDesc: usize, + } + impl windows_core::RuntimeName for ID3D12CommandQueue {} + windows_core::imp::define_interface!( + ID3D12Device, + ID3D12Device_Vtbl, + 0x189819f1_1db6_4b57_be54_1821339b85f7 + ); + impl core::ops::Deref for ID3D12Device { + type Target = ID3D12Object; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!(ID3D12Device, windows_core::IUnknown, ID3D12Object); + impl ID3D12Device { + pub unsafe fn OpenSharedHandle( + &self, + nthandle: HANDLE, + result__: *mut Option, + ) -> windows_core::Result<()> + where + T: windows_core::Interface, + { + unsafe { + (windows_core::Interface::vtable(self).OpenSharedHandle)( + windows_core::Interface::as_raw(self), + nthandle, + &T::IID, + result__ as *mut _ as *mut _, + ) + .ok() + } + } + } + #[repr(C)] + pub struct ID3D12Device_Vtbl { + pub base__: ID3D12Object_Vtbl, + GetNodeCount: usize, + CreateCommandQueue: usize, + CreateCommandAllocator: usize, + CreateGraphicsPipelineState: usize, + CreateComputePipelineState: usize, + CreateCommandList: usize, + CheckFeatureSupport: usize, + CreateDescriptorHeap: usize, + GetDescriptorHandleIncrementSize: usize, + CreateRootSignature: usize, + CreateConstantBufferView: usize, + CreateShaderResourceView: usize, + CreateUnorderedAccessView: usize, + CreateRenderTargetView: usize, + CreateDepthStencilView: usize, + CreateSampler: usize, + CopyDescriptors: usize, + CopyDescriptorsSimple: usize, + GetResourceAllocationInfo: usize, + GetCustomHeapProperties: usize, + CreateCommittedResource: usize, + CreateHeap: usize, + CreatePlacedResource: usize, + CreateReservedResource: usize, + CreateSharedHandle: usize, + pub OpenSharedHandle: unsafe extern "system" fn( + *mut core::ffi::c_void, + HANDLE, + *const windows_core::GUID, + *mut *mut core::ffi::c_void, + ) -> windows_core::HRESULT, + OpenSharedHandleByName: usize, + MakeResident: usize, + Evict: usize, + CreateFence: usize, + GetDeviceRemovedReason: usize, + GetCopyableFootprints: usize, + CreateQueryHeap: usize, + SetStablePowerState: usize, + CreateCommandSignature: usize, + GetResourceTiling: usize, + GetAdapterLuid: usize, + } + impl windows_core::RuntimeName for ID3D12Device {} + windows_core::imp::define_interface!( + ID3D12DeviceChild, + ID3D12DeviceChild_Vtbl, + 0x905db94b_a00c_4140_9df5_2b64ca9ea357 + ); + impl core::ops::Deref for ID3D12DeviceChild { + type Target = ID3D12Object; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D12DeviceChild, + windows_core::IUnknown, + ID3D12Object + ); + #[repr(C)] + pub struct ID3D12DeviceChild_Vtbl { + pub base__: ID3D12Object_Vtbl, + GetDevice: usize, + } + impl windows_core::RuntimeName for ID3D12DeviceChild {} + windows_core::imp::define_interface!( + ID3D12Fence, + ID3D12Fence_Vtbl, + 0x0a753dcf_c4d8_4b91_adf6_be5a60d95a76 + ); + impl core::ops::Deref for ID3D12Fence { + type Target = ID3D12Pageable; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D12Fence, + windows_core::IUnknown, + ID3D12Object, + ID3D12DeviceChild, + ID3D12Pageable + ); + #[repr(C)] + pub struct ID3D12Fence_Vtbl { + pub base__: ID3D12Pageable_Vtbl, + GetCompletedValue: usize, + SetEventOnCompletion: usize, + Signal: usize, + } + impl windows_core::RuntimeName for ID3D12Fence {} + windows_core::imp::define_interface!( + ID3D12Object, + ID3D12Object_Vtbl, + 0xc4fec28f_7966_4e95_9f94_f431cb56c3b8 + ); + windows_core::imp::interface_hierarchy!(ID3D12Object, windows_core::IUnknown); + #[repr(C)] + pub struct ID3D12Object_Vtbl { + pub base__: windows_core::IUnknown_Vtbl, + GetPrivateData: usize, + SetPrivateData: usize, + SetPrivateDataInterface: usize, + SetName: usize, + } + impl windows_core::RuntimeName for ID3D12Object {} + windows_core::imp::define_interface!( + ID3D12Pageable, + ID3D12Pageable_Vtbl, + 0x63ee58fb_1268_4835_86da_f008ce62f0d6 + ); + impl core::ops::Deref for ID3D12Pageable { + type Target = ID3D12DeviceChild; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D12Pageable, + windows_core::IUnknown, + ID3D12Object, + ID3D12DeviceChild + ); + #[repr(C)] + pub struct ID3D12Pageable_Vtbl { + pub base__: ID3D12DeviceChild_Vtbl, + } + impl windows_core::RuntimeName for ID3D12Pageable {} + windows_core::imp::define_interface!( + ID3D12Resource, + ID3D12Resource_Vtbl, + 0x696442be_a72e_4059_bc79_5b5c98040fad + ); + impl core::ops::Deref for ID3D12Resource { + type Target = ID3D12Pageable; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + ID3D12Resource, + windows_core::IUnknown, + ID3D12Object, + ID3D12DeviceChild, + ID3D12Pageable + ); + #[repr(C)] + pub struct ID3D12Resource_Vtbl { + pub base__: ID3D12Pageable_Vtbl, + Map: usize, + Unmap: usize, + GetDesc: usize, + GetGPUVirtualAddress: usize, + WriteToSubresource: usize, + ReadFromSubresource: usize, + GetHeapProperties: usize, + } + impl windows_core::RuntimeName for ID3D12Resource {} + windows_core::imp::define_interface!( + IDXGIDeviceSubObject, + IDXGIDeviceSubObject_Vtbl, + 0x3d3e0379_f9de_4d58_bb6c_18d62992f1a6 + ); + impl core::ops::Deref for IDXGIDeviceSubObject { + type Target = IDXGIObject; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + IDXGIDeviceSubObject, + windows_core::IUnknown, + IDXGIObject + ); + #[repr(C)] + pub struct IDXGIDeviceSubObject_Vtbl { + pub base__: IDXGIObject_Vtbl, + GetDevice: usize, + } + impl windows_core::RuntimeName for IDXGIDeviceSubObject {} + windows_core::imp::define_interface!( + IDXGIObject, + IDXGIObject_Vtbl, + 0xaec22fb8_76f3_4639_9be0_28eb43a67a2e + ); + windows_core::imp::interface_hierarchy!(IDXGIObject, windows_core::IUnknown); + #[repr(C)] + pub struct IDXGIObject_Vtbl { + pub base__: windows_core::IUnknown_Vtbl, + SetPrivateData: usize, + SetPrivateDataInterface: usize, + GetPrivateData: usize, + GetParent: usize, + } + impl windows_core::RuntimeName for IDXGIObject {} + windows_core::imp::define_interface!( + IDXGIResource, + IDXGIResource_Vtbl, + 0x035f3ab4_482e_4e50_b41f_8a7f8bd8960b + ); + impl core::ops::Deref for IDXGIResource { + type Target = IDXGIDeviceSubObject; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + IDXGIResource, + windows_core::IUnknown, + IDXGIObject, + IDXGIDeviceSubObject + ); + #[repr(C)] + pub struct IDXGIResource_Vtbl { + pub base__: IDXGIDeviceSubObject_Vtbl, + GetSharedHandle: usize, + GetUsage: usize, + SetEvictionPriority: usize, + GetEvictionPriority: usize, + } + impl windows_core::RuntimeName for IDXGIResource {} + windows_core::imp::define_interface!( + IDXGIResource1, + IDXGIResource1_Vtbl, + 0x30961379_4609_4a41_998e_54fe567ee0c1 + ); + impl core::ops::Deref for IDXGIResource1 { + type Target = IDXGIResource; + fn deref(&self) -> &Self::Target { + unsafe { core::mem::transmute(self) } + } + } + windows_core::imp::interface_hierarchy!( + IDXGIResource1, + windows_core::IUnknown, + IDXGIObject, + IDXGIDeviceSubObject, + IDXGIResource + ); + impl IDXGIResource1 { + pub unsafe fn CreateSharedHandle( + &self, + pattributes: Option<*const SECURITY_ATTRIBUTES>, + dwaccess: u32, + lpname: P2, + ) -> windows_core::Result + where + P2: windows_core::Param, + { + unsafe { + let mut result__ = core::mem::zeroed(); + (windows_core::Interface::vtable(self).CreateSharedHandle)( + windows_core::Interface::as_raw(self), + pattributes.unwrap_or(core::mem::zeroed()) as _, + dwaccess, + lpname.param().abi(), + &mut result__, + ) + .map(|| result__) + } + } + } + #[repr(C)] + pub struct IDXGIResource1_Vtbl { + pub base__: IDXGIResource_Vtbl, + CreateSubresourceSurface: usize, + pub CreateSharedHandle: unsafe extern "system" fn( + *mut core::ffi::c_void, + *const SECURITY_ATTRIBUTES, + u32, + windows_core::PCWSTR, + *mut HANDLE, + ) -> windows_core::HRESULT, + } + impl windows_core::RuntimeName for IDXGIResource1 {} + #[repr(C)] + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct SECURITY_ATTRIBUTES { + pub nLength: u32, + pub lpSecurityDescriptor: *mut core::ffi::c_void, + pub bInheritHandle: windows_core::BOOL, + } + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs new file mode 100644 index 000000000..72b2c360e --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs @@ -0,0 +1,188 @@ +use super::bindings::Windows::Win32::{ + CloseHandle, HANDLE, ID3D11DeviceContext4, ID3D11Fence, ID3D11Texture2D, ID3D12CommandQueue, + ID3D12Device, ID3D12Fence, ID3D12Resource, +}; +use super::{ + capture_size, capture_texture_descriptor, source_descriptor, validate_capture_descriptor, +}; +use anyhow::{Context as _, Result}; +use windows_core::Interface as _; + +pub(super) struct SharedTexture { + pub(super) size: wgpu::Extent3d, + destination: ID3D11Texture2D, + context: ID3D11DeviceContext4, + d3d11_fence: ID3D11Fence, + d3d12_fence: ID3D12Fence, + d3d12_queue: ID3D12CommandQueue, + fence_value: u64, +} + +impl SharedTexture { + pub(super) fn new( + device: &wgpu::Device, + hal_device: &wgpu::hal::dx12::Device, + source: &ID3D11Texture2D, + ) -> Result<(Self, wgpu::Texture)> { + use super::bindings::Windows::Win32::{ + D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, + D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_USAGE_DEFAULT, + DXGI_FORMAT_B8G8R8A8_TYPELESS, GENERIC_ALL, ID3D11Device5, IDXGIResource1, + }; + + let mut descriptor = source_descriptor(source); + validate_capture_descriptor(&descriptor)?; + descriptor.Format = DXGI_FORMAT_B8G8R8A8_TYPELESS; + descriptor.Usage = D3D11_USAGE_DEFAULT; + descriptor.BindFlags = D3D11_BIND_SHADER_RESOURCE as u32; + descriptor.CPUAccessFlags = 0; + descriptor.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE as u32; + + let source_device = + unsafe { source.GetDevice() }.context("getting capture D3D11 device")?; + let context: ID3D11DeviceContext4 = unsafe { source_device.GetImmediateContext() } + .context("getting capture D3D11 context")? + .cast() + .context("D3D11 fence signaling is unavailable")?; + let mut destination = None; + unsafe { + source_device.CreateTexture2D( + &descriptor, + None, + Some(std::ptr::addr_of_mut!(destination)), + ) + } + .ok() + .context("creating shared D3D11 capture texture")?; + let destination = destination.context("D3D11 returned no shared texture")?; + + let device5: ID3D11Device5 = source_device + .cast() + .context("D3D11 shared fences are unavailable")?; + let mut d3d11_fence: Option = None; + unsafe { + device5.CreateFence( + 0, + D3D11_FENCE_FLAG_SHARED, + std::ptr::addr_of_mut!(d3d11_fence), + ) + } + .context("creating shared D3D11 capture fence")?; + let d3d11_fence = d3d11_fence.context("D3D11 returned no shared fence")?; + + let resource: IDXGIResource1 = destination + .cast() + .context("querying shared DXGI resource")?; + let resource_handle = + unsafe { resource.CreateSharedHandle(None, GENERIC_ALL as u32, None) } + .map(OwnedHandle) + .context("creating capture resource handle")?; + let fence_handle = + unsafe { d3d11_fence.CreateSharedHandle(None, GENERIC_ALL as u32, None) } + .map(OwnedHandle) + .context("creating capture fence handle")?; + let (d3d12_resource, d3d12_fence) = + open_shared_resources(hal_device, &resource_handle, &fence_handle)?; + + let size = capture_size(&descriptor); + let hal_texture = unsafe { + wgpu::hal::dx12::Device::texture_from_raw( + // WGPU's bindings use the same ID3D12Resource COM ABI. Transfer + // ownership into the HAL so it releases this reference with the texture. + std::mem::transmute(d3d12_resource), + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureDimension::D2, + size, + 1, + 1, + ) + }; + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &capture_texture_descriptor("windows_capture_shared", size), + ) + }; + Ok(( + Self { + size, + destination, + context, + d3d11_fence, + d3d12_fence, + // Transfer an owned ID3D12CommandQueue reference between bindings + // with identical COM layout and reference-counting semantics. + d3d12_queue: unsafe { std::mem::transmute(hal_device.raw_queue().clone()) }, + fence_value: 0, + }, + texture, + )) + } + + pub(super) fn update(&mut self, source: &ID3D11Texture2D) -> Result<()> { + // CachedTexture gives each (native texture pointer, capture timestamp) frame + // its own destination and initializes it once. That makes this destination + // immutable while D3D12 samples it; only the producer-to-consumer handoff is + // needed here. + let fence_value = self + .fence_value + .checked_add(1) + .context("capture fence value exhausted")?; + unsafe { self.context.CopyResource(&self.destination, source) }; + unsafe { self.context.Signal(&self.d3d11_fence, fence_value) } + .ok() + .context("signaling capture copy completion")?; + // D3D11 immediate contexts can defer command submission. Flush after the + // producer signal so the D3D12 queue wait below cannot wait on work that + // is still sitting in the D3D11 command buffer. + unsafe { self.context.Flush() }; + unsafe { self.d3d12_queue.Wait(&self.d3d12_fence, fence_value) } + .ok() + .context("waiting for D3D11 capture copy")?; + self.fence_value = fence_value; + Ok(()) + } +} + +fn open_shared_resources( + hal_device: &wgpu::hal::dx12::Device, + resource_handle: &OwnedHandle, + fence_handle: &OwnedHandle, +) -> Result<(ID3D12Resource, ID3D12Fence)> { + // Clone before transferring this ABI-stable ID3D12Device pointer so our + // generated binding owns a reference independent of the HAL device. + let raw_device: ID3D12Device = unsafe { std::mem::transmute(hal_device.raw_device().clone()) }; + let mut resource: Option = None; + let mut fence: Option = None; + unsafe { + raw_device.OpenSharedHandle( + HANDLE(resource_handle.raw()), + std::ptr::addr_of_mut!(resource), + ) + } + .context("opening capture texture on D3D12")?; + unsafe { + raw_device.OpenSharedHandle(HANDLE(fence_handle.raw()), std::ptr::addr_of_mut!(fence)) + } + .context("opening capture fence on D3D12")?; + Ok(( + resource.context("D3D12 returned no capture texture")?, + fence.context("D3D12 returned no capture fence")?, + )) +} + +struct OwnedHandle(HANDLE); + +impl OwnedHandle { + fn raw(&self) -> *mut std::ffi::c_void { + self.0.0 + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if let Err(error) = unsafe { CloseHandle(self.0) }.ok() { + log::error!("failed to close shared capture handle: {error}"); + } + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs new file mode 100644 index 000000000..39fc02c68 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs @@ -0,0 +1,121 @@ +use super::bindings::Windows::Win32::{ + D3D11_MAPPED_SUBRESOURCE, ID3D11DeviceContext, ID3D11Texture2D, +}; +use super::{ + capture_size, capture_texture_descriptor, source_descriptor, validate_capture_descriptor, +}; +use anyhow::{Context as _, Result}; + +pub(super) struct UploadedTexture { + pub(super) size: wgpu::Extent3d, + context: ID3D11DeviceContext, + staging: ID3D11Texture2D, + pixels: Vec, + texture: wgpu::Texture, +} + +impl UploadedTexture { + pub(super) fn new( + device: &wgpu::Device, + source: &ID3D11Texture2D, + ) -> Result<(Self, wgpu::Texture)> { + use super::bindings::Windows::Win32::{D3D11_CPU_ACCESS_READ, D3D11_USAGE_STAGING}; + + let mut descriptor = source_descriptor(source); + validate_capture_descriptor(&descriptor)?; + descriptor.Usage = D3D11_USAGE_STAGING; + descriptor.BindFlags = 0; + descriptor.CPUAccessFlags = D3D11_CPU_ACCESS_READ as u32; + descriptor.MiscFlags = 0; + let size = capture_size(&descriptor); + + let source_device = + unsafe { source.GetDevice() }.context("getting capture D3D11 device")?; + let context = unsafe { source_device.GetImmediateContext() } + .context("getting capture D3D11 context")?; + let mut staging = None; + unsafe { + source_device.CreateTexture2D(&descriptor, None, Some(std::ptr::addr_of_mut!(staging))) + } + .ok() + .context("creating D3D11 capture staging texture")?; + let staging = staging.context("D3D11 returned no staging texture")?; + let texture = device.create_texture(&wgpu::TextureDescriptor { + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ..capture_texture_descriptor("windows_capture_uploaded", size) + }); + Ok(( + Self { + size, + context, + staging, + pixels: vec![0; size.width as usize * size.height as usize * 4], + texture: texture.clone(), + }, + texture, + )) + } + + pub(super) fn update(&mut self, queue: &wgpu::Queue, source: &ID3D11Texture2D) -> Result<()> { + unsafe { self.context.CopyResource(&self.staging, source) }; + let mapped = MappedTexture::new(&self.context, &self.staging)?; + let bytes_per_row = self.size.width as usize * 4; + for row in 0..self.size.height as usize { + let source = mapped.row(row, bytes_per_row); + self.pixels[row * bytes_per_row..(row + 1) * bytes_per_row].copy_from_slice(source); + } + drop(mapped); + + queue.write_texture( + self.texture.as_image_copy(), + &self.pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row as u32), + rows_per_image: Some(self.size.height), + }, + self.size, + ); + Ok(()) + } +} + +struct MappedTexture<'a> { + context: &'a ID3D11DeviceContext, + texture: &'a ID3D11Texture2D, + mapped: D3D11_MAPPED_SUBRESOURCE, +} + +impl<'a> MappedTexture<'a> { + fn new(context: &'a ID3D11DeviceContext, texture: &'a ID3D11Texture2D) -> Result { + use super::bindings::Windows::Win32::D3D11_MAP_READ; + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { context.Map(texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) } + .ok() + .context("mapping D3D11 capture texture")?; + Ok(Self { + context, + texture, + mapped, + }) + } + + fn row(&self, row: usize, bytes_per_row: usize) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + self.mapped + .pData + .cast::() + .add(row * self.mapped.RowPitch as usize), + bytes_per_row, + ) + } + } +} + +impl Drop for MappedTexture<'_> { + fn drop(&mut self) { + unsafe { self.context.Unmap(self.texture, 0) }; + } +} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/target.rs b/vendor/gpui-wgpu/src/wgpu_renderer/target.rs new file mode 100644 index 000000000..2f2b9d140 --- /dev/null +++ b/vendor/gpui-wgpu/src/wgpu_renderer/target.rs @@ -0,0 +1,519 @@ +use gpui::{DevicePixels, Size}; + +use super::WgpuSurfaceConfig; + +/// Configuration and lifecycle state shared by window, canvas, and headless targets. +pub(super) struct RenderTarget { + config: wgpu::SurfaceConfiguration, + transparent_alpha_mode: wgpu::CompositeAlphaMode, + opaque_alpha_mode: wgpu::CompositeAlphaMode, + maximum_dimension: u32, + configured: bool, + needs_redraw: bool, + copy_backend_supported: bool, + copy_surface_supported: bool, + pub(super) clear_color: wgpu::Color, +} + +impl RenderTarget { + pub(super) fn new( + adapter: &wgpu::Adapter, + device: &wgpu::Device, + surface: Option<&wgpu::Surface<'_>>, + requested: WgpuSurfaceConfig, + backend: wgpu::Backend, + ) -> anyhow::Result { + let ( + format, + transparent_alpha_mode, + opaque_alpha_mode, + present_mode, + copy_surface_supported, + ) = if let Some(surface) = surface { + let capabilities = surface.get_capabilities(adapter); + let format = [ + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureFormat::Rgba8Unorm, + ] + .into_iter() + .find(|format| capabilities.formats.contains(format)) + .or_else(|| { + capabilities + .formats + .iter() + .find(|format| !format.is_srgb()) + .copied() + }) + .or_else(|| capabilities.formats.first().copied()) + .ok_or_else(|| { + anyhow::anyhow!( + "surface reports no texture formats for adapter {:?}", + adapter.get_info().name + ) + })?; + let (transparent, opaque) = + select_alpha_modes(&capabilities.alpha_modes).ok_or_else(|| { + anyhow::anyhow!( + "surface reports no alpha modes for adapter {:?}", + adapter.get_info().name + ) + })?; + let present_mode = select_present_mode( + requested.preferred_present_mode, + &capabilities.present_modes, + wgpu::PresentMode::Fifo, + ); + ( + format, + transparent, + opaque, + present_mode, + capabilities.usages.contains(wgpu::TextureUsages::COPY_DST), + ) + } else { + // Native desktop swapchains are BGRA; matching that keeps snapshots comparable. + ( + wgpu::TextureFormat::Bgra8Unorm, + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Opaque, + wgpu::PresentMode::Fifo, + true, + ) + }; + let maximum_dimension = device.limits().max_texture_dimension_2d; + let (width, height) = clamped_size(requested.size, maximum_dimension); + let alpha_mode = if requested.transparent { + transparent_alpha_mode + } else { + opaque_alpha_mode + }; + let mut target = Self { + config: wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width, + height, + present_mode, + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: Vec::new(), + }, + transparent_alpha_mode, + opaque_alpha_mode, + maximum_dimension, + configured: surface.is_some(), + needs_redraw: false, + copy_backend_supported: cfg!(target_os = "windows") && backend == wgpu::Backend::Dx12, + copy_surface_supported, + clear_color: clear_color(requested.transparent), + }; + target.refresh_copy_usage(); + Ok(target) + } + + pub(super) fn configuration(&self) -> &wgpu::SurfaceConfiguration { + &self.config + } + + pub(super) fn resize(&mut self, size: Size) -> bool { + let (width, height) = clamped_size(size, self.maximum_dimension); + if (width, height) == (self.config.width, self.config.height) { + return false; + } + self.config.width = width; + self.config.height = height; + true + } + + /// Returns whether blend pipelines must be rebuilt. + pub(super) fn set_transparent(&mut self, transparent: bool) -> bool { + self.clear_color = clear_color(transparent); + let alpha_mode = if transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + if alpha_mode == self.config.alpha_mode { + return false; + } + self.config.alpha_mode = alpha_mode; + self.refresh_copy_usage(); + true + } + + /// Whether the retained frame reaches the swapchain by copy rather than blit. + pub(super) fn retained_copy_enabled(&self) -> bool { + self.copy_backend_supported + && self.copy_surface_supported + // The formats the copy is verified with. + && matches!( + self.config.format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm + ) + && super::retained::alpha_supported(self.config.alpha_mode) + } + + fn refresh_copy_usage(&mut self) { + let enabled = self.retained_copy_enabled(); + self.config + .usage + .set(wgpu::TextureUsages::COPY_DST, enabled); + } + + #[cfg(not(target_family = "wasm"))] + pub(super) fn update_surface_copy_support( + &mut self, + usages: wgpu::TextureUsages, + formats: &[wgpu::TextureFormat], + ) { + self.copy_surface_supported = + usages.contains(wgpu::TextureUsages::COPY_DST) && formats.contains(&self.config.format); + self.refresh_copy_usage(); + } + + #[cfg(all(test, target_os = "windows"))] + pub(super) fn set_format_for_tests(&mut self, format: wgpu::TextureFormat) { + self.config.format = format; + self.refresh_copy_usage(); + } + + #[cfg(not(target_family = "wasm"))] + pub(super) fn apply( + &mut self, + requested: WgpuSurfaceConfig, + capabilities: &wgpu::SurfaceCapabilities, + ) -> anyhow::Result { + // Validate before changing the live target. An unsupported retained alpha + // mode would invalidate configure() and misreport presentation-copy support. + let (transparent, opaque) = select_alpha_modes(&capabilities.alpha_modes) + .ok_or_else(|| anyhow::anyhow!("replacement surface reports no alpha modes"))?; + anyhow::ensure!( + capabilities.formats.contains(&self.config.format), + "replacement surface does not support the existing target format {:?}", + self.config.format + ); + self.transparent_alpha_mode = transparent; + self.opaque_alpha_mode = opaque; + self.update_surface_copy_support(capabilities.usages, &capabilities.formats); + self.resize(requested.size); + self.config.present_mode = select_present_mode( + requested.preferred_present_mode, + &capabilities.present_modes, + self.config.present_mode, + ); + Ok(self.set_transparent(requested.transparent)) + } + + #[cfg(not(target_family = "wasm"))] + pub(super) fn recovery_config(&self) -> WgpuSurfaceConfig { + WgpuSurfaceConfig { + size: self.viewport_size(), + transparent: self.config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, + preferred_present_mode: Some(self.config.present_mode), + } + } + + pub(super) fn viewport_size(&self) -> Size { + Size { + width: DevicePixels(self.config.width as i32), + height: DevicePixels(self.config.height as i32), + } + } + + pub(super) fn format(&self) -> wgpu::TextureFormat { + self.config.format + } + + pub(super) fn width(&self) -> u32 { + self.config.width + } + + pub(super) fn height(&self) -> u32 { + self.config.height + } + + pub(super) fn alpha_mode(&self) -> wgpu::CompositeAlphaMode { + self.config.alpha_mode + } + + pub(super) fn maximum_dimension(&self) -> u32 { + self.maximum_dimension + } + + pub(super) fn clear_color(&self) -> wgpu::Color { + self.clear_color + } + + pub(super) fn is_configured(&self) -> bool { + self.configured + } + + pub(super) fn set_configured(&mut self, configured: bool) { + self.configured = configured; + } + + pub(super) fn request_redraw(&mut self) { + self.needs_redraw = true; + } + + pub(super) fn take_redraw_request(&mut self) -> bool { + std::mem::take(&mut self.needs_redraw) + } +} + +fn select_present_mode( + requested: Option, + supported: &[wgpu::PresentMode], + current: wgpu::PresentMode, +) -> wgpu::PresentMode { + // AutoVsync and AutoNoVsync are valid on every surface even though they + // are intentionally omitted from SurfaceCapabilities::present_modes. + let candidate = requested.unwrap_or(current); + if is_supported(candidate, supported) { + return candidate; + } + // FIFO is guaranteed by the surface API, even if a backend omits it from + // its capability list. + wgpu::PresentMode::Fifo +} + +fn is_supported(mode: wgpu::PresentMode, supported: &[wgpu::PresentMode]) -> bool { + matches!( + mode, + wgpu::PresentMode::AutoVsync | wgpu::PresentMode::AutoNoVsync | wgpu::PresentMode::Fifo + ) || supported.contains(&mode) +} + +fn clamped_size(size: Size, maximum: u32) -> (u32, u32) { + let requested_width = size.width.0.max(1) as u32; + let requested_height = size.height.0.max(1) as u32; + let width = requested_width.min(maximum); + let height = requested_height.min(maximum); + if (width, height) != (requested_width, requested_height) { + log::warn!( + "requested target size ({requested_width}, {requested_height}) exceeds maximum texture dimension {maximum}; clamping to ({width}, {height})" + ); + } + (width, height) +} + +/// The supported alpha modes for transparent and opaque windows, in that order. +fn select_alpha_modes( + supported: &[wgpu::CompositeAlphaMode], +) -> Option<(wgpu::CompositeAlphaMode, wgpu::CompositeAlphaMode)> { + // A non-opaque CAMetalLayer, which WGPU's Metal backend reports as + // PostMultiplied, composites what gpui draws there as it does with Metal. + #[cfg(target_os = "macos")] + const TRANSPARENT: &[wgpu::CompositeAlphaMode] = &[ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Inherit, + wgpu::CompositeAlphaMode::PostMultiplied, + ]; + #[cfg(not(target_os = "macos"))] + const TRANSPARENT: &[wgpu::CompositeAlphaMode] = &[ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Inherit, + ]; + const OPAQUE: &[wgpu::CompositeAlphaMode] = &[ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::Inherit, + ]; + let pick = |preferences: &[wgpu::CompositeAlphaMode]| { + preferences + .iter() + .find(|mode| supported.contains(mode)) + .or_else(|| supported.first()) + .copied() + }; + Some((pick(TRANSPARENT)?, pick(OPAQUE)?)) +} + +fn clear_color(transparent: bool) -> wgpu::Color { + if transparent { + return wgpu::Color::TRANSPARENT; + } + #[cfg(target_os = "windows")] + return wgpu::Color::WHITE; + #[cfg(target_os = "macos")] + return wgpu::Color::BLACK; + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + wgpu::Color::TRANSPARENT +} + +#[cfg(test)] +mod tests { + use super::{select_alpha_modes, select_present_mode}; + + #[cfg(not(target_family = "wasm"))] + #[test] + fn replacement_reselects_supported_alpha_and_copy_usage_before_configuration() { + use super::{RenderTarget, WgpuSurfaceConfig}; + use gpui::{DevicePixels, Size}; + use wgpu::{CompositeAlphaMode as Alpha, TextureUsages as Usage}; + let size = Size { + width: DevicePixels(201), + height: DevicePixels(103), + }; + let request = |transparent| WgpuSurfaceConfig { + size, + transparent, + preferred_present_mode: None, + }; + let mut target = RenderTarget { + config: wgpu::SurfaceConfiguration { + usage: Usage::RENDER_ATTACHMENT | Usage::COPY_DST, + format: wgpu::TextureFormat::Bgra8Unorm, + width: 201, + height: 103, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: Alpha::PreMultiplied, + view_formats: Vec::new(), + }, + transparent_alpha_mode: Alpha::PreMultiplied, + opaque_alpha_mode: Alpha::Opaque, + maximum_dimension: 4096, + configured: true, + needs_redraw: false, + copy_backend_supported: true, + copy_surface_supported: true, + clear_color: wgpu::Color::TRANSPARENT, + }; + let mut caps = wgpu::SurfaceCapabilities { + formats: vec![wgpu::TextureFormat::Bgra8Unorm], + present_modes: vec![wgpu::PresentMode::Fifo], + alpha_modes: vec![Alpha::Opaque, Alpha::Inherit], + usages: Usage::RENDER_ATTACHMENT | Usage::COPY_DST, + }; + assert!(target.apply(request(true), &caps).unwrap()); + assert_eq!(target.alpha_mode(), Alpha::Inherit); + assert!(!target.retained_copy_enabled()); + assert!(!target.configuration().usage.contains(Usage::COPY_DST)); + assert!(target.apply(request(false), &caps).unwrap()); + assert_eq!(target.alpha_mode(), Alpha::Opaque); + assert!(target.retained_copy_enabled()); + caps.alpha_modes = vec![Alpha::PreMultiplied, Alpha::Opaque]; + assert!(target.apply(request(true), &caps).unwrap()); + assert_eq!(target.alpha_mode(), Alpha::PreMultiplied); + assert!(target.configuration().usage.contains(Usage::COPY_DST)); + caps.usages = Usage::RENDER_ATTACHMENT; + assert!(!target.apply(request(true), &caps).unwrap()); + assert!(!target.retained_copy_enabled()); + assert!(!target.configuration().usage.contains(Usage::COPY_DST)); + let unchanged = target.configuration().clone(); + caps.alpha_modes.clear(); + assert!(target.apply(request(true), &caps).is_err()); + assert_eq!( + *target.configuration(), + unchanged, + "failed replacement must leave the live configuration intact" + ); + caps.alpha_modes = vec![Alpha::PreMultiplied]; + caps.formats = vec![wgpu::TextureFormat::Rgba8Unorm]; + assert!(target.apply(request(true), &caps).is_err()); + assert_eq!(*target.configuration(), unchanged); + } + + #[cfg(target_os = "macos")] + #[test] + fn metal_transparent_windows_are_not_opaque() { + // What WGPU's Metal backend reports for a CAMetalLayer. + let metal = [ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::PostMultiplied, + ]; + assert_eq!( + select_alpha_modes(&metal), + Some(( + wgpu::CompositeAlphaMode::PostMultiplied, + wgpu::CompositeAlphaMode::Opaque, + )), + ); + } + + #[test] + fn alpha_modes_follow_preference_then_the_first_supported() { + use wgpu::CompositeAlphaMode::{Inherit, Opaque, PostMultiplied, PreMultiplied}; + + assert_eq!( + select_alpha_modes(&[PostMultiplied, Opaque, PreMultiplied]), + Some((PreMultiplied, Opaque)), + ); + assert_eq!(select_alpha_modes(&[Inherit]), Some((Inherit, Inherit))); + // Neither opaque preference is offered, so opaque windows take the + // first mode the surface supports. + assert_eq!( + select_alpha_modes(&[PostMultiplied]), + Some((PostMultiplied, PostMultiplied)), + ); + } + + #[test] + fn no_alpha_modes_select_nothing() { + assert_eq!(select_alpha_modes(&[]), None); + } + + #[test] + fn unsupported_present_mode_falls_back_to_fifo() { + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::Immediate), + &[wgpu::PresentMode::Fifo], + wgpu::PresentMode::Fifo, + ), + wgpu::PresentMode::Fifo, + ); + } + + #[test] + fn missing_capabilities_keep_current_mode() { + assert_eq!( + select_present_mode(None, &[], wgpu::PresentMode::Fifo,), + wgpu::PresentMode::Fifo, + ); + } + + #[test] + fn none_preserves_supported_current_mode() { + assert_eq!( + select_present_mode( + None, + &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Mailbox], + wgpu::PresentMode::Mailbox, + ), + wgpu::PresentMode::Mailbox, + ); + } + + #[test] + fn none_falls_back_when_replacement_surface_drops_current_mode() { + assert_eq!( + select_present_mode(None, &[wgpu::PresentMode::Fifo], wgpu::PresentMode::Mailbox,), + wgpu::PresentMode::Fifo, + ); + } + + #[test] + fn none_preserves_unlisted_auto_current_mode() { + assert_eq!( + select_present_mode( + None, + &[wgpu::PresentMode::Fifo], + wgpu::PresentMode::AutoVsync, + ), + wgpu::PresentMode::AutoVsync, + ); + } + + #[test] + fn auto_modes_are_valid_without_capability_listing() { + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::AutoNoVsync), + &[wgpu::PresentMode::Fifo], + wgpu::PresentMode::Fifo, + ), + wgpu::PresentMode::AutoNoVsync, + ); + } +} diff --git a/vendor/gpui-wgpu/tests/headless_primitives.rs b/vendor/gpui-wgpu/tests/headless_primitives.rs new file mode 100644 index 000000000..d4909b179 --- /dev/null +++ b/vendor/gpui-wgpu/tests/headless_primitives.rs @@ -0,0 +1,642 @@ +//! Renders one of every primitive kind through the headless WGPU renderer and checks +//! that each one actually lands on the target. This is the cross-platform smoke test for +//! the shared render plan, instance transport, and generated shaders. +#![cfg(feature = "test-support")] + +use gpui::{ + AtlasKey, AtlasTile, BackdropFilter, BorderStyle, Bounds, ColorSpace, ContentMask, Corners, + DevicePixels, Edges, Hsla, MonochromeSprite, PlatformHeadlessRenderer, Point, PolychromeSprite, + Quad, RenderImageParams, RenderSvgParams, ScaledFilter, ScaledPixels, Scene, ShaderBool, + Shadow, Size, Underline, checkerboard, linear_color_stop, linear_gradient, solid_background, +}; +use gpui_ce_wgpu::WgpuHeadlessRenderer; +use smallvec::smallvec; +use std::borrow::Cow; + +const TARGET: Size = Size { + width: DevicePixels(310), + height: DevicePixels(100), +}; + +#[test] +fn filled_path_resolves_without_invalidating_the_frame() { + let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds: bounds(0.0, 0.0, 200.0, 100.0), + content_mask: full_mask(), + background: solid_background(gpui::black()), + ..Default::default() + }); + let mut builder = gpui::PathBuilder::fill(); + builder.move_to(gpui::point(gpui::px(20.0), gpui::px(20.0))); + builder.line_to(gpui::point(gpui::px(80.0), gpui::px(20.0))); + builder.line_to(gpui::point(gpui::px(50.0), gpui::px(80.0))); + builder.close(); + let mut path = builder.build().expect("triangle").scale(1.0); + path.content_mask = full_mask(); + path.color = solid_background(gpui::white()); + scene.insert_primitive(path); + scene.finish(); + + // The path uses an MSAA attachment when supported. Storing that transient + // attachment invalidates the command buffer, including the background quad. + let image = renderer + .render_scene_to_image(&scene, TARGET) + .expect("valid path frame"); + assert_eq!(image.get_pixel(50, 40).0, [255, 255, 255, 255]); + assert_eq!(image.get_pixel(150, 40).0, [0, 0, 0, 255]); +} + +fn bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds { + origin: Point { + x: ScaledPixels(x), + y: ScaledPixels(y), + }, + size: Size { + width: ScaledPixels(w), + height: ScaledPixels(h), + }, + } +} + +fn full_mask() -> ContentMask { + ContentMask { + bounds: bounds(0.0, 0.0, 310.0, 100.0), + ..Default::default() + } +} + +fn mask(width: f32, height: f32) -> ContentMask { + ContentMask { + bounds: bounds(0.0, 0.0, width, height), + ..Default::default() + } +} + +fn tile(renderer: &WgpuHeadlessRenderer, key: AtlasKey, bytes: Vec) -> AtlasTile { + renderer + .sprite_atlas() + .get_or_insert_with(&key, &mut || { + Ok(Some(( + Size { + width: DevicePixels(8), + height: DevicePixels(8), + }, + Cow::Owned(bytes.clone()), + ))) + }) + .expect("atlas insert must succeed") + .expect("atlas insert must produce a tile") +} + +#[test] +fn every_primitive_kind_renders() { + let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); + let mono = tile( + &renderer, + AtlasKey::Svg(RenderSvgParams { + path: "test-mono".into(), + size: Size { + width: DevicePixels(8), + height: DevicePixels(8), + }, + }), + vec![255; 64], + ); + let poly = tile( + &renderer, + AtlasKey::Image(RenderImageParams { + image_id: gpui::ImageId(1), + frame_index: 0, + }), + // BGRA red, opaque. + (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), + ); + + let mut scene = Scene::default(); + let green: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x00ff00)); + let red: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xff0000)); + let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); + let blue: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x0000ff)); + + // 1. Solid quad. + let quad_bounds = bounds(10.0, 10.0, 30.0, 30.0); + scene.insert_primitive(Quad { + order: 0, + bounds: quad_bounds, + content_mask: full_mask(), + background: solid_background(green), + ..Default::default() + }); + // 2. Bordered quad (white border, transparent fill). + let bordered = bounds(50.0, 10.0, 30.0, 30.0); + scene.insert_primitive(Quad { + order: 0, + bounds: bordered, + content_mask: full_mask(), + border_color: white.into(), + border_widths: gpui::Edges::all(ScaledPixels(4.0)), + ..Default::default() + }); + // 3. Shadow (blue, no blur so it is a solid block). + let shadow_bounds = bounds(90.0, 10.0, 30.0, 30.0); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds: shadow_bounds, + corner_radii: Default::default(), + content_mask: full_mask(), + color: blue.into(), + element_bounds: shadow_bounds, + element_corner_radii: Default::default(), + inset: ShaderBool::Disabled, + corner_smoothing: 0.0, + }); + // 4. Underline (white, solid). + let underline_bounds = bounds(130.0, 20.0, 30.0, 4.0); + scene.insert_primitive(Underline { + order: 0, + padding: 0, + bounds: underline_bounds, + content_mask: full_mask(), + color: white.into(), + thickness: ScaledPixels(4.0), + wavy: ShaderBool::Disabled, + }); + // 5. Monochrome sprite (white coverage tile tinted green). + let mono_bounds = bounds(10.0, 60.0, 30.0, 30.0); + scene.insert_primitive(MonochromeSprite { + order: 0, + padding: 0, + bounds: mono_bounds, + content_mask: full_mask(), + color: green.into(), + tile: mono, + transformation: Default::default(), + }); + // 6. Polychrome sprite (red image). + let poly_bounds = bounds(50.0, 60.0, 30.0, 30.0); + scene.insert_primitive(PolychromeSprite { + order: 0, + grayscale: ShaderBool::Disabled, + opacity: 1.0, + corner_smoothing: 0.0, + bounds: poly_bounds, + content_mask: full_mask(), + corner_radii: Default::default(), + tile: poly, + }); + // 7. A second quad batch. Overlapping the image pushes this quad above it in draw + // order, so it starts past the first quads in the frame's quad buffer: a backend + // that loses the batch base draws the wrong quads here. + let late_quad = bounds(60.0, 70.0, 30.0, 30.0); + scene.insert_primitive(Quad { + order: 0, + bounds: late_quad, + content_mask: full_mask(), + background: solid_background(blue), + ..Default::default() + }); + // 8–9. Gradient drop and inset shadows. The inset gradient stays in the element's paint + // coordinate space while its offset hole is smaller. + let gradient = linear_gradient( + 90.0, + linear_color_stop(red, 0.0), + linear_color_stop(blue, 1.0), + ) + .color_space(ColorSpace::Srgb); + let gradient_drop_bounds = bounds(210.0, 10.0, 40.0, 40.0); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds: gradient_drop_bounds, + corner_radii: Default::default(), + content_mask: full_mask(), + color: gradient, + element_bounds: gradient_drop_bounds, + element_corner_radii: Default::default(), + inset: ShaderBool::Disabled, + corner_smoothing: 0.0, + }); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds: bounds(266.0, 16.0, 28.0, 28.0), + corner_radii: Default::default(), + content_mask: full_mask(), + color: gradient, + element_bounds: bounds(260.0, 10.0, 40.0, 40.0), + element_corner_radii: Default::default(), + inset: ShaderBool::Enabled, + corner_smoothing: 0.0, + }); + scene.finish(); + let quad_batches: Vec<_> = scene + .render_commands() + .iter() + .filter_map(|command| match command { + gpui::RenderCommand::Batch(gpui::PrimitiveBatch::Quads { range, .. }) => { + Some(range.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + quad_batches, + vec![0..2, 2..3], + "the scene must split quads across batches" + ); + + let image = renderer + .render_scene_to_image(&scene, TARGET) + .expect("render must succeed"); + let px = |x: u32, y: u32| { + let p = image.get_pixel(x, y).0; + (p[0], p[1], p[2], p[3]) + }; + let mut failures = Vec::new(); + let mut check = |name: &str, x: u32, y: u32, expected: (u8, u8, u8)| { + let (r, g, b, _) = px(x, y); + let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 8; + if !(close(r, expected.0) && close(g, expected.1) && close(b, expected.2)) { + failures.push(format!( + "{name} at ({x},{y}): got ({r},{g},{b}) expected {expected:?}" + )); + } + }; + check("solid quad", 25, 25, (0, 255, 0)); + check("bordered quad border", 52, 25, (255, 255, 255)); + check("bordered quad interior", 65, 25, (0, 0, 0)); + check("shadow", 105, 25, (0, 0, 255)); + check("underline", 145, 22, (255, 255, 255)); + check("monochrome sprite", 25, 75, (0, 255, 0)); + check("polychrome sprite", 55, 65, (255, 0, 0)); + check("second quad batch", 85, 85, (0, 0, 255)); + check("background", 190, 90, (0, 0, 0)); + check("gradient inset shadow hole", 280, 30, (0, 0, 0)); + let mut check_dominant = |name: &str, x: u32, y: u32, red_dominant: bool| { + let (r, g, b, _) = px(x, y); + let matches = if red_dominant { + r > 180 && g < 80 && b < 80 + } else { + b > 180 && r < 80 && g < 80 + }; + if !matches { + failures.push(format!( + "{name} at ({x},{y}): got ({r},{g},{b}), expected {} dominance", + if red_dominant { "red" } else { "blue" } + )); + } + }; + check_dominant("gradient drop shadow left edge", 212, 30, true); + check_dominant("gradient drop shadow right edge", 247, 30, false); + check_dominant("gradient inset shadow left edge", 262, 30, true); + check_dominant("gradient inset shadow right edge", 297, 30, false); + if !failures.is_empty() { + let path = std::env::temp_dir().join("gpui_headless_primitives.png"); + image.save(&path).ok(); + panic!( + "{}\n(image saved to {})", + failures.join("\n"), + path.display() + ); + } +} + +#[test] +fn smoothed_primitives_share_one_contour() { + let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); + let image_tile = tile( + &renderer, + AtlasKey::Image(RenderImageParams { + image_id: gpui::ImageId(2), + frame_index: 0, + }), + (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), + ); + let target = Size { + width: DevicePixels(360), + height: DevicePixels(170), + }; + let content_mask = mask(360.0, 170.0); + let green: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x19d36b)); + let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); + let blue: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x287cff)); + let black: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x000000)); + let radii = Corners::all(ScaledPixels(18.0)); + + let mut scene = Scene::default(); + + let fill_bounds = bounds(10.0, 10.0, 50.0, 50.0); + scene.insert_primitive(Quad { + bounds: fill_bounds, + content_mask, + background: solid_background(green), + corner_radii: radii, + corner_smoothing: 1.0, + ..Default::default() + }); + + let border_bounds = bounds(72.0, 10.0, 50.0, 50.0); + scene.insert_primitive(Quad { + bounds: border_bounds, + content_mask, + border_color: white.into(), + border_widths: Edges { + top: ScaledPixels(2.0), + right: ScaledPixels(5.0), + bottom: ScaledPixels(8.0), + left: ScaledPixels(3.0), + }, + corner_radii: radii, + corner_smoothing: 0.6, + ..Default::default() + }); + + let dashed_bounds = bounds(134.0, 10.0, 58.0, 50.0); + scene.insert_primitive(Quad { + bounds: dashed_bounds, + content_mask, + border_style: BorderStyle::Dashed, + border_color: white.into(), + border_widths: Edges::all(ScaledPixels(3.0)), + corner_radii: Corners { + top_left: ScaledPixels(20.0), + top_right: ScaledPixels(8.0), + bottom_right: ScaledPixels(16.0), + bottom_left: ScaledPixels(3.0), + }, + corner_smoothing: 1.0, + ..Default::default() + }); + + let image_bounds = bounds(204.0, 10.0, 50.0, 50.0); + scene.insert_primitive(PolychromeSprite { + order: 0, + grayscale: ShaderBool::Disabled, + opacity: 1.0, + corner_smoothing: 1.0, + bounds: image_bounds, + content_mask, + corner_radii: radii, + tile: image_tile, + }); + + let drop_element = bounds(20.0, 96.0, 50.0, 44.0); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(5.0), + bounds: bounds(25.0, 101.0, 50.0, 44.0), + corner_radii: Corners::all(ScaledPixels(15.0)), + content_mask, + color: blue.into(), + element_bounds: drop_element, + element_corner_radii: Corners::all(ScaledPixels(15.0)), + inset: ShaderBool::Disabled, + corner_smoothing: 0.6, + }); + scene.insert_primitive(Quad { + bounds: drop_element, + content_mask, + background: solid_background(green), + corner_radii: Corners::all(ScaledPixels(15.0)), + corner_smoothing: 0.6, + ..Default::default() + }); + + let inset_bounds = bounds(100.0, 94.0, 50.0, 48.0); + scene.insert_primitive(Quad { + bounds: inset_bounds, + content_mask, + background: solid_background(green), + corner_radii: Corners::all(ScaledPixels(16.0)), + corner_smoothing: 1.0, + ..Default::default() + }); + scene.insert_primitive(Shadow { + order: 0, + blur_radius: ScaledPixels(4.0), + bounds: bounds(104.0, 98.0, 42.0, 40.0), + corner_radii: Corners::all(ScaledPixels(12.0)), + content_mask, + color: black.into(), + element_bounds: inset_bounds, + element_corner_radii: Corners::all(ScaledPixels(16.0)), + inset: ShaderBool::Enabled, + corner_smoothing: 1.0, + }); + + let filter_bounds = bounds(190.0, 92.0, 70.0, 52.0); + let backdrop = Quad { + bounds: filter_bounds, + content_mask, + background: checkerboard(white, 2.0), + ..Default::default() + }; + scene.insert_primitive(backdrop); + scene.insert_primitive(BackdropFilter { + order: 0, + bounds: filter_bounds, + content_mask, + corner_radii: Corners::all(ScaledPixels(18.0)), + corner_smoothing: 1.0, + filters: smallvec![ScaledFilter::Blur(ScaledPixels(5.0))], + opacity: 1.0, + }); + + scene.finish(); + let image = renderer + .render_scene_to_image(&scene, target) + .expect("render must succeed"); + let mut unfiltered_scene = Scene::default(); + unfiltered_scene.insert_primitive(backdrop); + unfiltered_scene.finish(); + let unfiltered = renderer + .render_scene_to_image(&unfiltered_scene, target) + .expect("reference render must succeed"); + let pixel = |x: u32, y: u32| image.get_pixel(x, y).0; + let reference_pixel = |x: u32, y: u32| unfiltered.get_pixel(x, y).0; + let is_black = |x, y| pixel(x, y)[0..3].iter().all(|channel| *channel <= 12); + let is_white = |x, y| pixel(x, y)[0..3].iter().all(|channel| *channel >= 235); + + for (name, left, center, channel, minimum) in + [("fill", 10, 35, 1, 180), ("image", 204, 229, 0, 220)] + { + assert!( + is_black(left, 10), + "smoothed {name} must exclude its corner" + ); + assert!( + pixel(center, 10)[channel] > minimum, + "{name} shoulder must reach the top edge", + ); + assert!(pixel(center, 35)[channel] > minimum, "{name} center"); + } + + for (side, outer, inner) in [ + ("top", (96, 10), (96, 14)), + ("right", (120, 35), (115, 35)), + ("bottom", (96, 56), (96, 50)), + ] { + assert!(is_white(outer.0, outer.1), "{side} border"); + assert!( + is_black(inner.0, inner.1), + "{side} border interior boundary" + ); + } + + let dashed_pixels = (8..62) + .flat_map(|y| (132..194).map(move |x| (x, y))) + .filter(|&(x, y)| is_white(x, y)) + .count(); + assert!( + (150..420).contains(&dashed_pixels), + "dashed contour coverage: {dashed_pixels}" + ); + assert!(is_black(163, 35), "dashed border interior"); + + assert!( + pixel(73, 132)[2] > 35, + "drop shadow must extend beyond the element" + ); + assert!( + pixel(125, 96)[1] < pixel(125, 118)[1], + "inset shadow must darken the edge" + ); + assert!( + pixel(125, 118)[1] > 120, + "inset shadow must preserve the center" + ); + + let blurred_midtones = (192..258) + .flat_map(|x| (94..142).map(move |y| pixel(x, y)[0])) + .filter(|channel| (30..225).contains(channel)) + .count(); + assert!( + blurred_midtones > 800, + "backdrop blur did not composite: {blurred_midtones}" + ); + assert!( + pixel(190, 92) + .iter() + .zip(reference_pixel(190, 92)) + .all(|(&actual, expected)| actual.abs_diff(expected) <= 1), + "the smoothed mask must exclude the backdrop corner" + ); + assert!( + pixel(225, 92)[0].abs_diff(reference_pixel(225, 92)[0]) > 20, + "the smoothed mask must include the top shoulder" + ); + + if std::env::var_os("GPUI_SAVE_HEADLESS_TESTS").is_some() { + image + .save(std::env::temp_dir().join("gpui_smoothed_primitives.png")) + .expect("save diagnostic image"); + } +} + +#[test] +fn smoothed_fill_and_border_trace_the_same_contour() { + // Fills may take the superellipse shortcut while bordered quads always trace the Figma + // contour. Both must land on one edge, or a fill pulls away from its own border. + let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); + let target = Size { + width: DevicePixels(320), + height: DevicePixels(320), + }; + let content_mask = mask(320.0, 320.0); + let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); + let element = bounds(10.0, 10.0, 300.0, 300.0); + let center = 160.0; + + let mut edge_distances = |border_widths: Edges| -> Vec { + let mut scene = Scene::default(); + scene.insert_primitive(Quad { + bounds: element, + content_mask, + background: solid_background(white), + border_color: white.into(), + border_widths, + corner_radii: Corners::all(ScaledPixels(100.0)), + corner_smoothing: 0.45, + ..Default::default() + }); + scene.finish(); + let image = renderer + .render_scene_to_image(&scene, target) + .expect("render must succeed"); + + // Coverage summed along a ray from the center locates the edge on that ray. + (0..=90) + .map(|degrees| { + let angle = (180.0 + degrees as f32).to_radians(); + let start = 140.0; + start + + (0..40 * 16) + .map(|step| { + let distance = start + step as f32 / 16.0; + let x = center + angle.cos() * distance; + let y = center + angle.sin() * distance; + image.get_pixel(x as u32, y as u32).0[3] as f32 / 255.0 / 16.0 + }) + .sum::() + }) + .collect() + }; + + let fill = edge_distances(Edges::default()); + let bordered = edge_distances(Edges::all(ScaledPixels(1.0))); + for (degrees, (fill, bordered)) in fill.iter().zip(&bordered).enumerate() { + assert!( + (fill - bordered).abs() <= 0.35, + "{degrees} degrees past the left edge, the fill edge is {:.2}px inside the border", + bordered - fill + ); + } +} + +#[test] +fn content_mask_fades_quad_edges() { + let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); + let mut scene = Scene::default(); + let red: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xff0000)); + scene.insert_primitive(Quad { + order: 0, + bounds: bounds(0.0, 0.0, 310.0, 100.0), + content_mask: ContentMask { + bounds: bounds(0.0, 0.0, 100.0, 100.0), + fade_out: Edges { + top: ScaledPixels(10.0), + right: ScaledPixels(0.0), + bottom: ScaledPixels(0.0), + left: ScaledPixels(20.0), + }, + }, + background: solid_background(red), + ..Default::default() + }); + scene.finish(); + let image = renderer + .render_scene_to_image(&scene, TARGET) + .expect("render must succeed"); + let pixel = |x: u32, y: u32| image.get_pixel(x, y).0; + let close = |x: u32, y: u32, red: u8| { + let actual = pixel(x, y)[0]; + assert!( + actual.abs_diff(red) <= 3, + "at ({x},{y}) got {actual}, expected {red}" + ); + }; + + // Fully inside both fades. + close(60, 50, 255); + // The ramp is evaluated at pixel centers (x + 0.5), so 10.5px into the + // 20px left fade is 52.5% coverage. + close(10, 50, 134); + // 2.5px into the 10px top fade is 25% coverage. + close(60, 2, 64); + // The unfaded right edge still clips hard at the mask boundary. + assert_eq!(pixel(101, 50)[0], 0); +} From 1a2d25c00fa9a2dca7700ee7fe7822b4997eeadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 11:52:45 +0300 Subject: [PATCH 37/67] perf(startup): overlap repository bootstrap with window initialization Create AppStore and apply session settings and entry gates before opening the native window. Transfer one prepared bootstrap decision into the view so its workers can load repositories during DX12 initialization without duplicate restore requests. Consume and acknowledge retained initial feedback, preserve Git-runtime recovery and focused/workspace modes, and clear failed-open loading. Five alternating release-with-debug DX12/FXC pairs on Ryzen AI 9 HX 370 / Radeon 890M with the GitComet repository: - Median repository ready: 1183.4 -> 1015.1 ms (14.2% lower). - Ready range: 1165.8-1242.6 -> 983.2-1150.6 ms. - Median first draw: 708.2 -> 868.6 ms, a measured tradeoff. Three candidate runs overlapped loading; two deferred until window creation because the Git probe had not settled. First-frame drawing itself was not slower. The first observed launch reached ready at 1350.2 ms. Actual saved profile interaction was observed at 859.8 ms, with repository readiness at 1229.6 ms on the deferred path. These are warm-system observations, not a reboot-cold or universal subsecond guarantee. Renderer source and dependency versions match the frozen baseline, though build source identities changed. Validation: six new bootstrap regressions and 152 existing app, notification, and window-lifecycle tests passed; release-with-debug build and formatting passed; DX12 history navigation and Settings live smoke checks passed. Fresh 1 kHz samply capture retained matching symbols and ETL, with 550.5 ms of remaining ordinary-shader compiler CPU. Document the next renderer work. --- crates/gitcomet-ui-gpui/src/app/launch.rs | 22 +- .../src/view/gitcomet_view.rs | 219 +----- crates/gitcomet-ui-gpui/src/view/mod.rs | 1 + .../gitcomet-ui-gpui/src/view/state_apply.rs | 124 ++-- .../src/view/window_bootstrap.rs | 690 ++++++++++++++++++ docs/windows-startup-performance.md | 68 +- 6 files changed, 882 insertions(+), 242 deletions(-) create mode 100644 crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index b753742d2..1d0be6506 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -764,6 +764,18 @@ pub(super) fn open_gitcomet_window( let view_config = launch.view_config.clone(); let ui_scale_percent = ui_scale.percent; + let store_stage = gitcomet_core::startup_trace::span("app_store_init"); + #[cfg(test)] + let (store, events) = AppStore::new_test(Arc::clone(&backend)); + #[cfg(not(test))] + let (store, events) = AppStore::new(Arc::clone(&backend)); + drop(store_stage); + // The store's own workers can open the active repository while the native + // window initializes its renderer. Entry gates and store settings are applied + // before any repository request, and the view consumes this decision once. + let bootstrap = + crate::view::window_bootstrap::PreparedWindowBootstrap::new(&store, view_config, cx); + let window = crate::ui_probe::time_section("open main window", || { let _stage = gitcomet_core::startup_trace::span("open_window"); cx.open_window( @@ -792,15 +804,7 @@ pub(super) fn open_gitcomet_window( close_window_or_warn(window, cx); false }); - let store_stage = gitcomet_core::startup_trace::span("app_store_init"); - #[cfg(test)] - let (store, events) = AppStore::new_test(Arc::clone(&backend)); - #[cfg(not(test))] - let (store, events) = AppStore::new(Arc::clone(&backend)); - drop(store_stage); - cx.new(|cx| { - GitCometView::new_with_config(store, events, view_config.clone(), window, cx) - }) + cx.new(|cx| GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx)) }, ) }) diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs index 716d9d392..4ce817e06 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs @@ -479,28 +479,53 @@ impl GitCometView { Self::new_with_config(store, events, config, window, cx) } + #[cfg(any(test, feature = "benchmarks"))] pub fn new_with_config( store: AppStore, events: smol::channel::Receiver, config: GitCometViewConfig, window: &mut Window, cx: &mut gpui::Context, + ) -> Self { + let bootstrap = super::window_bootstrap::PreparedWindowBootstrap::new(&store, config, cx); + Self::new_with_bootstrap(store, events, bootstrap, window, cx) + } + + pub(crate) fn new_with_bootstrap( + store: AppStore, + events: smol::channel::Receiver, + bootstrap: super::window_bootstrap::PreparedWindowBootstrap, + window: &mut Window, + cx: &mut gpui::Context, ) -> Self { let _stage = gitcomet_core::startup_trace::span("view_init"); + let super::window_bootstrap::PreparedWindowBootstrap { + config, + ui_session, + mut ui_preferences, + workspace_id, + persisted_workspace_repo_paths, + persisted_workspace_active_repository, + initial_window_placement, + workspace_theme_key, + entry_denials, + mut startup_repo_bootstrap_pending, + startup_repo_open_failure_revision, + deferred_repo_bootstrap, + pending_repo_open_reservations, + pending_repo_open_active, + } = bootstrap; let GitCometViewConfig { - mut initial_path, - initial_repository_launch_mode, + initial_path: _, + initial_repository_launch_mode: _, view_mode, focused_diff, focused_mergetool, focused_mergetool_exit_code, startup_crash_report, - workspace, + workspace: _, } = config; crate::environment::track_window(window, cx); - if initial_path.is_none() { - initial_path = focused_mergetool.as_ref().map(|cfg| cfg.repo_path.clone()); - } let focused_mergetool_labels = focused_mergetool.as_ref().map(|cfg| cfg.labels.clone()); let focused_mergetool_bootstrap = if view_mode == GitCometViewMode::FocusedMergetool { focused_mergetool @@ -511,75 +536,6 @@ impl GitCometView { }; let store = Arc::new(store); - let mut ui_session = session::load(); - let ( - workspace_id, - persisted_workspace_repo_paths, - persisted_workspace_active_repository, - initial_window_placement, - workspace_theme_key, - ) = match workspace { - WorkspaceBootstrap::LegacySession => (None, Vec::new(), None, None, None), - WorkspaceBootstrap::Empty => { - ui_session.open_repos.clear(); - ui_session.active_repo = None; - ui_session.sidebar_width = None; - ui_session.details_width = None; - ui_session.sidebar_collapsed = Some(false); - ui_session.change_tracking_height = None; - ui_session.untracked_height = None; - (None, Vec::new(), None, None, None) - } - WorkspaceBootstrap::Saved(workspace) => { - let workspace = *workspace; - ui_session.open_repos.clone_from(&workspace.repositories); - ui_session - .active_repo - .clone_from(&workspace.active_repository); - ui_session.sidebar_width = workspace.layout.sidebar_width; - ui_session.details_width = workspace.layout.details_width; - ui_session.sidebar_collapsed = Some(workspace.layout.sidebar_collapsed); - ui_session.change_tracking_height = workspace.layout.change_tracking_height; - ui_session.untracked_height = workspace.layout.untracked_height; - ( - Some(workspace.id), - workspace.repositories, - workspace.active_repository, - Some(workspace.placement), - workspace.theme_mode, - ) - } - }; - // Every repository this window would open passes the entry gates - // here, before any bootstrap is dispatched or deferred. - let mut entry_denials = Vec::new(); - if view_mode == GitCometViewMode::Normal { - if let Some(path) = initial_path.as_ref() - && let gitcomet_extension_api::GateDecision::Deny { reason } = - super::extension_host::entry_decision( - path, - gitcomet_extension_api::EntryOrigin::CommandLine, - cx, - ) - { - entry_denials.push(reason); - initial_path = None; - } - let (allowed, denied) = super::extension_host::filter_entries( - std::mem::take(&mut ui_session.open_repos), - gitcomet_extension_api::EntryOrigin::WorkspaceRestore, - cx, - ); - ui_session.open_repos = allowed; - entry_denials.extend(denied); - if ui_session - .active_repo - .as_ref() - .is_some_and(|active| !ui_session.open_repos.contains(active)) - { - ui_session.active_repo = None; - } - } image_preview::Preferences { mode: image_preview::ImageSamplingMode::parse( ui_session.image_sampling_mode.as_deref(), @@ -589,7 +545,6 @@ impl GitCometView { ), } .initialize(cx); - let mut ui_preferences = UiPreferences::from_session(&ui_session); crate::session_ui::initialize_appearance(&ui_session, cx); ui_preferences.appearance.metrics = crate::appearance::current(cx); crate::session_ui::ui_scale(&ui_session, cx); @@ -600,19 +555,6 @@ impl GitCometView { let _stage = gitcomet_core::startup_trace::span("font_preferences"); crate::session_ui::font_preferences(window, &ui_session, cx) }; - if should_seed_initial_repository_from_session( - view_mode, - initial_path.as_deref(), - initial_repository_launch_mode, - !ui_session.open_repos.is_empty(), - ) && let Some(path) = initial_path.as_ref() - { - if !ui_session.open_repos.iter().any(|p| p == path) { - ui_session.open_repos.push(path.clone()); - } - ui_session.active_repo = Some(path.clone()); - } - let restored_sidebar_width = ui_preferences.window.sidebar_width; let restored_details_width = ui_preferences.window.details_width; let restored_sidebar_collapsed = ui_preferences.window.sidebar_collapsed; @@ -650,98 +592,6 @@ impl GitCometView { let remote_url_policy = ui_preferences.security.remote_url_policy; let check_for_updates_on_startup = ui_preferences.security.check_for_updates_on_startup; let commit_push_after_enabled = ui_preferences.repository.commit_push_after_enabled; - let history_show_tags = ui_preferences.history.show_tags; - let history_tag_fetch_mode = ui_preferences.history.tag_fetch_mode; - let default_tag_type = ui_preferences.repository.default_tag_type; - let remote_settings = RemoteSettings { - prune_deleted_remote_branches_on_fetch: ui_preferences - .remotes - .prune_deleted_remote_branches_on_fetch, - }; - store.dispatch(Msg::SetRemoteUrlPolicy(remote_url_policy)); - store.dispatch(Msg::SetGitLogSettings { - show_history_tags: history_show_tags, - tag_fetch_mode: history_tag_fetch_mode, - verify_commit_signatures: ui_preferences.history.verify_commit_signatures, - }); - store.dispatch(Msg::SetDefaultTagType(default_tag_type)); - store.dispatch(Msg::SetRemoteSettings(remote_settings)); - store.dispatch(Msg::SetMaintenanceSettings(MaintenanceSettings { - recommend: ui_preferences.maintenance.recommend, - })); - store.dispatch(Msg::SetFileBrowserSettings(FileBrowserSettings { - follow_selected_commit: ui_preferences.history.files_follow_selected_commit, - })); - store.dispatch(Msg::SetLargeFileSettings(ui_preferences.large_files)); - let saved_open_repos = ui_session.open_repos.clone(); - let saved_active_repo = ui_session.active_repo.clone(); - let mut startup_repo_bootstrap_pending = false; - let mut deferred_repo_bootstrap = None; - - // Only auto-restore/open on startup if the store hasn't already been preloaded. - // This avoids re-opening repos (and changing RepoIds) when the UI is attached to an - // already-initialized store (notably in `gpui::test` setup). - let initial_store_state = store.snapshot(); - let store_preloaded = !initial_store_state.repos.is_empty(); - let git_runtime_available = initial_store_state.git_runtime.is_available(); - let mut pending_repo_open_reservations = FxHashMap::default(); - let mut pending_repo_open_active = None; - if initial_repository_launch_mode == InitialRepositoryLaunchMode::OpenExplicitly - && let Some(path) = initial_path.as_ref() - { - let failure_revision = initial_store_state.repo_open_failure_revision; - pending_repo_open_reservations.insert( - path.clone(), - PendingRepoOpen { - failure_revision, - persist_in_workspace: true, - }, - ); - pending_repo_open_active = Some(path.clone()); - } - let should_auto_restore = !crate::startup_probe::disable_auto_restore() - && view_mode != GitCometViewMode::FocusedMergetool - && crate::ui_runtime::current().auto_restores_session() - && !store_preloaded; - - if should_auto_restore { - if !saved_open_repos.is_empty() { - if git_runtime_available { - store.dispatch(Msg::RestoreSession { - open_repos: saved_open_repos, - active_repo: saved_active_repo, - }); - startup_repo_bootstrap_pending = true; - } else { - deferred_repo_bootstrap = Some(DeferredRepoBootstrap::RestoreSession { - open_repos: saved_open_repos, - active_repo: saved_active_repo, - }); - } - } - } else if store_preloaded { - if let Some(path) = initial_path.as_ref() { - if git_runtime_available { - store.dispatch(Msg::OpenRepo(path.clone())); - } else { - deferred_repo_bootstrap = - Some(DeferredRepoBootstrap::OpenRepos(vec![path.clone()])); - } - } - } else if let Some(path) = initial_path.as_ref() { - if git_runtime_available { - store.dispatch(Msg::OpenRepo(path.clone())); - startup_repo_bootstrap_pending = true; - } else { - deferred_repo_bootstrap = - Some(DeferredRepoBootstrap::OpenRepos(vec![path.clone()])); - } - } - - if deferred_repo_bootstrap.is_some() { - startup_repo_bootstrap_pending = true; - } - let initial_state = store.snapshot(); let ui_model = cx.new(|cx| { AppUiModel::new_with_preferences( @@ -815,7 +665,9 @@ impl GitCometView { labels: [config.label_left.into(), config.label_right.into()], }) }); - if !initial_state.repos.is_empty() { + if !initial_state.repos.is_empty() + || initial_state.repo_open_failure_revision > startup_repo_open_failure_revision + { startup_repo_bootstrap_pending = false; } @@ -834,6 +686,8 @@ impl GitCometView { }); let weak_view = cx.weak_entity(); + // The receiver retains a coalesced StateChanged even if repository loading + // finished before this view existed. Its first poll observes startup readiness. let poller = Poller::start(Arc::clone(&store), events, ui_model.downgrade(), window, cx); let title_bar = cx.new(|cx| { @@ -1476,6 +1330,7 @@ impl GitCometView { view.set_theme(initial_theme, cx); view.sync_terminal_button_target(cx); + view.consume_initial_store_feedback(cx); for reason in entry_denials { view.push_toast(components::ToastKind::Warning, reason.to_string(), cx); } diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index 416492544..9780f2df8 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -333,6 +333,7 @@ mod terminal_preferences; pub(crate) mod test_support; mod toast_host; mod ui_persistence; +pub(crate) mod window_bootstrap; mod window_gates; use gitcomet_ui_kit::zoom_wheel; pub(crate) use gitcomet_ui_kit::{file_icons, icons, tooltip, tooltip_host}; diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index 464fa59a2..ac05ba860 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -221,47 +221,25 @@ impl GitCometView { } } - pub(super) fn apply_state_snapshot( + /// Store workers may finish before the window attaches. Report only the + /// feedback still retained for this window, then acknowledge it just as a + /// live update would; the populated repository/runtime state stays intact. + pub(super) fn consume_initial_store_feedback(&mut self, cx: &mut gpui::Context) { + let initial = Arc::clone(&self.state); + let mut follow_up_msgs = Vec::new(); + self.consume_store_feedback(None, &initial, &mut follow_up_msgs, cx); + for msg in follow_up_msgs { + self.store.dispatch(msg); + } + } + + fn consume_store_feedback( &mut self, - next: Arc, + previous: Option<&AppState>, + next: &AppState, + follow_up_msgs: &mut Vec, cx: &mut gpui::Context, - ) -> bool { - note_opened_repos_for_activation( - &self.state, - &next, - &mut self.last_repo_activation_dispatch_at, - Instant::now(), - ); - let workspace_membership_changed = self.state.active_repo != next.active_repo - || !Arc::ptr_eq(&self.state.repo_open_failures, &next.repo_open_failures) - || !self - .state - .repos - .iter() - .map(|repo| { - ( - repo.id, - &repo.spec.workdir, - repo.is_provisional_external_drop_open(), - ) - }) - .eq(next.repos.iter().map(|repo| { - ( - repo.id, - &repo.spec.workdir, - repo.is_provisional_external_drop_open(), - ) - })); - let git_runtime_changed = self.state.git_runtime != next.git_runtime; - let prev_git_runtime_available = self.state.git_runtime.is_available(); - let prev_had_repos = !self.state.repos.is_empty(); - let prev_annex_rail = CollapsedSidebarSection::Annex.is_available(self.active_repo()); - let prev_auth_prompt = self.state.auth_prompt.clone(); - let prev_branch_exists_prompt = self.state.branch_exists_prompt.clone(); - let prev_submodule_trust_prompt = self.state.submodule_trust_prompt.clone(); - let prev_submodule_trust_check = self.state.submodule_trust_check_pending; - let merge_view_active = active_merge_view_target(next.as_ref()).is_some(); - let mut follow_up_msgs = Vec::new(); + ) { let hook_activity_workflow_repo = self .hook_activity_workflow_repo_id(cx) .or_else(|| self.pending_hook_activity_open.map(|(repo_id, _)| repo_id)); @@ -270,7 +248,7 @@ impl GitCometView { // as a notification and as a diagnostic, and one event is one error. let mut error_reports: Vec = Vec::new(); let new_notifications = next - .notifications_after(self.state.notifications_seq) + .notifications_after(previous.map_or(0, |state| state.notifications_seq)) .to_vec(); if !new_notifications.is_empty() { follow_up_msgs.push(Msg::AcknowledgeNotifications { @@ -294,10 +272,9 @@ impl GitCometView { for next_repo in &next.repos { let feedback = &next_repo.feedback; // The numbering restarts with each lifetime of the repository. - let (old_diagnostics_seq, old_command_log_seq) = self - .state - .repos - .iter() + let (old_diagnostics_seq, old_command_log_seq) = previous + .into_iter() + .flat_map(|state| state.repos.iter()) .find(|r| r.id == next_repo.id && r.lifetime() == next_repo.lifetime()) .map_or((0, 0), |r| { (r.feedback.diagnostics_seq, r.feedback.command_log_seq) @@ -389,9 +366,15 @@ impl GitCometView { } } - let previous_repo = self.state.repos.iter().find(|repo| repo.id == next_repo.id); + let previous_repo = previous + .into_iter() + .flat_map(|state| state.repos.iter()) + .find(|repo| repo.id == next_repo.id); for operation in next_repo.feedback.hook_activity.iter().filter(|operation| { - operation.is_reportable() + // Hook activity is retained history, not an unacknowledged + // feedback buffer. Attachment must not replay old completions. + previous.is_some() + && operation.is_reportable() && !operation.status.is_active() && !operation.handled_by_auth_prompt }) { @@ -451,6 +434,53 @@ impl GitCometView { self.report_error(report, cx); } } + } + + pub(super) fn apply_state_snapshot( + &mut self, + next: Arc, + cx: &mut gpui::Context, + ) -> bool { + note_opened_repos_for_activation( + &self.state, + &next, + &mut self.last_repo_activation_dispatch_at, + Instant::now(), + ); + let workspace_membership_changed = self.state.active_repo != next.active_repo + || !Arc::ptr_eq(&self.state.repo_open_failures, &next.repo_open_failures) + || !self + .state + .repos + .iter() + .map(|repo| { + ( + repo.id, + &repo.spec.workdir, + repo.is_provisional_external_drop_open(), + ) + }) + .eq(next.repos.iter().map(|repo| { + ( + repo.id, + &repo.spec.workdir, + repo.is_provisional_external_drop_open(), + ) + })); + let repo_open_failed = + next.repo_open_failure_revision > self.state.repo_open_failure_revision; + let git_runtime_changed = self.state.git_runtime != next.git_runtime; + let prev_git_runtime_available = self.state.git_runtime.is_available(); + let prev_had_repos = !self.state.repos.is_empty(); + let prev_annex_rail = CollapsedSidebarSection::Annex.is_available(self.active_repo()); + let prev_auth_prompt = self.state.auth_prompt.clone(); + let prev_branch_exists_prompt = self.state.branch_exists_prompt.clone(); + let prev_submodule_trust_prompt = self.state.submodule_trust_prompt.clone(); + let prev_submodule_trust_check = self.state.submodule_trust_check_pending; + let merge_view_active = active_merge_view_target(next.as_ref()).is_some(); + let mut follow_up_msgs = Vec::new(); + let previous = Arc::clone(&self.state); + self.consume_store_feedback(Some(previous.as_ref()), &next, &mut follow_up_msgs, cx); let next_submodule_add_progress = next .repos @@ -686,7 +716,7 @@ impl GitCometView { for msg in follow_up_msgs { self.store.dispatch(msg); } - if !self.state.repos.is_empty() { + if !self.state.repos.is_empty() || repo_open_failed { self.startup_repo_bootstrap_pending = false; } if prev_auth_prompt != self.state.auth_prompt { diff --git a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs new file mode 100644 index 000000000..456f6facd --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs @@ -0,0 +1,690 @@ +//! Prepare repository work before native window/renderer initialization. +use super::*; + +/// Owns the bootstrap decision until the view attaches. An empty store snapshot +/// does not mean a second restore is needed: the first may still be queued. +pub(crate) struct PreparedWindowBootstrap { + pub(super) config: GitCometViewConfig, + pub(super) ui_session: session::UiSession, + pub(super) ui_preferences: UiPreferences, + pub(super) workspace_id: Option, + pub(super) persisted_workspace_repo_paths: Vec, + pub(super) persisted_workspace_active_repository: Option, + pub(super) initial_window_placement: Option, + pub(super) workspace_theme_key: Option, + pub(super) entry_denials: Vec, + pub(super) startup_repo_bootstrap_pending: bool, + pub(super) startup_repo_open_failure_revision: u64, + pub(super) deferred_repo_bootstrap: Option, + pub(super) pending_repo_open_reservations: FxHashMap, + pub(super) pending_repo_open_active: Option, +} + +impl PreparedWindowBootstrap { + pub(crate) fn new(store: &AppStore, config: GitCometViewConfig, cx: &mut App) -> Self { + Self::from_session(store, config, session::load(), cx) + } + + fn from_session( + store: &AppStore, + mut config: GitCometViewConfig, + mut ui_session: session::UiSession, + cx: &mut App, + ) -> Self { + let _stage = gitcomet_core::startup_trace::span("repository_bootstrap_prepare"); + let view_mode = config.view_mode; + let initial_repository_launch_mode = config.initial_repository_launch_mode; + let workspace = config.workspace.clone(); + let mut initial_path = config.initial_path.take().or_else(|| { + config + .focused_mergetool + .as_ref() + .map(|cfg| cfg.repo_path.clone()) + }); + let ( + workspace_id, + persisted_workspace_repo_paths, + persisted_workspace_active_repository, + initial_window_placement, + workspace_theme_key, + ) = match workspace { + WorkspaceBootstrap::LegacySession => (None, Vec::new(), None, None, None), + WorkspaceBootstrap::Empty => { + ui_session.open_repos.clear(); + ui_session.active_repo = None; + ui_session.sidebar_width = None; + ui_session.details_width = None; + ui_session.sidebar_collapsed = Some(false); + ui_session.change_tracking_height = None; + ui_session.untracked_height = None; + (None, Vec::new(), None, None, None) + } + WorkspaceBootstrap::Saved(workspace) => { + let workspace = *workspace; + ui_session.open_repos.clone_from(&workspace.repositories); + ui_session + .active_repo + .clone_from(&workspace.active_repository); + ui_session.sidebar_width = workspace.layout.sidebar_width; + ui_session.details_width = workspace.layout.details_width; + ui_session.sidebar_collapsed = Some(workspace.layout.sidebar_collapsed); + ui_session.change_tracking_height = workspace.layout.change_tracking_height; + ui_session.untracked_height = workspace.layout.untracked_height; + ( + Some(workspace.id), + workspace.repositories, + workspace.active_repository, + Some(workspace.placement), + workspace.theme_mode, + ) + } + }; + // Every repository this window would open passes the entry gates + // here, before any bootstrap is dispatched or deferred. + let mut entry_denials = Vec::new(); + if view_mode == GitCometViewMode::Normal { + if let Some(path) = initial_path.as_ref() + && let gitcomet_extension_api::GateDecision::Deny { reason } = + super::extension_host::entry_decision( + path, + gitcomet_extension_api::EntryOrigin::CommandLine, + cx, + ) + { + entry_denials.push(reason); + initial_path = None; + } + let (allowed, denied) = super::extension_host::filter_entries( + std::mem::take(&mut ui_session.open_repos), + gitcomet_extension_api::EntryOrigin::WorkspaceRestore, + cx, + ); + ui_session.open_repos = allowed; + entry_denials.extend(denied); + if ui_session + .active_repo + .as_ref() + .is_some_and(|active| !ui_session.open_repos.contains(active)) + { + ui_session.active_repo = None; + } + } + if should_seed_initial_repository_from_session( + view_mode, + initial_path.as_deref(), + initial_repository_launch_mode, + !ui_session.open_repos.is_empty(), + ) && let Some(path) = initial_path.as_ref() + { + if !ui_session.open_repos.iter().any(|p| p == path) { + ui_session.open_repos.push(path.clone()); + } + ui_session.active_repo = Some(path.clone()); + } + + let ui_preferences = UiPreferences::from_session(&ui_session); + let history_show_tags = ui_preferences.history.show_tags; + let history_tag_fetch_mode = ui_preferences.history.tag_fetch_mode; + let default_tag_type = ui_preferences.repository.default_tag_type; + let remote_settings = RemoteSettings { + prune_deleted_remote_branches_on_fetch: ui_preferences + .remotes + .prune_deleted_remote_branches_on_fetch, + }; + store.dispatch(Msg::SetRemoteUrlPolicy( + ui_preferences.security.remote_url_policy, + )); + store.dispatch(Msg::SetGitLogSettings { + show_history_tags: history_show_tags, + tag_fetch_mode: history_tag_fetch_mode, + verify_commit_signatures: ui_preferences.history.verify_commit_signatures, + }); + store.dispatch(Msg::SetDefaultTagType(default_tag_type)); + store.dispatch(Msg::SetRemoteSettings(remote_settings)); + store.dispatch(Msg::SetMaintenanceSettings(MaintenanceSettings { + recommend: ui_preferences.maintenance.recommend, + })); + store.dispatch(Msg::SetFileBrowserSettings(FileBrowserSettings { + follow_selected_commit: ui_preferences.history.files_follow_selected_commit, + })); + store.dispatch(Msg::SetLargeFileSettings(ui_preferences.large_files)); + let saved_open_repos = ui_session.open_repos.clone(); + let saved_active_repo = ui_session.active_repo.clone(); + let mut startup_repo_bootstrap_pending = false; + let mut deferred_repo_bootstrap = None; + + // Directly constructed views may receive a store that is already populated. + // Preparation itself happens once, even when its queued restore has not run + // by the time the native window attaches the view. + let initial_store_state = store.snapshot(); + let store_preloaded = !initial_store_state.repos.is_empty(); + let git_runtime_available = initial_store_state.git_runtime.is_available(); + let mut pending_repo_open_reservations = FxHashMap::default(); + let mut pending_repo_open_active = None; + if initial_repository_launch_mode == InitialRepositoryLaunchMode::OpenExplicitly + && let Some(path) = initial_path.as_ref() + { + let failure_revision = initial_store_state.repo_open_failure_revision; + pending_repo_open_reservations.insert( + path.clone(), + PendingRepoOpen { + failure_revision, + persist_in_workspace: true, + }, + ); + pending_repo_open_active = Some(path.clone()); + } + let should_auto_restore = !crate::startup_probe::disable_auto_restore() + && view_mode != GitCometViewMode::FocusedMergetool + && crate::ui_runtime::current().auto_restores_session() + && !store_preloaded; + + if should_auto_restore { + if !saved_open_repos.is_empty() { + if git_runtime_available { + store.dispatch(Msg::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + } + } + } else if store_preloaded { + if let Some(path) = initial_path.as_ref() { + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + } else { + deferred_repo_bootstrap = + Some(DeferredRepoBootstrap::OpenRepos(vec![path.clone()])); + } + } + } else if let Some(path) = initial_path.as_ref() { + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = + Some(DeferredRepoBootstrap::OpenRepos(vec![path.clone()])); + } + } + + if deferred_repo_bootstrap.is_some() { + startup_repo_bootstrap_pending = true; + } + + gitcomet_core::startup_trace::mark(if deferred_repo_bootstrap.is_some() { + "repository_bootstrap_deferred_for_git" + } else if startup_repo_bootstrap_pending { + "repository_bootstrap_dispatched" + } else { + "repository_bootstrap_not_needed" + }); + config.initial_path = initial_path; + Self { + config, + ui_session, + ui_preferences, + workspace_id, + persisted_workspace_repo_paths, + persisted_workspace_active_repository, + initial_window_placement, + workspace_theme_key, + entry_denials, + startup_repo_bootstrap_pending, + startup_repo_open_failure_revision: initial_store_state.repo_open_failure_revision, + deferred_repo_bootstrap, + pending_repo_open_reservations, + pending_repo_open_active, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gitcomet_core::process::{ + GitExecutableAvailability, GitExecutablePreference, GitRuntimeState, + }; + use gitcomet_core::services::{GitBackend, GitRepository}; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingBackend { + opens: Arc, + fail: bool, + } + + impl GitBackend for RecordingBackend { + fn open(&self, workdir: &Path) -> gitcomet_core::services::Result> { + self.opens.fetch_add(1, Ordering::SeqCst); + if self.fail { + return Err(gitcomet_core::error::Error::new( + gitcomet_core::error::ErrorKind::NotARepository, + )); + } + Ok(Arc::new( + gitcomet_core::test_support::UnconfiguredRepository::new(workdir.to_path_buf()), + )) + } + } + + fn store( + available: bool, + fail: bool, + ) -> ( + AppStore, + smol::channel::Receiver, + Arc, + ) { + let opens = Arc::new(AtomicUsize::new(0)); + let (store, events) = AppStore::new_test(Arc::new(RecordingBackend { + opens: Arc::clone(&opens), + fail, + })); + store.disable_repo_monitors_for_test(); + store.replace_snapshot_for_test(Arc::new(AppState { + git_runtime: GitRuntimeState { + preference: GitExecutablePreference::SystemPath, + availability: if available { + GitExecutableAvailability::Available { + version_output: "git version 2.55.0".into(), + } + } else { + GitExecutableAvailability::Checking + }, + }, + ..AppState::test_default() + })); + (store, events, opens) + } + + fn restored_config(paths: Vec) -> GitCometViewConfig { + let mut workspace = session::Workspace::new(paths); + workspace.active_repository = workspace.repositories.first().cloned(); + GitCometViewConfig { + workspace: WorkspaceBootstrap::Saved(Box::new(workspace)), + ..GitCometViewConfig::normal(None) + } + } + + fn prepare( + store: &AppStore, + config: GitCometViewConfig, + session: session::UiSession, + cx: &mut gpui::TestAppContext, + ) -> PreparedWindowBootstrap { + crate::ui_runtime::with_override( + crate::ui_runtime::UiRuntime::deterministic_auto_restore(), + || cx.update(|app| PreparedWindowBootstrap::from_session(store, config, session, app)), + ) + } + + fn wait_for_state(store: &AppStore, ready: impl Fn(&AppState) -> bool) { + let deadline = Instant::now() + Duration::from_secs(3); + while !ready(&store.snapshot()) { + assert!(Instant::now() < deadline, "bootstrap did not settle"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn shutdown(store: &AppStore) { + store + .shutdown_for_test() + .recv_timeout(Duration::from_secs(3)) + .expect("store and its effects shut down"); + } + + fn flush_feedback_acknowledgments(store: &AppStore) { + // The worker coalesces acknowledgments until the next reducer pass. + // A test barrier alone deliberately does not publish a new state. + store.dispatch(Msg::SetDefaultTagType(store.snapshot().default_tag_type)); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .expect("feedback acknowledgments are reduced"); + } + + #[gpui::test] + fn restored_repository_loads_before_window_and_attaches_without_reopening( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(true, false); + let active_directory = tempfile::tempdir().unwrap(); + let inactive_directory = tempfile::tempdir().unwrap(); + let path = active_directory.path().to_path_buf(); + let inactive = inactive_directory.path().to_path_buf(); + let bootstrap = prepare( + &store, + restored_config(vec![path.clone(), inactive.clone()]), + session::UiSession { + history_show_tags: Some(false), + history_verify_commit_signatures: Some(false), + file_browser_follow_selected_commit: Some(true), + ..Default::default() + }, + cx, + ); + wait_for_state(&store, |state| { + state + .repos + .iter() + .any(|repo| repo.spec.workdir == path && matches!(repo.open, Loadable::Ready(()))) + }); + let state = store.snapshot(); + let active_id = state.active_repo.expect("restored active repository"); + assert_eq!(state.repos.len(), 2); + assert!(!state.git_log_settings.show_history_tags); + assert!(!state.git_log_settings.verify_commit_signatures); + assert!(state.file_browser_settings.follow_selected_commit); + assert!(matches!( + state + .repos + .iter() + .find(|repo| repo.spec.workdir == inactive) + .unwrap() + .open, + Loadable::NotLoaded + )); + assert!( + !events.is_empty(), + "the live poller must observe the preloaded state" + ); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + cx.update(|_, app| { + let view = view.read(app); + assert_eq!(view.state.active_repo, Some(active_id)); + assert!(!view.startup_repo_bootstrap_pending); + }); + shutdown(&store_for_assert); + assert_eq!( + opens.load(Ordering::SeqCst), + 1, + "only the active tab opens once" + ); + assert_eq!(store_for_assert.snapshot().active_repo, Some(active_id)); + } + + #[gpui::test] + fn failed_prepared_open_is_not_retried_when_empty_store_attaches( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(true, true); + let bootstrap = prepare( + &store, + restored_config(vec![PathBuf::from("/repos/bootstrap-failure")]), + session::UiSession::default(), + cx, + ); + wait_for_state(&store, |state| state.repo_open_failure_revision != 0); + assert!(store.snapshot().repos.is_empty()); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + cx.update(|_, app| { + let view = view.read(app); + assert!(!view.startup_repo_bootstrap_pending); + let toasts = view.toast_host.read(app).toasts_for_tests(app); + assert_eq!( + toasts + .iter() + .filter(|(kind, message)| { + *kind == components::ToastKind::Warning + && message.contains("No valid Git repository") + }) + .count(), + 1, + "the pre-attachment failure must be reported once" + ); + }); + cx.update(|_, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + }); + }); + cx.update(|_, app| { + let toasts = view.read(app).toast_host.read(app).toasts_for_tests(app); + assert_eq!( + toasts + .iter() + .filter(|(kind, message)| { + *kind == components::ToastKind::Warning + && message.contains("No valid Git repository") + }) + .count(), + 1, + "the first poll must not repeat feedback already shown on attachment" + ); + }); + flush_feedback_acknowledgments(&store_for_assert); + assert!(store_for_assert.snapshot().notifications.is_empty()); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn entry_gates_filter_bootstrap_before_any_window_exists(cx: &mut gpui::TestAppContext) { + use gitcomet_extension_api::{Extension, ExtensionId, GateDecision, Registrar}; + const DENY_MARKER: &str = ".bootstrap-entry-denied"; + struct DenyMarkedRepository; + impl Extension for DenyMarkedRepository { + fn id(&self) -> ExtensionId { + ExtensionId::new("test.bootstrap-entry").unwrap() + } + + fn register(&self, registrar: &mut Registrar) { + registrar.repository_entry_gate( + "deny-marker", + std::rc::Rc::new(|request, app| { + assert!( + app.windows().is_empty(), + "gate runs before the first window" + ); + if request.path.join(DENY_MARKER).is_file() { + GateDecision::Deny { + reason: "bootstrap entry denied".into(), + } + } else { + GateDecision::Allow + } + }), + ); + } + } + let registry = + gitcomet_extension_api::Registry::build(vec![Box::new(DenyMarkedRepository)]).unwrap(); + cx.update(|app| super::super::extension_host::install_registry(registry, app)); + let denied = tempfile::tempdir().unwrap(); + std::fs::write(denied.path().join(DENY_MARKER), "").unwrap(); + let denied_path = denied.path().to_path_buf(); + let allowed_directory = tempfile::tempdir().unwrap(); + let allowed = allowed_directory.path().to_path_buf(); + let (store, _events, opens) = store(true, false); + let mut config = restored_config(vec![denied_path.clone(), allowed.clone()]); + config.initial_path = Some(denied_path); + let bootstrap = prepare(&store, config, session::UiSession::default(), cx); + assert_eq!(bootstrap.entry_denials.len(), 2); + assert!(bootstrap.config.initial_path.is_none()); + assert_eq!(bootstrap.ui_session.open_repos, vec![allowed.clone()]); + assert!(bootstrap.ui_session.active_repo.is_none()); + wait_for_state(&store, |state| { + state.repos.iter().any(|repo| { + repo.spec.workdir == allowed && matches!(repo.open, Loadable::Ready(())) + }) + }); + shutdown(&store); + assert_eq!(opens.load(Ordering::SeqCst), 1); + assert_eq!(store.snapshot().repos.len(), 1); + } + + #[gpui::test] + fn unsettled_git_recovers_and_a_coalesced_failure_clears_loading( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(false, true); + let path = PathBuf::from("/repos/bootstrap-awaits-git"); + let bootstrap = prepare( + &store, + restored_config(vec![path.clone()]), + session::UiSession::default(), + cx, + ); + assert!(bootstrap.startup_repo_bootstrap_pending); + assert_eq!( + bootstrap.deferred_repo_bootstrap, + Some(DeferredRepoBootstrap::RestoreSession { + open_repos: vec![path.clone()], + active_repo: Some(path.clone()), + }) + ); + assert_eq!(opens.load(Ordering::SeqCst), 0); + assert!(store.snapshot().repos.is_empty()); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + store_for_assert.dispatch(Msg::SetGitRuntimeState( + AppState::test_default().git_runtime, + )); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .expect("runtime recovery is reduced"); + cx.update(|_, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(view.deferred_repo_bootstrap.is_none()); + }); + }); + // Do not apply the intermediate opening tab: live notifications can + // coalesce the entire open/failure while a frame is being rendered. + wait_for_state(&store_for_assert, |state| { + state.repo_open_failure_revision != 0 + }); + assert!(store_for_assert.snapshot().repos.is_empty()); + cx.update(|_, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(!view.startup_repo_bootstrap_pending); + }); + }); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn retained_diagnostics_are_reported_and_acknowledged_once_on_attachment( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(true, false); + let mut state = (*store.snapshot()).clone(); + let repo_id = RepoId(73); + let mut repo = RepoState::new_opening( + repo_id, + gitcomet_core::domain::RepoSpec { + workdir: PathBuf::from("/repos/preloaded-diagnostic"), + }, + ); + repo.open = Loadable::Ready(()); + repo.feedback_mut() + .push_diagnostic(gitcomet_state::model::DiagnosticEntry { + time: std::time::SystemTime::now(), + kind: DiagnosticKind::Error, + message: "preloaded diagnostic".into(), + }); + state.repos.push(repo); + state.active_repo = Some(repo_id); + store.replace_snapshot_for_test(Arc::new(state)); + let bootstrap = prepare( + &store, + GitCometViewConfig::normal(None), + session::UiSession::default(), + cx, + ); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + cx.update(|_, app| { + let toasts = view.read(app).toast_host.read(app).toasts_for_tests(app); + assert_eq!( + toasts + .iter() + .filter(|(_, text)| text == "preloaded diagnostic") + .count(), + 1 + ); + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + }); + let toasts = view.read(app).toast_host.read(app).toasts_for_tests(app); + assert_eq!( + toasts + .iter() + .filter(|(_, text)| text == "preloaded diagnostic") + .count(), + 1 + ); + }); + flush_feedback_acknowledgments(&store_for_assert); + assert!( + store_for_assert.snapshot().repos[0] + .feedback + .diagnostics + .is_empty() + ); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + fn empty_workspaces_and_focused_tools_do_not_restore_legacy_tabs( + cx: &mut gpui::TestAppContext, + ) { + let legacy_path = PathBuf::from("/repos/legacy-session"); + let focused_path = PathBuf::from("/repos/focused-tool"); + let session = session::UiSession { + open_repos: vec![legacy_path.clone()], + active_repo: Some(legacy_path), + ..Default::default() + }; + let (store, _events, opens) = store(false, false); + let empty = prepare( + &store, + GitCometViewConfig { + workspace: WorkspaceBootstrap::Empty, + ..GitCometViewConfig::normal(None) + }, + session.clone(), + cx, + ); + assert!(empty.ui_session.open_repos.is_empty()); + assert!(empty.deferred_repo_bootstrap.is_none()); + assert!(!empty.startup_repo_bootstrap_pending); + + let focused = prepare( + &store, + GitCometViewConfig { + initial_path: Some(focused_path.clone()), + view_mode: GitCometViewMode::FocusedMergetool, + ..Default::default() + }, + session, + cx, + ); + assert_eq!( + focused.deferred_repo_bootstrap, + Some(DeferredRepoBootstrap::OpenRepos(vec![focused_path])) + ); + shutdown(&store); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } +} diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index f1f032549..35c980d11 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -78,6 +78,53 @@ DXC was Windows SDK 1.8.2502.11, staged next to isolated executables; loaded modules confirmed the DLLs used. Once smoothing is deferred, DXC adds only a small improvement. Production packaging does not bundle these SDK DLLs. +## Repository bootstrap overlap + +The next change creates the `AppStore` and prepares repository bootstrap before +`cx.open_window`. Repository workers can then load status and history while +the native window initializes its DX12 renderer. The previous saved-profile +trace had Git available by 96.3 ms, but created the store at 658.5 ms. + +Preparation applies settings and entry gates before dispatching repository +requests. The view consumes one prepared bootstrap decision and attaches to +the latest store snapshot, including retained notifications and diagnostics. +Git-runtime recovery, focused tools, empty workspaces, saved workspace layout, +and failed-open handling retain their existing behavior. + +Six new bootstrap tests pass. They cover loading before a window exists, +single dispatch and stable repository identity, entry gates, focused/empty +workspaces, failures before attachment, Git recovery, and feedback consumption. +Another 152 existing app, notification, and window-lifecycle tests pass. + +Five alternating pairs compared the frozen lazy-only build with this bootstrap +candidate, using the same release profile and DX12/FXC: + +| Milestone | Lazy only | Early bootstrap | +| --- | ---: | ---: | +| Launch to first draw, median | 708.2 ms | 868.6 ms | +| Launch to repository ready, median | 1,183.4 ms | 1,015.1 ms | +| Repository-ready range | 1,165.8–1,242.6 ms | 983.2–1,150.6 ms | + +This is a tradeoff, not a uniform improvement. Three candidate runs overlapped +loading, while two waited for the Git runtime probe and restored only after +renderer initialization. First-frame CPU drawing was not slower; the delay +occurred before window attachment. Contention between renderer initialization +and repository workers is a possible explanation, not a proven attribution. +The first observed bootstrap launch reached harness readiness at 1,350.2 ms. +Neither these warm-cache pairs nor that first observation establish reboot-cold +subsecond startup. Dependency source identities and build paths also changed +when integrating the identical renderer source, potentially affecting layout. + +With the actual saved profile, the parent observed interaction at 859.8 ms; +app tracing recorded repository readiness at 1,229.6 ms. That run took the +deferred path: bootstrap preparation was at 102.7 ms and the Git probe finished +at 107.5 ms, but its UI handoff waited until window initialization completed. + +A fresh 1 kHz samply capture of an early-bootstrap run attributed 550.5 ms of +sampled compiler CPU to ordinary shaders (quads 188.1 ms, shadows 161.1 ms). +There were still no eager smoothing pipeline calls. This is diagnostic evidence +for the next renderer experiment, not a controlled attribution of contention. + ## Remaining work The candidate still compiles ordinary shaders, including quads (~121 ms @@ -93,10 +140,21 @@ Watcher initialization (~142 ms sampled CPU) and directory traversal run concurrently. A worktree-dirty scan also overlaps the main status scan. These inclusive and parallel costs must not be added as elapsed-time savings. -Starting repository bootstrap before window/GPU creation is the next candidate -for overlapping useful work. It must preserve settings-before-restore ordering, -entry gates, Git-runtime readiness, focused modes, and single ownership of -bootstrap dispatch. Any saving requires measurement. +After bootstrap overlap, further experiments include: + +- Create ordinary DX12 pipelines with a small worker pool. Elapsed savings are + unknown; workers need their own error scopes and must preserve failure recovery. +- Start the gitlink status supplement alongside the gix worktree scan after + selecting paths. This could overlap part of the observed 149 ms wait while + preserving submodule status, cancellation, and error handling. +- Reduce repeated filesystem lookups during watcher initialization while + preserving watch coverage. Other-worktree scans already exclude the active + checkout; they are not duplicate active-repository status requests. + +These are unmeasured opportunities. Public `wgpu::PipelineCache` in version +29.0.4 supports Vulkan only, so it does not provide persistent DX12 shader +caching. More shader deferral requires evidence that the initial frame does +not need those pipelines. ## Validation and reproduction @@ -133,6 +191,8 @@ Large local artifacts remain under ignored `target/startup-investigation/`: `repo-lazy-pairs/session.json`, `experiment-summary.json`, the frozen `baseline-frozen/` and `lazy-runtime/` binaries/PDBs, and `samply-lazy-candidate/` with the matching symbols, ETL, and analysis. +The bootstrap experiment adds `bootstrap-runtime/`, `repo-bootstrap-pairs/`, +`bootstrap-stage-real-profile/`, and `samply-bootstrap-candidate/`. Their availability is local; this document records the durable measurements. ## Upstream precompilation From b37e714ecf8218a06cd299cb33c5e8a32fb696a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 12:23:44 +0300 Subject: [PATCH 38/67] perf(windows): compile ordinary DX12 pipelines in parallel Use at most three compilation lanes including the caller during Windows DX12 startup and device recovery, bounded by available parallelism. Keep all ordinary pipelines available before rendering; other backends and warm runtime rebuilds retain serial initialization. Batch shared vertex stages together. Capture device errors on their originating worker, join every worker before propagating errors/panics, preserve fatal errors, and discard partial results on failure. Thread creation failure falls back to the remaining lanes. Eight alternating pairs across two sessions on Ryzen AI 9 HX 370 / Radeon 890M, using release-with-debug, DX12/FXC, fresh app profiles and the GitComet repository: - Repository ready median: 1100.2 -> 847.8 ms; faster in every pair. - Median paired ready saving: 251.1 ms (median paired ratio 0.776). - Ready range: 1009.1-1267.5 -> 807.0-1014.7 ms; 7/8 candidates below 1 second. - First draw median: 801.6 -> 671.7 ms; two pairs regressed by 14.5/23.8 ms. Bootstrap path mix differs (baseline early 4/8, candidate early 7/8). Four matched early/early pairs still improve ready by a median 187.4 ms, at a sampled peak working-set cost of +150.3 MiB and private-byte cost of +209.8 MiB. These coarse process samples are not heap allocations or steady-state memory. The only candidate over 1 second followed the deferred Git-probe path. First observed launch: 837.6 ms first draw, 1019.6 ms harness ready. Actual saved profile: app ready 709.8 ms, interactive 755.3 ms, parent-observed interactive 879.2 ms. Empty Home: parent-observed ready 630.6 ms. These are warm-system observations, not a strict maximum or reboot-cold guarantee. Validation: release-with-debug build passed; two new DX12 GPU tests verify serial/parallel pixel equality and validation failure/retry isolation; all four smoothing regressions pass; history navigation and Settings live checks pass. Formatting/diff checks pass. Preserve exact pre-format compiled/test sources, frozen EXE/PDB hashes, paired captures and independent analysis locally; record durable results and excluded-crate test reproduction in repository docs. --- docs/windows-startup-performance.md | 75 ++- vendor/gpui-wgpu/UPSTREAM.md | 54 ++- .../gpui-wgpu/src/wgpu_renderer/pipelines.rs | 449 ++++++++++++++++-- .../gpui-wgpu/src/wgpu_renderer/resources.rs | 5 +- 4 files changed, 524 insertions(+), 59 deletions(-) diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index 35c980d11..33d633012 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -3,6 +3,12 @@ Measured on October 20, 2026 with a Ryzen AI 9 HX 370 and Radeon 890M. DX12 remains the default; DX11 was used only as a diagnostic comparison. +The final candidate's warm-start medians are **671.7 ms to first draw** and +**847.8 ms to repository readiness** across eight runs in two alternating-pair +sessions. Seven of eight runs reached readiness below one second; the remaining +run took 1,014.7 ms. The median target is met on this machine; a strict +subsecond maximum was not achieved, and reboot-cold startup was not measured. + ## Baseline and bottleneck GitComet baseline: `848514d8b4b9e3d11e37565ebc6399ded0a707fd`. @@ -125,10 +131,66 @@ sampled compiler CPU to ordinary shaders (quads 188.1 ms, shadows 161.1 ms). There were still no eager smoothing pipeline calls. This is diagnostic evidence for the next renderer experiment, not a controlled attribution of contention. +## Parallel ordinary pipeline creation + +The renderer now has a bounded parallel initialization path for Windows DX12. +It uses at most three compilation lanes, including the caller, and respects +available parallelism. All ordinary pipelines are created before rendering. +Other backends and warm runtime rebuilds keep serial initialization. + +Each worker captures its own wgpu errors. All workers join before an error or +panic propagates, partial pipeline sets are discarded on failure, and pipelines +sharing a vertex stage remain in the same batch to avoid duplicate cache misses. +Two new DX12 tests pass: serial/parallel pixel equality over repeated frames, +and worker validation failure followed by a clean retry without error leakage. +The four existing smoothing regressions also pass on this renderer. + +Eight alternating pairs across two sessions (five plus three) compared the +frozen bootstrap build with the parallel candidate using DX12/FXC and the same +`release-with-debug` profile. No compilation ran during measurements. + +| Milestone | Bootstrap / serial pipelines | Bootstrap / parallel pipelines | +| --- | ---: | ---: | +| Launch to first draw, median | 801.6 ms | 671.7 ms | +| Launch to repository ready, median | 1,100.2 ms | 847.8 ms | +| Repository-ready range | 1,009.1–1,267.5 ms | 807.0–1,014.7 ms | + +The candidate reached repository readiness faster in every pair. Median paired +saving was 251.1 ms, with a median paired ratio of 0.776 (22.4% lower latency). +Two pairs had small first-draw regressions of 14.5 and 23.8 ms despite faster +readiness. Bootstrap timing varied: the serial baseline overlapped loading in +four of eight runs, and the candidate in seven of eight. These are practical +end-to-end results with mixed bootstrap paths, not a shader-only microbenchmark. +The candidate's one deferred run was also its one result above one second. +The four pairs in which both variants overlapped loading still improved +readiness in every pair, with a median paired saving of 187.4 ms. + +Memory results depend on that path mix. Across all eight runs, median sampled +peak working set was 675.1 MiB for serial creation and 669.1 MiB for parallel +creation; median sampled peak private bytes were 838.1 and 864.5 MiB. +In the four comparable early-loading pairs, parallel creation increased sampled +peak working set by a median 150.3 MiB and private bytes by 209.8 MiB. +These are maxima of current-process samples approximately every 260 ms over the +short run. They are not exact native peak counters, allocation stacks, retained +heap, steady-state memory, or GPU memory. No general memory reduction is claimed. + +The first observed parallel-candidate launch reached first draw at 837.6 ms and +repository readiness at 1,019.6 ms. With the actual saved profile, app tracing +recorded first paint at 623.3 ms, repository ready at 709.8 ms, and interaction at +755.3 ms; the parent observed interaction at 879.2 ms. An empty profile reached +ready Home at 539.2 ms in app tracing, observed at 630.6 ms by the parent. +History navigation and opening Settings passed live checks on the final binary. + +Parallel compilation moves work onto a bounded set of threads; these results +do not establish a reduction in total shader compiler CPU. The exact pre-format +compiled/test sources and their relationship to the rustfmt-formatted working +source are retained in the local provenance artifacts. + ## Remaining work -The candidate still compiles ordinary shaders, including quads (~121 ms +The earlier lazy-only trace attributed ordinary shader work to quads (~121 ms sampled compiler CPU), shadows (~67 ms), and path rasterization (~39 ms). +Those shaders still compile; their initialization can now overlap. After window creation, repository `LoadStatus` took about 376 ms wall time in the diagnostic trace. It includes a 149 ms child-command wait while @@ -140,10 +202,12 @@ Watcher initialization (~142 ms sampled CPU) and directory traversal run concurrently. A worktree-dirty scan also overlaps the main status scan. These inclusive and parallel costs must not be added as elapsed-time savings. -After bootstrap overlap, further experiments include: +Further experiments include: -- Create ordinary DX12 pipelines with a small worker pool. Elapsed savings are - unknown; workers need their own error scopes and must preserve failure recovery. +- Adopt a completed background Git probe before window attachment, with explicit + ownership of the deferred restore and protection against duplicate recovery. + The current UI handoff can still serialize loading when the probe finishes + just after bootstrap preparation. - Start the gitlink status supplement alongside the gix worktree scan after selecting paths. This could overlap part of the observed 149 ms wait while preserving submodule status, cancellation, and error handling. @@ -193,6 +257,9 @@ Large local artifacts remain under ignored `target/startup-investigation/`: `samply-lazy-candidate/` with the matching symbols, ETL, and analysis. The bootstrap experiment adds `bootstrap-runtime/`, `repo-bootstrap-pairs/`, `bootstrap-stage-real-profile/`, and `samply-bootstrap-candidate/`. +The final experiment adds `parallel-runtime/`, `repo-parallel-pairs/`, +`repo-parallel-confirmation/`, `parallel-comparison-summary.json`, the +`parallel-stage-*` traces, and `parallel-compiled-source/`. Their availability is local; this document records the durable measurements. ## Upstream precompilation diff --git a/vendor/gpui-wgpu/UPSTREAM.md b/vendor/gpui-wgpu/UPSTREAM.md index 8846e8397..4efe91d59 100644 --- a/vendor/gpui-wgpu/UPSTREAM.md +++ b/vendor/gpui-wgpu/UPSTREAM.md @@ -19,7 +19,7 @@ rendering quality. Failed creation removes only newly created pipelines and uses the existing DX12 frame retry policy. Device/renderer reconstruction starts with no smoothing pipelines. -The change affects these files relative to the base revision: +The smoothing change affects these files relative to the base revision: - `src/wgpu_renderer.rs` - `src/wgpu_renderer/drawing.rs` @@ -28,6 +28,15 @@ The change affects these files relative to the base revision: - `src/wgpu_renderer/lazy_smoothing_tests.rs` - `src/wgpu_renderer/pipelines.rs` +GitComet also adds bounded parallel creation of ordinary pipelines in +`src/wgpu_renderer/pipelines.rs` and `src/wgpu_renderer/resources.rs`. +Windows DX12 startup and device recovery use at most three compilation lanes, +including the calling thread, bounded by available parallelism. Workers capture +their own device errors, all workers join before failure propagation, and +partial results are discarded on failure. Adjacent pipelines sharing a vertex +shader stay in the same batch. Warm runtime rebuilds and other backends retain +serial creation. All ordinary pipelines are ready before rendering begins. + All other upstream source files, tests, examples, and the license are retained unchanged. `Cargo.toml` expands the upstream workspace dependencies and lints, and resolves GPUI sibling crates from the pinned base revision. GitComet's root @@ -36,22 +45,55 @@ or the other GPUI crates. ## Validation -The exact renderer source was built in GitComet's `release-with-debug` profile +The smoothing change was built in GitComet's `release-with-debug` profile and tested on Windows with the AMD Radeon 890M using DX12. Four focused tests passed: first-use rendering matched prepared-pipeline pixels, backdrop and isolated-filter planning worked, failed creation rolled back correctly, and the existing DX12 frame retry behavior was preserved. History navigation and settings were also exercised in the application. +The parallel creation change passes two further Windows DX12 tests: exact pixel +equality against serial creation, and worker validation failure followed by +successful retry without leaking errors. All four smoothing regressions pass +with the parallel creation change as well. + +This crate is excluded from the production workspace, so testing it with +`cargo test -p gpui_ce_wgpu` at the root does not resolve its dev dependencies. +For an isolated test build, copy this directory to a fresh +`target/gpui-wgpu-tests` directory, seed its `Cargo.lock` from the root lockfile, +and append the following to the copied manifest: + +```toml +[workspace] + +[patch."https://github.com/Havunen/gpui-ce.git"] +gpui_ce_wgpu = { path = "." } + +[patch.crates-io] +block = { path = "../../vendor/block" } +``` + +Then run from the repository root: + +```powershell +cargo test --manifest-path target/gpui-wgpu-tests/Cargo.toml --features test-support --lib parallel_pipeline -- --test-threads=1 +cargo test --manifest-path target/gpui-wgpu-tests/Cargo.toml --features test-support --lib smoothing -- --test-threads=1 +``` + +Cargo resolves test-only dependencies in the copied lockfile. Keep production +workspace membership and manifests unchanged. The Windows parallel tests +require DX12 and do not silently validate a different backend. + ## Updating When changing GitComet's GPUI revision, compare this crate with `crates/gpui_wgpu` at the new revision. If upstream includes the same behavior, remove this directory, its workspace exclusion, and the root git-source patch. -Otherwise, apply the smoothing changes to the new crate, expand its workspace -manifest values again, and update every sibling dependency revision here to -match the root GPUI revision. +Otherwise, apply the smoothing and parallel creation changes to the new crate, +expand its workspace manifest values again, and update every sibling dependency +revision here to match the root GPUI revision. -Keep the upstream tests and run the smoothing tests on DX12 after an update. +Keep the upstream tests and run the smoothing and parallel creation tests on +DX12 after an update. Do not import temporary measurement instrumentation or absolute paths from an experiment checkout. diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs b/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs index b87662e34..bb4bfc4a2 100644 --- a/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs +++ b/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs @@ -472,6 +472,61 @@ impl WgpuPipelines { dual_source_blending: bool, tier: RendererTier, ) -> Self { + // Warm runtime rebuilds keep the original serial error-scope behavior. + Self::new_with_workers( + device, + bind_group_layouts, + surface_format, + alpha_mode, + path_sample_count, + dual_source_blending, + tier, + 1, + ) + .expect("serial pipeline creation reports errors through the caller's device scopes") + } + + pub(super) fn new_for_backend( + device: &wgpu::Device, + bind_group_layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + tier: RendererTier, + _backend: wgpu::Backend, + ) -> Result { + let workers = 1; + #[cfg(target_os = "windows")] + let workers = if _backend == wgpu::Backend::Dx12 { + std::thread::available_parallelism() + .map_or(1, std::num::NonZeroUsize::get) + .min(3) + } else { + workers + }; + Self::new_with_workers( + device, + bind_group_layouts, + surface_format, + alpha_mode, + path_sample_count, + dual_source_blending, + tier, + workers, + ) + } + + fn new_with_workers( + device: &wgpu::Device, + bind_group_layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + tier: RendererTier, + _workers: usize, + ) -> Result { let dual_source_blending = tier == RendererTier::Modern && supported_dual_source_blending(device, dual_source_blending); let base_source = match tier { @@ -547,65 +602,97 @@ impl WgpuPipelines { shader::DataLayout::Surface => &surface_layout, shader::DataLayout::Blur => &blur_layout, }; - let create = - |specification: shader::Pipeline, target: &wgpu::ColorTargetState, samples, module| { - create_render_pipeline( - device, - specification, - layout_for(specification.data_layout), - target, - samples, - module, - ) + let describe = + |specification: shader::Pipeline, target, samples, module| PipelineCreation { + specification, + layout: layout_for(specification.data_layout), + target, + sample_count: samples, + module, }; - - Self { - quads: create(shader::QUADS, &scene_target, 1, &shader_module), - damage_clear_quads: None, - smoothed_quads: None, - shadows: create(shader::SHADOWS, &scene_target, 1, &shader_module), - smoothed_shadows: None, - path_rasterization: create( + let subpixel_target = wgpu::ColorTargetState { + write_mask: wgpu::ColorWrites::COLOR, + ..color_target(surface_format, Some(subpixel_blend_state())) + }; + let mut requests = vec![ + describe(shader::QUADS, &scene_target, 1, &shader_module), + describe(shader::SHADOWS, &scene_target, 1, &shader_module), + describe( shader::PATH_RASTERIZATION, &path_rasterization_target, path_sample_count, &shader_module, ), - paths: create(shader::PATHS, &path_target, 1, &shader_module), - underlines: create(shader::UNDERLINES, &scene_target, 1, &shader_module), - monochrome_sprites: create( - shader::MONOCHROME_SPRITES, - &scene_target, - 1, - &shader_module, - ), - subpixel_sprites: subpixel_shader_module.as_ref().map(|module| { - create( - shader::SUBPIXEL_SPRITES, - &wgpu::ColorTargetState { - write_mask: wgpu::ColorWrites::COLOR, - ..color_target(surface_format, Some(subpixel_blend_state())) - }, - 1, - module, - ) - }), - polychrome_sprites: create( - shader::POLYCHROME_SPRITES, - &scene_target, + describe(shader::PATHS, &path_target, 1, &shader_module), + describe(shader::UNDERLINES, &scene_target, 1, &shader_module), + describe(shader::MONOCHROME_SPRITES, &scene_target, 1, &shader_module), + ]; + if let Some(module) = subpixel_shader_module.as_ref() { + requests.push(describe( + shader::SUBPIXEL_SPRITES, + &subpixel_target, 1, - &shader_module, - ), - smoothed_polychrome_sprites: None, - surfaces: create(shader::SURFACES, &composite_target, 1, &shader_module), - blur_downsample: create( + module, + )); + } + requests.extend([ + describe(shader::POLYCHROME_SPRITES, &scene_target, 1, &shader_module), + describe(shader::SURFACES, &composite_target, 1, &shader_module), + // Keep the two shared fullscreen vertex stages in one worker batch. + describe( shader::BLUR_DOWNSAMPLE, &overwrite_target, 1, &shader_module, ), - blur: create(shader::BLUR, &overwrite_target, 1, &shader_module), - blur_composite: create(shader::BLUR_COMPOSITE, &composite_target, 1, &shader_module), + describe(shader::BLUR, &overwrite_target, 1, &shader_module), + describe(shader::BLUR_COMPOSITE, &composite_target, 1, &shader_module), + ]); + #[cfg(target_os = "windows")] + let pipelines = if _workers > 1 { + create_pipelines_parallel(device, &requests, _workers)? + } else { + requests + .iter() + .map(|request| request.create(device)) + .collect() + }; + #[cfg(not(target_os = "windows"))] + let pipelines: Vec<_> = requests + .iter() + .map(|request| request.create(device)) + .collect(); + let mut pipelines = pipelines.into_iter(); + let mut next = || { + pipelines + .next() + .expect("every requested pipeline was created") + }; + let quads = next(); + let shadows = next(); + let path_rasterization = next(); + let paths = next(); + let underlines = next(); + let monochrome_sprites = next(); + let subpixel_sprites = subpixel_shader_module.as_ref().map(|_| next()); + + Ok(Self { + quads, + damage_clear_quads: None, + smoothed_quads: None, + shadows, + smoothed_shadows: None, + path_rasterization, + paths, + underlines, + monochrome_sprites, + subpixel_sprites, + polychrome_sprites: next(), + smoothed_polychrome_sprites: None, + surfaces: next(), + blur_downsample: next(), + blur: next(), + blur_composite: next(), smoothed_blur_composite: None, smoothing_textured_layout: textured_layout, smoothing_blur_layout: blur_layout, @@ -614,8 +701,135 @@ impl WgpuPipelines { base_shader: shader_module, instance_layout: instance_layout, damage_clear_format: surface_format, + }) + } +} + +struct PipelineCreation<'a> { + specification: shader::Pipeline, + layout: &'a wgpu::PipelineLayout, + target: &'a wgpu::ColorTargetState, + sample_count: u32, + module: &'a wgpu::ShaderModule, +} + +impl PipelineCreation<'_> { + fn create(&self, device: &wgpu::Device) -> WgpuRenderPipeline { + create_render_pipeline( + device, + self.specification, + self.layout, + self.target, + self.sample_count, + self.module, + ) + } +} + +#[cfg(target_os = "windows")] +fn create_pipelines_parallel( + device: &wgpu::Device, + requests: &[PipelineCreation<'_>], + workers: usize, +) -> Result, wgpu::Error> { + use std::{ + panic::{AssertUnwindSafe, catch_unwind, resume_unwind}, + sync::atomic::{AtomicUsize, Ordering}, + }; + + // Adjacent pipelines sharing a vertex stage stay sequential: wgpu's DX12 + // shader cache releases its lock during compilation and is not single-flight. + let mut batches: Vec> = Vec::new(); + for (index, request) in requests.iter().enumerate() { + if index > 0 + && request.specification.vertex_entry == requests[index - 1].specification.vertex_entry + && request.module == requests[index - 1].module + && request.layout == requests[index - 1].layout + { + batches.last_mut().unwrap().end = index + 1; + } else { + batches.push(index..index + 1); } } + let next_batch = AtomicUsize::new(0); + let run_worker = || { + // Error scopes are thread-local. Parent initialization scopes cannot + // observe a worker failure, so drain all three scopes on the worker. + let scopes = [ + device.push_error_scope(wgpu::ErrorFilter::Validation), + device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), + device.push_error_scope(wgpu::ErrorFilter::Internal), + ]; + let mut pipelines = Vec::new(); + while let Some(batch) = batches.get(next_batch.fetch_add(1, Ordering::Relaxed)) { + for index in batch.clone() { + pipelines.push((index, requests[index].create(device))); + } + } + let mut error = None; + for scope in scopes.into_iter().rev() { + if let Some(captured) = gpui::block_on(scope.pop()) { + retain_pipeline_error(&mut error, captured); + } + } + match error { + Some(error) => Err(error), + None => Ok(pipelines), + } + }; + let results = std::thread::scope(|scope| { + // Include the calling thread in the bound; never start one thread per PSO. + let mut handles = Vec::new(); + for index in 1..workers.clamp(1, 3).min(batches.len().max(1)) { + match std::thread::Builder::new() + .name(format!("gpui-dx12-pipelines-{index}")) + .spawn_scoped(scope, &run_worker) + { + Ok(handle) => handles.push(handle), + // Already-started workers and the caller can finish the queue. + Err(_) => break, + } + } + let caller = catch_unwind(AssertUnwindSafe(&run_worker)); + let mut results = vec![caller]; + // Join every worker before returning errors or resuming any panic. + results.extend(handles.into_iter().map(|handle| handle.join())); + results + }); + let mut pipelines = Vec::with_capacity(requests.len()); + let mut error = None; + let mut panic = None; + for result in results { + match result { + Ok(Ok(created)) => pipelines.extend(created), + Ok(Err(captured)) => retain_pipeline_error(&mut error, captured), + Err(payload) => { + panic.get_or_insert(payload); + } + } + } + if let Some(payload) = panic { + resume_unwind(payload); + } + if let Some(error) = error { + return Err(error); + } + pipelines.sort_unstable_by_key(|(index, _)| *index); + Ok(pipelines + .into_iter() + .map(|(_, pipeline)| pipeline) + .collect()) +} + +#[cfg(target_os = "windows")] +fn retain_pipeline_error(slot: &mut Option, error: wgpu::Error) { + // Never hide a fatal worker error behind a validation failure from another. + if slot.is_none() + || matches!(slot, Some(wgpu::Error::Validation { .. })) + && !matches!(error, wgpu::Error::Validation { .. }) + { + *slot = Some(error); + } } fn generated_bind_group_layout( @@ -818,6 +1032,147 @@ mod tests { use super::*; use crate::WgpuContext; + #[cfg(target_os = "windows")] + #[test] + fn parallel_pipeline_failure_is_captured_and_allows_retry() -> anyhow::Result<()> { + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + anyhow::ensure!(context.adapter.get_info().backend == wgpu::Backend::Dx12); + let device = &context.device; + let layouts = WgpuBindGroupLayouts::new(device, context.renderer_tier()); + let layout = create_pipeline_layout(device, "parallel_test", &layouts, &layouts.instances); + let module = create_shader_module(device, "parallel_test", BASE_WGSL); + let valid_target = color_target(context.color_texture_format(), None); + let invalid_target = color_target(wgpu::TextureFormat::Depth24Plus, None); + let requests = [ + PipelineCreation { + specification: shader::SHADOWS, + layout: &layout, + target: &valid_target, + sample_count: 1, + module: &module, + }, + PipelineCreation { + specification: shader::QUADS, + layout: &layout, + target: &invalid_target, + sample_count: 1, + module: &module, + }, + PipelineCreation { + specification: shader::UNDERLINES, + layout: &layout, + target: &valid_target, + sample_count: 1, + module: &module, + }, + ]; + let parent_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let error = match create_pipelines_parallel(device, &requests, 3) { + Ok(_) => panic!("invalid worker pipeline was accepted"), + Err(error) => error, + }; + assert!(matches!(error, wgpu::Error::Validation { .. })); + assert!(gpui::block_on(parent_scope.pop()).is_none()); + assert!( + context.uncaptured_error_slot().lock().unwrap().is_none(), + "worker errors must be returned, not leak into an unrelated frame" + ); + let mut retry = requests; + retry[1].target = &valid_target; + let pipelines = create_pipelines_parallel(device, &retry, 3)?; + assert_eq!( + pipelines + .iter() + .map(|pipeline| pipeline.specification.label) + .collect::>(), + ["shadows", "quads", "underlines"], + "completion order must not change pipeline assignment" + ); + assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); + Ok(()) + } + + #[cfg(all(feature = "test-support", target_os = "windows"))] + #[test] + fn parallel_pipeline_creation_matches_serial_pixels() -> anyhow::Result<()> { + use super::super::WgpuRenderer; + use gpui::{ + Bounds, ContentMask, Corners, DevicePixels, Quad, ScaledPixels, Scene, Shadow, point, + size, solid_background, + }; + + let _gpu_test_guard = crate::test_gpu::guard(); + let context = WgpuContext::new_headless(None)?; + anyhow::ensure!(context.adapter.get_info().backend == wgpu::Backend::Dx12); + let target = size(DevicePixels(64), DevicePixels(64)); + let mut parallel = WgpuRenderer::new_headless(&context, target)?; + let mut serial = WgpuRenderer::new_headless(&context, target)?; + for (renderer, workers) in [(&mut parallel, 3), (&mut serial, 1)] { + renderer.retained.disable_for_tests(); + let config = renderer.target.configuration().clone(); + let samples = renderer.rendering_params.path_sample_count; + let dual_source = renderer.dual_source_blending; + let resources = renderer.resources_mut(); + resources.pipelines = WgpuPipelines::new_with_workers( + &resources.device, + &resources.bind_group_layouts, + config.format, + config.alpha_mode, + samples, + dual_source, + resources.renderer_tier, + workers, + )?; + assert_eq!( + resources.pipelines.subpixel_sprites.is_some(), + dual_source, + "parallel creation must retain supported subpixel rendering" + ); + assert!(resources.pipelines.smoothed_quads.is_none()); + assert!(resources.pipelines.smoothed_shadows.is_none()); + } + let bounds = Bounds::new( + point(ScaledPixels(16.0), ScaledPixels(16.0)), + size(ScaledPixels(32.0), ScaledPixels(32.0)), + ); + let content_mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(64.0), ScaledPixels(64.0)), + ), + ..Default::default() + }; + let corners = Corners::all(ScaledPixels(8.0)); + let mut scene = Scene::default(); + scene.insert_primitive(Shadow { + order: 0, + bounds, + element_bounds: bounds, + content_mask, + corner_radii: corners, + element_corner_radii: corners, + corner_smoothing: 0.0, + color: solid_background(gpui::white()), + blur_radius: ScaledPixels(4.0), + inset: false.into(), + }); + scene.insert_primitive(Quad { + bounds, + content_mask, + corner_radii: corners, + background: solid_background(gpui::white()), + ..Default::default() + }); + scene.finish(); + let reference = serial.render_to_image(&scene)?; + assert_eq!(reference.get_pixel(32, 32).0, [255; 4]); + assert_eq!(parallel.render_to_image(&scene)?, reference); + assert_eq!(parallel.render_to_image(&scene)?, reference); + assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); + Ok(()) + } + #[test] fn smoothing_creation_failure_rolls_back_only_new_pipelines() -> anyhow::Result<()> { let _gpu_test_guard = crate::test_gpu::guard(); diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs b/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs index c012094b2..fc04a7f12 100644 --- a/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs +++ b/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs @@ -101,7 +101,7 @@ impl WgpuResources { let queue = Arc::clone(&context.queue); let renderer_tier = context.renderer_tier(); let bind_group_layouts = WgpuBindGroupLayouts::new(&device, renderer_tier); - let pipelines = WgpuPipelines::new( + let pipelines = WgpuPipelines::new_for_backend( &device, &bind_group_layouts, surface_config.format, @@ -109,7 +109,8 @@ impl WgpuResources { rendering.path_sample_count, dual_source_blending, renderer_tier, - ); + context.adapter.get_info().backend, + )?; let linear_sampler = |label| { device.create_sampler(&wgpu::SamplerDescriptor { label: Some(label), From 1da63f84a8329efed88b90701c0a6a5ac336d8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 14:05:38 +0300 Subject: [PATCH 39/67] Pin GPUI to upstream lazy DX12 shader initialization Pin all six GPUI dependencies and the UI kit revision contract to 18e1b2f65899ccbc36304fbb8f9ee703ea86adc4. Resolve all 22 GPUI packages from that source and remove the superseded vendored renderer patch. Keep DX12, lazy smoothing pipelines, and bounded parallel ordinary pipeline compilation. Preserve unrelated Cargo.lock dependency selections and update integration provenance. Locked offline metadata and diff checks pass; upstream renderer validation passed 67 native tests. Fresh GitComet startup measurements follow separately. --- Cargo.lock | 43 +- Cargo.toml | 17 +- crates/gitcomet-ui-kit/gpui-revision | 2 +- docs/windows-startup-performance.md | 8 +- vendor/README.md | 20 +- vendor/gpui-wgpu/Cargo.toml | 98 - vendor/gpui-wgpu/LICENSE-APACHE | 222 -- vendor/gpui-wgpu/UPSTREAM.md | 99 - vendor/gpui-wgpu/benches/renderer.rs | 209 -- vendor/gpui-wgpu/examples/custom_gpu.rs | 369 --- vendor/gpui-wgpu/src/gpui_wgpu.rs | 17 - vendor/gpui-wgpu/src/test_gpu.rs | 15 - vendor/gpui-wgpu/src/wgpu_atlas.rs | 622 ----- vendor/gpui-wgpu/src/wgpu_context.rs | 1123 --------- vendor/gpui-wgpu/src/wgpu_renderer.rs | 1181 --------- vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs | 766 ------ vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs | 298 --- vendor/gpui-wgpu/src/wgpu_renderer/filters.rs | 284 --- vendor/gpui-wgpu/src/wgpu_renderer/frame.rs | 1104 --------- .../gpui-wgpu/src/wgpu_renderer/headless.rs | 2203 ----------------- .../src/wgpu_renderer/lazy_smoothing_tests.rs | 227 -- .../gpui-wgpu/src/wgpu_renderer/path_types.rs | 6 - .../gpui-wgpu/src/wgpu_renderer/pipelines.rs | 1432 ----------- .../gpui-wgpu/src/wgpu_renderer/platform.rs | 715 ------ .../gpui-wgpu/src/wgpu_renderer/resources.rs | 434 ---- .../gpui-wgpu/src/wgpu_renderer/retained.rs | 318 --- .../gpui-wgpu/src/wgpu_renderer/settings.rs | 97 - .../src/wgpu_renderer/surface_tests.rs | 153 -- .../gpui-wgpu/src/wgpu_renderer/surfaces.rs | 163 -- .../src/wgpu_renderer/surfaces/macos.rs | 405 --- .../src/wgpu_renderer/surfaces/texture.rs | 70 - .../src/wgpu_renderer/surfaces/unsupported.rs | 24 - .../src/wgpu_renderer/surfaces/windows.rs | 300 --- .../surfaces/windows/bindings.rs | 1180 --------- .../wgpu_renderer/surfaces/windows/shared.rs | 188 -- .../wgpu_renderer/surfaces/windows/upload.rs | 121 - vendor/gpui-wgpu/src/wgpu_renderer/target.rs | 519 ---- vendor/gpui-wgpu/tests/headless_primitives.rs | 642 ----- 38 files changed, 38 insertions(+), 15656 deletions(-) delete mode 100644 vendor/gpui-wgpu/Cargo.toml delete mode 100644 vendor/gpui-wgpu/LICENSE-APACHE delete mode 100644 vendor/gpui-wgpu/UPSTREAM.md delete mode 100644 vendor/gpui-wgpu/benches/renderer.rs delete mode 100644 vendor/gpui-wgpu/examples/custom_gpu.rs delete mode 100644 vendor/gpui-wgpu/src/gpui_wgpu.rs delete mode 100644 vendor/gpui-wgpu/src/test_gpu.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_atlas.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_context.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/filters.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/frame.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/headless.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/platform.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/resources.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/retained.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/settings.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs delete mode 100644 vendor/gpui-wgpu/src/wgpu_renderer/target.rs delete mode 100644 vendor/gpui-wgpu/tests/headless_primitives.rs diff --git a/Cargo.lock b/Cargo.lock index 13b395e72..3c43c9d6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "block", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4109,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "gpui-ce", "naga 24.0.0", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "schemars", "serde", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "log", @@ -4220,7 +4220,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,6 +4244,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "anyhow", "core-foundation", @@ -4274,7 +4275,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "accesskit", "accesskit_windows", @@ -4307,7 +4308,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -10091,7 +10092,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "encoding_rs", ] @@ -10099,7 +10100,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index 7d1da2618..b356d2c64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ members = [ exclude = [ "vendor/block", "vendor/block/test_utils", - "vendor/gpui-wgpu", "vendor/mermaid-rs-renderer", "crates/gitcomet-filesystem-native", "crates/win32-window-utils", @@ -110,10 +109,6 @@ rust-version = "1.99.1" # block 0.1.6 has no released fix for its uninhabited extern class static. block = { path = "vendor/block" } -[patch."https://github.com/Havunen/gpui-ce.git"] -# Defer unused smoothing shaders while retaining DX12; see vendor/gpui-wgpu/UPSTREAM.md. -gpui_ce_wgpu = { path = "vendor/gpui-wgpu" } - [workspace.dependencies] # Internal crates gitcomet-app = { path = "crates/gitcomet-app", default-features = false } @@ -168,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index b6b1ca4fa..fd4693bad 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -0665fb07d59cf7af185e58f44b9f890590abe204 +18e1b2f65899ccbc36304fbb8f9ee703ea86adc4 diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index 33d633012..88f3c1449 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -36,10 +36,10 @@ original shaders and compiles before encoding the first frame that needs them. It supports reuse, resource rebuilds, failure rollback, and retries. A future feature requesting smoothing can still incur a one-time compilation delay. -The tested renderer is integrated through `vendor/gpui-wgpu`, with a Cargo -git-source patch affecting only `gpui_ce_wgpu`. Other GPUI crates retain the -baseline revision. This avoids depending on an unpublished local commit. -See `vendor/gpui-wgpu/UPSTREAM.md` for its provenance and removal procedure. +These measurements used the renderer then vendored in `vendor/gpui-wgpu`. +The smoothing and parallel pipeline changes are now included in upstream GPUI +commit `18e1b2f65899ccbc36304fbb8f9ee703ea86adc4`. All GPUI dependencies now +pin that revision, replacing the vendored renderer and its Cargo patch. ## Controlled measurements diff --git a/vendor/README.md b/vendor/README.md index c310a5df2..a9c94ba46 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -49,21 +49,11 @@ the same upstream revision. ## GPUI WGPU renderer -`gpui-wgpu/` contains the Apache-2.0-licensed `gpui_ce_wgpu` crate from GPUI -revision `0665fb07d59cf7af185e58f44b9f890590abe204`, with lazy creation of the -four smoothing pipelines from local GPUI commit -`9362a02d98324c6d91a1bec58b90ab22ab667252`. Normal frames no longer compile -those shaders at renderer initialization. A frame that uses smoothing creates -the pipelines before drawing, with the same shaders and error recovery. - -Only this renderer crate is patched. All other GPUI crates retain the existing -git revision, and Windows continues to use DX12. Its manifest expands upstream -workspace dependencies so the vendor directory is portable; the source, -examples, tests, and license match the tested GPUI commit. The crate is excluded -from this workspace's formatting and lint configuration. - -See [`gpui-wgpu/UPSTREAM.md`](gpui-wgpu/UPSTREAM.md) for provenance, validation, -and the procedure for updating or removing the patch. +GPUI revision `18e1b2f65899ccbc36304fbb8f9ee703ea86adc4` includes lazy creation +of the four smoothing pipelines and bounded parallel creation of ordinary DX12 +pipelines. This supersedes the renderer previously vendored in `gpui-wgpu/`. +The renderer and other GPUI crates now use the same pinned upstream revision, +without a renderer patch. Windows continues to use DX12. ## Difftastic diff --git a/vendor/gpui-wgpu/Cargo.toml b/vendor/gpui-wgpu/Cargo.toml deleted file mode 100644 index c93ea6fbf..000000000 --- a/vendor/gpui-wgpu/Cargo.toml +++ /dev/null @@ -1,98 +0,0 @@ -# Vendored from Havunen/gpui-ce 0665fb07d59cf7af185e58f44b9f890590abe204 -# with lazy smoothing pipeline creation from 9362a02d98324c6d91a1bec58b90ab22ab667252. -# Workspace inheritance is expanded here; GPUI siblings retain the pinned base. -# See UPSTREAM.md for provenance, validation, and the upgrade procedure. -[package] -name = "gpui_ce_wgpu" -version = "0.1.0" -description = "A community fork of Zed's GPU-accelerated UI framework" -edition = "2024" -publish = true -license = "Apache-2.0" - -[lints.rust] -unexpected_cfgs = { level = "allow" } - -[lints.clippy] -dbg_macro = "deny" -todo = "deny" -declare_interior_mutable_const = "deny" -redundant_clone = "deny" -disallowed_methods = "deny" -style = { level = "allow", priority = -1 } -type_complexity = "allow" -let_underscore_future = "allow" -single_range_in_vec_init = "allow" -too_many_arguments = "allow" -large_enum_variant = "allow" -nonminimal_bool = "allow" - -[lib] -path = "src/gpui_wgpu.rs" - -[features] -default = [] -test-support = ["gpui/test-support", "dep:image"] -custom-gpu = ["gpui/custom-gpu"] -wgpu-surfaces = ["gpui/wgpu-surfaces"] - -[dependencies] -gpui = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.2", package = "gpui-ce", default-features = false } -gpui_render = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.1.0", package = "gpui_ce_render" } -anyhow = "1" -collections = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.2", package = "gpui_ce_collections" } -etagere = "0.3" -image = { version = "0.25", default-features = false, features = ["gif", "webp"], optional = true } -log = { version = "0.4", features = ["kv_unstable_serde", "serde"] } -parking_lot = "0.12" -profiling = "1" -raw-window-handle = "0.6" -smallvec = { version = "1", features = ["union", "const_new"] } -gpui_util = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.2.6", package = "gpui_ce_util" } -wgpu = "=29.0.4" -wgsl-rs = { git = "https://github.com/schell/wgsl-rs", rev = "b2de16a1b81e67d76bc265c296184f882864badb", default-features = false, features = ["validation"] } - -[target.'cfg(not(target_family = "wasm"))'.dependencies] -pollster = "1" - -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.10" -core-video = { version = "0.6", features = ["metal"] } -metal = "0.33" -objc2 = "0.6" -objc2-metal = "0.3" - -[target.'cfg(target_os = "windows")'.dependencies] -windows-core = "0.100" - -[target.'cfg(target_family = "wasm")'.dependencies] -web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } -wgpu = { version = "=29.0.4", features = ["webgl"] } -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -js-sys = "0.3" - -[dev-dependencies] -bytemuck = { version = "1", features = ["derive"] } -criterion = { version = "0.8", features = ["html_reports"] } -env_logger = "0.11" -gpui_platform = { git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", version = "0.1.0", package = "gpui_ce_platform", features = ["wayland", "x11", "test-support"] } -naga = { version = "=29.0.4", features = ["wgsl-in"] } - -[target.'cfg(target_os = "linux")'.dev-dependencies] -wayland-backend = { version = "0.3.17", features = [ - "client_system", - "dlopen", - "rwh_06", -] } -wayland-client = "0.31.15" -wayland-protocols = { version = "0.32.13", features = ["client"] } - -[[example]] -name = "custom_gpu" -required-features = ["custom-gpu"] - -[[bench]] -name = "renderer" -harness = false -required-features = ["test-support"] diff --git a/vendor/gpui-wgpu/LICENSE-APACHE b/vendor/gpui-wgpu/LICENSE-APACHE deleted file mode 100644 index 461a0fe5b..000000000 --- a/vendor/gpui-wgpu/LICENSE-APACHE +++ /dev/null @@ -1,222 +0,0 @@ -Copyright 2022 - 2025 Zed Industries, Inc. - - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - - http://www.apache.org/licenses/LICENSE-2.0 - - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - - 1. Definitions. - - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - - END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-wgpu/UPSTREAM.md b/vendor/gpui-wgpu/UPSTREAM.md deleted file mode 100644 index 4efe91d59..000000000 --- a/vendor/gpui-wgpu/UPSTREAM.md +++ /dev/null @@ -1,99 +0,0 @@ -# GPUI WGPU renderer - -This directory vendors the `gpui_ce_wgpu` crate from -[`Havunen/gpui-ce`](https://github.com/Havunen/gpui-ce), licensed under Apache 2.0 -(see `LICENSE-APACHE`). - -The base is the same revision used by GitComet's other GPUI crates: -`0665fb07d59cf7af185e58f44b9f890590abe204`. The renderer source includes local -GPUI commit `9362a02d98324c6d91a1bec58b90ab22ab667252`, -`perf(wgpu): create smoothing pipelines on first use`. That commit is not -required to be available from a remote: all of the renderer source is included -here. - -## Changes - -The four smoothing pipelines are created only when a frame needs them. They are -prepared before that frame is encoded, preserving the existing shaders and -rendering quality. Failed creation removes only newly created pipelines and -uses the existing DX12 frame retry policy. Device/renderer reconstruction -starts with no smoothing pipelines. - -The smoothing change affects these files relative to the base revision: - -- `src/wgpu_renderer.rs` -- `src/wgpu_renderer/drawing.rs` -- `src/wgpu_renderer/filters.rs` -- `src/wgpu_renderer/frame.rs` -- `src/wgpu_renderer/lazy_smoothing_tests.rs` -- `src/wgpu_renderer/pipelines.rs` - -GitComet also adds bounded parallel creation of ordinary pipelines in -`src/wgpu_renderer/pipelines.rs` and `src/wgpu_renderer/resources.rs`. -Windows DX12 startup and device recovery use at most three compilation lanes, -including the calling thread, bounded by available parallelism. Workers capture -their own device errors, all workers join before failure propagation, and -partial results are discarded on failure. Adjacent pipelines sharing a vertex -shader stay in the same batch. Warm runtime rebuilds and other backends retain -serial creation. All ordinary pipelines are ready before rendering begins. - -All other upstream source files, tests, examples, and the license are retained -unchanged. `Cargo.toml` expands the upstream workspace dependencies and lints, -and resolves GPUI sibling crates from the pinned base revision. GitComet's root -manifest patches only `gpui_ce_wgpu`; it does not change the selected renderer -or the other GPUI crates. - -## Validation - -The smoothing change was built in GitComet's `release-with-debug` profile -and tested on Windows with the AMD Radeon 890M using DX12. Four focused tests -passed: first-use rendering matched prepared-pipeline pixels, backdrop and -isolated-filter planning worked, failed creation rolled back correctly, and -the existing DX12 frame retry behavior was preserved. History navigation and -settings were also exercised in the application. - -The parallel creation change passes two further Windows DX12 tests: exact pixel -equality against serial creation, and worker validation failure followed by -successful retry without leaking errors. All four smoothing regressions pass -with the parallel creation change as well. - -This crate is excluded from the production workspace, so testing it with -`cargo test -p gpui_ce_wgpu` at the root does not resolve its dev dependencies. -For an isolated test build, copy this directory to a fresh -`target/gpui-wgpu-tests` directory, seed its `Cargo.lock` from the root lockfile, -and append the following to the copied manifest: - -```toml -[workspace] - -[patch."https://github.com/Havunen/gpui-ce.git"] -gpui_ce_wgpu = { path = "." } - -[patch.crates-io] -block = { path = "../../vendor/block" } -``` - -Then run from the repository root: - -```powershell -cargo test --manifest-path target/gpui-wgpu-tests/Cargo.toml --features test-support --lib parallel_pipeline -- --test-threads=1 -cargo test --manifest-path target/gpui-wgpu-tests/Cargo.toml --features test-support --lib smoothing -- --test-threads=1 -``` - -Cargo resolves test-only dependencies in the copied lockfile. Keep production -workspace membership and manifests unchanged. The Windows parallel tests -require DX12 and do not silently validate a different backend. - -## Updating - -When changing GitComet's GPUI revision, compare this crate with -`crates/gpui_wgpu` at the new revision. If upstream includes the same behavior, -remove this directory, its workspace exclusion, and the root git-source patch. -Otherwise, apply the smoothing and parallel creation changes to the new crate, -expand its workspace manifest values again, and update every sibling dependency -revision here to match the root GPUI revision. - -Keep the upstream tests and run the smoothing and parallel creation tests on -DX12 after an update. -Do not import temporary measurement instrumentation or absolute paths from an -experiment checkout. diff --git a/vendor/gpui-wgpu/benches/renderer.rs b/vendor/gpui-wgpu/benches/renderer.rs deleted file mode 100644 index b6db76fc9..000000000 --- a/vendor/gpui-wgpu/benches/renderer.rs +++ /dev/null @@ -1,209 +0,0 @@ -use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; -use gpui::{ - Bounds, ContentMask, Corners, DevicePixels, PlatformHeadlessRenderer, Point, Quad, - ScaledPixels, Scene, ShaderBool, Size, Underline, solid_background, white, -}; -use gpui_ce_wgpu::WgpuHeadlessRenderer; -use std::hint::black_box; - -const TARGET_SIZE: Size = Size { - width: DevicePixels(1280), - height: DevicePixels(720), -}; - -fn quad_scene(count: usize) -> Scene { - let mut scene = unplanned_quad_scene(count); - scene.finish(); - scene -} - -fn smoothed_quad_scene(count: usize, corner_radii: Corners) -> Scene { - let mut scene = unplanned_quad_scene(count); - for quad in &mut scene.quads { - quad.corner_radii = corner_radii; - quad.corner_smoothing = 1.0; - } - scene.finish(); - scene -} - -fn alternating_corner_modes_scene(count: usize) -> Scene { - let mut scene = unplanned_quad_scene(count); - for (index, quad) in scene.quads.iter_mut().enumerate() { - quad.corner_radii = Corners::all(ScaledPixels(8.0)); - quad.corner_smoothing = if index % 2 == 0 { 0.0 } else { 1.0 }; - } - scene.finish(); - scene -} - -fn unplanned_quad_scene(count: usize) -> Scene { - let mut scene = Scene::default(); - for index in 0..count { - let column = index % 32; - let row = index / 32; - let bounds = Bounds { - origin: Point { - x: ScaledPixels((column * 40) as f32), - y: ScaledPixels((row * 40) as f32), - }, - size: Size { - width: ScaledPixels(36.0), - height: ScaledPixels(36.0), - }, - }; - scene.insert_primitive(Quad { - order: index as u32, - bounds, - content_mask: ContentMask { - bounds, - ..Default::default() - }, - background: solid_background(gpui::rgb(0x336699)), - ..Default::default() - }); - } - scene -} - -fn unplanned_mixed_scene(count: usize) -> Scene { - let mut scene = Scene::default(); - let bounds = Bounds { - origin: Point { - x: ScaledPixels(0.0), - y: ScaledPixels(0.0), - }, - size: Size { - width: ScaledPixels(1.0), - height: ScaledPixels(1.0), - }, - }; - for index in 0..count { - scene.insert_primitive(Quad { - order: (index * 2) as u32, - bounds, - content_mask: ContentMask { - bounds, - ..Default::default() - }, - background: solid_background(gpui::rgb(0x336699)), - ..Default::default() - }); - scene.insert_primitive(Underline { - order: (index * 2 + 1) as u32, - padding: 0, - bounds, - content_mask: ContentMask { - bounds, - ..Default::default() - }, - color: white().into(), - thickness: ScaledPixels(1.0), - wavy: ShaderBool::Disabled, - }); - } - scene -} - -fn bench_renderer(criterion: &mut Criterion) { - let mut renderer = WgpuHeadlessRenderer::new().expect("headless WGPU renderer must initialize"); - let single_quad = quad_scene(1); - let dense_quads = quad_scene(512); - let compact_smoothed_quads = smoothed_quad_scene(512, Corners::all(ScaledPixels(8.0))); - let reach_aware_smoothed_quads = smoothed_quad_scene( - 512, - Corners { - top_left: ScaledPixels(14.0), - top_right: ScaledPixels(0.0), - bottom_right: ScaledPixels(0.0), - bottom_left: ScaledPixels(0.0), - }, - ); - let alternating_corner_modes = alternating_corner_modes_scene(512); - let mut mixed_batches = unplanned_mixed_scene(512); - mixed_batches.finish(); - - let mut group = criterion.benchmark_group("renderer_end_to_end"); - group.bench_function("single_quad", |bencher| { - bencher.iter(|| { - renderer - .render_scene_to_image(&single_quad, TARGET_SIZE) - .expect("single quad render must succeed") - }) - }); - group.bench_function("512_quads", |bencher| { - bencher.iter(|| { - renderer - .render_scene_to_image(&dense_quads, TARGET_SIZE) - .expect("dense quad render must succeed") - }) - }); - group.bench_function("single_quad_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&single_quad, TARGET_SIZE) - .expect("single quad render must succeed") - }) - }); - group.bench_function("512_quads_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&dense_quads, TARGET_SIZE) - .expect("dense quad render must succeed") - }) - }); - group.bench_function("512_compact_smoothed_quads_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&compact_smoothed_quads, TARGET_SIZE) - .expect("compact smoothed quad render must succeed") - }) - }); - group.bench_function("512_reach_aware_smoothed_quads_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&reach_aware_smoothed_quads, TARGET_SIZE) - .expect("reach-aware smoothed quad render must succeed") - }) - }); - group.bench_function("512_alternating_corner_modes_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&alternating_corner_modes, TARGET_SIZE) - .expect("alternating corner modes must render successfully") - }) - }); - group.bench_function("1024_mixed_batches_wait", |bencher| { - bencher.iter(|| { - renderer - .render_scene_and_wait(&mixed_batches, TARGET_SIZE) - .expect("mixed batch render must succeed") - }) - }); - group.finish(); - - let mut group = criterion.benchmark_group("scene_plan"); - group.bench_function("compile_1024_mixed_batches", |bencher| { - bencher.iter_batched( - || unplanned_mixed_scene(512), - |mut scene| { - scene.finish(); - black_box(*scene.render_plan().requirements()) - }, - BatchSize::SmallInput, - ) - }); - let mut mixed_scene = unplanned_mixed_scene(512); - mixed_scene.finish(); - group.bench_function("traverse_1024_mixed_batches", |bencher| { - bencher.iter(|| { - for command in mixed_scene.render_commands() { - black_box(command); - } - }) - }); - group.finish(); -} - -criterion_group!(benches, bench_renderer); -criterion_main!(benches); diff --git a/vendor/gpui-wgpu/examples/custom_gpu.rs b/vendor/gpui-wgpu/examples/custom_gpu.rs deleted file mode 100644 index 632bdbdba..000000000 --- a/vendor/gpui-wgpu/examples/custom_gpu.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! A minimal custom GPU control. -//! -//! The control owns its buffers, pipeline, and offscreen target. GPUI owns the -//! device and queue, so those resources are recreated after device loss. -//! -//! Run with `--features custom-gpu` on supported targets. macOS windows also -//! need the WGPU renderer: -//! -//! ```sh -//! cargo run -p gpui_ce_wgpu --example custom_gpu --features custom-gpu,gpui_platform/macos-wgpu -//! ``` - -#[cfg(any( - target_family = "wasm", - target_os = "linux", - target_os = "freebsd", - target_os = "macos" -))] -mod custom_gpu { - use std::borrow::Cow; - - use gpui::{ - App, AppContext, Bounds, Context, ParentElement, Render, Styled, TitlebarOptions, Window, - WindowBounds, WindowOptions, div, px, size, - }; - use gpui_ce_wgpu::{WgpuContextHandle, WgpuRenderTarget}; - use gpui_platform::application; - use wgpu::util::DeviceExt; - - const SHADER: &str = r#" -struct Uniforms { - time: f32, - aspect: f32, - pad: vec2, -}; - -@group(0) @binding(0) -var uniforms: Uniforms; - -struct VertexInput { - @location(0) position: vec2, - @location(1) color: vec3, -}; - -struct VertexOutput { - @builtin(position) position: vec4, - @location(0) color: vec3, -}; - -@vertex -fn vs_main(input: VertexInput) -> VertexOutput { - let angle = uniforms.time; - let rotation = mat2x2( - cos(angle), -sin(angle), - sin(angle), cos(angle), - ); - let position = rotation * vec2(input.position.x / uniforms.aspect, input.position.y); - - var output: VertexOutput; - output.position = vec4(position, 0.0, 1.0); - output.color = input.color; - return output; -} - -@fragment -fn fs_main(input: VertexOutput) -> @location(0) vec4 { - return vec4(input.color, 1.0); -} -"#; - - #[repr(C)] - #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] - struct Uniforms { - time: f32, - aspect: f32, - _pad: [f32; 2], - } - - #[repr(C)] - #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] - struct Vertex { - position: [f32; 2], - color: [f32; 3], - } - - const VERTICES: &[Vertex] = &[ - Vertex { - position: [0.0, 0.75], - color: [1.0, 0.2, 0.2], - }, - Vertex { - position: [-0.75, -0.5], - color: [0.2, 1.0, 0.2], - }, - Vertex { - position: [0.75, -0.5], - color: [0.2, 0.4, 1.0], - }, - ]; - - struct CustomGpuControl { - context: Option, - target: Option, - pipeline: Option, - bind_group: Option, - uniform_buffer: Option, - vertex_buffer: Option, - time: f32, - } - - impl CustomGpuControl { - fn new(_window: &mut Window, _cx: &mut Context) -> Self { - Self { - context: None, - target: None, - pipeline: None, - bind_group: None, - uniform_buffer: None, - vertex_buffer: None, - time: 0.0, - } - } - - fn clear_gpu_resources(&mut self) { - self.context = None; - self.target = None; - self.pipeline = None; - self.bind_group = None; - self.uniform_buffer = None; - self.vertex_buffer = None; - } - - fn ensure_gpu_resources(&mut self, window: &Window, context: &WgpuContextHandle) { - let viewport = window.viewport_size(); - let scale_factor = window.scale_factor(); - let target_size = size( - gpui::DevicePixels((f32::from(viewport.width) * scale_factor).round() as i32), - gpui::DevicePixels((f32::from(viewport.height) * scale_factor).round() as i32), - ); - let target_size = size( - target_size.width.max(gpui::DevicePixels(1)), - target_size.height.max(gpui::DevicePixels(1)), - ); - - if self - .target - .as_ref() - .is_none_or(|target| target.size() != target_size) - { - self.target = Some(WgpuRenderTarget::new(context, target_size)); - } - - if self.pipeline.is_some() { - return; - } - - let device = context.device(); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("custom-gpu-shader"), - source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SHADER)), - }); - let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("custom-gpu-uniforms"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("custom-gpu-bind-group-layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }], - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("custom-gpu-bind-group"), - layout: &bind_group_layout, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: uniform_buffer.as_entire_binding(), - }], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("custom-gpu-pipeline-layout"), - bind_group_layouts: &[Some(&bind_group_layout)], - immediate_size: 0, - }); - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("custom-gpu-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - compilation_options: Default::default(), - buffers: &[wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[ - wgpu::VertexAttribute { - format: wgpu::VertexFormat::Float32x2, - offset: 0, - shader_location: 0, - }, - wgpu::VertexAttribute { - format: wgpu::VertexFormat::Float32x3, - offset: std::mem::size_of::<[f32; 2]>() as u64, - shader_location: 1, - }, - ], - }], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { - format: context.texture_format(), - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }); - let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("custom-gpu-vertices"), - contents: bytemuck::cast_slice(VERTICES), - usage: wgpu::BufferUsages::VERTEX, - }); - - self.pipeline = Some(pipeline); - self.bind_group = Some(bind_group); - self.uniform_buffer = Some(uniform_buffer); - self.vertex_buffer = Some(vertex_buffer); - } - - fn render_gpu(&mut self, window: &mut Window) { - let Some(context) = WgpuContextHandle::from_window(window) else { - self.clear_gpu_resources(); - return; - }; - if self - .context - .as_ref() - .is_some_and(|previous| !context.is_same_device(previous)) - { - self.clear_gpu_resources(); - } - if context.device_lost() { - self.clear_gpu_resources(); - return; - } - self.context = Some(context.clone()); - - self.ensure_gpu_resources(window, &context); - self.time += 0.02; - let target = self.target.as_ref().unwrap(); - let uniform_buffer = self.uniform_buffer.as_ref().unwrap(); - let pipeline = self.pipeline.as_ref().unwrap(); - let bind_group = self.bind_group.as_ref().unwrap(); - let vertex_buffer = self.vertex_buffer.as_ref().unwrap(); - let aspect = target.size().width.0 as f32 / target.size().height.0 as f32; - context.queue().write_buffer( - uniform_buffer, - 0, - bytemuck::bytes_of(&Uniforms { - time: self.time, - aspect, - _pad: [0.0; 2], - }), - ); - - let mut encoder = - context - .device() - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("custom-gpu-encoder"), - }); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("custom-gpu-pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target.view(), - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - ..Default::default() - }); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, bind_group, &[]); - pass.set_vertex_buffer(0, vertex_buffer.slice(..)); - pass.draw(0..VERTICES.len() as u32, 0..1); - } - context.queue().submit([encoder.finish()]); - } - } - - impl Render for CustomGpuControl { - fn render( - &mut self, - window: &mut Window, - _cx: &mut Context, - ) -> impl gpui::IntoElement { - window.request_animation_frame(); - self.render_gpu(window); - let mut root = div().size_full(); - if let Some(target) = self.target.as_ref() { - root = root.child(target.surface()); - } - root - } - } - - pub fn run() { - env_logger::init(); - application().run(|cx: &mut App| { - cx.open_window( - WindowOptions::new() - .titlebar(Some(TitlebarOptions { - title: Some("Custom GPU rendering".into()), - ..Default::default() - })) - .window_bounds(Some(WindowBounds::Windowed(Bounds::centered( - None, - size(px(800.0), px(600.0)), - cx, - )))), - |window, cx| cx.new(|cx| CustomGpuControl::new(window, cx)), - ) - .unwrap(); - cx.activate(true); - }); - } -} - -#[cfg(any( - target_family = "wasm", - target_os = "linux", - target_os = "freebsd", - target_os = "macos" -))] -fn main() { - custom_gpu::run(); -} - -#[cfg(not(any( - target_family = "wasm", - target_os = "linux", - target_os = "freebsd", - target_os = "macos" -)))] -fn main() { - eprintln!("custom_gpu is supported on Linux, FreeBSD, macOS, and WASM targets only"); -} diff --git a/vendor/gpui-wgpu/src/gpui_wgpu.rs b/vendor/gpui-wgpu/src/gpui_wgpu.rs deleted file mode 100644 index cd8bd6a12..000000000 --- a/vendor/gpui-wgpu/src/gpui_wgpu.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod wgpu_atlas; -mod wgpu_context; -mod wgpu_renderer; - -#[cfg(all(test, not(target_family = "wasm")))] -mod test_gpu; - -pub use wgpu; -pub use wgpu_atlas::*; -pub use wgpu_context::*; -#[cfg(all(feature = "test-support", not(target_family = "wasm")))] -pub use wgpu_renderer::WgpuHeadlessRenderer; -pub use wgpu_renderer::{ - FontRasterizationSettings, GpuContext, SubpixelOrder, WgpuRenderer, WgpuSurfaceConfig, -}; - -pub use wgpu_renderer::RecoveryPending; diff --git a/vendor/gpui-wgpu/src/test_gpu.rs b/vendor/gpui-wgpu/src/test_gpu.rs deleted file mode 100644 index 7d17b0dda..000000000 --- a/vendor/gpui-wgpu/src/test_gpu.rs +++ /dev/null @@ -1,15 +0,0 @@ -/// Keep independent DX12 test devices from overlapping creation and teardown -/// on Windows. CPU tests still run in parallel, and runtime contexts are unchanged. -pub(crate) fn guard() -> Option> { - #[cfg(target_os = "windows")] - { - static GPU_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - Some( - GPU_TESTS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ) - } - #[cfg(not(target_os = "windows"))] - None -} diff --git a/vendor/gpui-wgpu/src/wgpu_atlas.rs b/vendor/gpui-wgpu/src/wgpu_atlas.rs deleted file mode 100644 index 8b8050002..000000000 --- a/vendor/gpui-wgpu/src/wgpu_atlas.rs +++ /dev/null @@ -1,622 +0,0 @@ -use anyhow::{Context as _, Result}; -use collections::FxHashMap; -use etagere::{BucketedAtlasAllocator, size2}; -use gpui::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, - GlyphAtlasCache, GlyphAtlasEntry, PlatformAtlas, Point, RenderGlyphParams, Size, - ValidatedRasterizedGlyph, -}; -use parking_lot::Mutex; -use std::{borrow::Cow, ops, sync::Arc}; - -use crate::WgpuContext; - -fn device_size_to_etagere(size: Size) -> etagere::Size { - size2(size.width.0, size.height.0) -} - -fn etagere_point_to_device(point: etagere::Point) -> Point { - Point { - x: DevicePixels(point.x), - y: DevicePixels(point.y), - } -} - -pub struct WgpuAtlas(Mutex); - -/// Identity of a backing atlas texture, distinct from its reusable slot ID. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct WgpuTextureIdentity(u64); - -struct PendingUpload { - id: AtlasTextureId, - bounds: Bounds, - data: Vec, -} - -struct WgpuAtlasState { - device: Arc, - queue: Arc, - max_texture_size: u32, - color_texture_format: wgpu::TextureFormat, - storage: WgpuAtlasStorage, - tiles_by_key: FxHashMap, - glyph_cache: GlyphAtlasCache, - pending_uploads: Vec, - next_texture_identity: u64, - generation: u64, -} - -pub struct WgpuTextureInfo { - pub view: wgpu::TextureView, - /// Distinguishes backing textures even when their AtlasTextureId slot is reused. - pub identity: WgpuTextureIdentity, -} - -impl WgpuAtlas { - pub fn new( - device: Arc, - queue: Arc, - color_texture_format: wgpu::TextureFormat, - ) -> Self { - let max_texture_size = device.limits().max_texture_dimension_2d; - WgpuAtlas(Mutex::new(WgpuAtlasState { - device, - queue, - max_texture_size, - color_texture_format, - storage: WgpuAtlasStorage::default(), - tiles_by_key: Default::default(), - glyph_cache: Default::default(), - pending_uploads: Vec::new(), - next_texture_identity: 0, - generation: 0, - })) - } - - pub fn from_context(context: &WgpuContext) -> Self { - Self::new( - context.device.clone(), - context.queue.clone(), - context.color_texture_format(), - ) - } - - pub fn before_frame(&self) { - let mut lock = self.0.lock(); - lock.flush_uploads(); - } - - pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { - let lock = self.0.lock(); - let texture = &lock.storage[id]; - WgpuTextureInfo { - view: texture.view.clone(), - identity: texture.identity, - } - } - - /// Clears all cached textures and tiles, forcing them to be recreated. - /// Use this for incremental recovery when the device is still valid. - pub fn clear(&self) { - let mut lock = self.0.lock(); - lock.storage = WgpuAtlasStorage::default(); - lock.tiles_by_key.clear(); - lock.glyph_cache.clear(); - lock.pending_uploads.clear(); - lock.generation = lock.generation.wrapping_add(1); - } - - /// Handles device lost by clearing all textures and cached tiles. - /// The atlas will lazily recreate textures as needed on subsequent frames. - pub fn handle_device_lost(&self, context: &WgpuContext) { - let mut lock = self.0.lock(); - lock.device = context.device.clone(); - lock.queue = context.queue.clone(); - lock.color_texture_format = context.color_texture_format(); - lock.storage = WgpuAtlasStorage::default(); - lock.tiles_by_key.clear(); - lock.glyph_cache.clear(); - lock.pending_uploads.clear(); - lock.generation = lock.generation.wrapping_add(1); - } -} - -impl PlatformAtlas for WgpuAtlas { - fn get_or_insert_with<'a>( - &self, - key: &AtlasKey, - build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, - ) -> Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - return Ok(Some(*tile)); - } - - profiling::scope!("new tile"); - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - key.texture_kind().validate_upload(size, &bytes)?; - let tile = lock.insert_tile(key.clone(), size, &bytes)?; - - Ok(Some(tile)) - } - - fn get_or_insert_glyph_with( - &self, - params: &RenderGlyphParams, - build: &mut dyn FnMut() -> Result, - ) -> Result { - let mut lock = self.0.lock(); - if let Some(entry) = lock.glyph_cache.get(params) { - return Ok(entry); - } - - let glyph = build()?; - let tile = if glyph.size == Size::default() { - None - } else { - let key = AtlasKey::from((params.clone(), glyph.format)); - Some(lock.insert_tile(key, glyph.size, &glyph.pixels)?) - }; - Ok(lock.glyph_cache.insert(params, &glyph, tile)) - } - - fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); - - lock.glyph_cache.remove(key); - - let Some(tile) = lock.tiles_by_key.remove(key) else { - return; - }; - let id = tile.texture_id; - - let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { - return; - }; - - if let Some(mut texture) = texture_slot.take() { - texture.allocator.deallocate(tile.tile_id.into()); - texture.decrement_ref_count(); - if texture.is_unreferenced() { - lock.pending_uploads - .retain(|upload| upload.id != texture.id); - lock.storage[id.kind] - .free_list - .push(texture.id.index as usize); - } else { - *texture_slot = Some(texture); - } - lock.generation = lock.generation.wrapping_add(1); - } - } - - fn generation(&self) -> u64 { - self.0.lock().generation - } -} - -impl WgpuAtlasState { - fn insert_tile( - &mut self, - key: AtlasKey, - size: Size, - bytes: &[u8], - ) -> Result { - anyhow::ensure!( - size.width.0 as u32 <= self.max_texture_size - && size.height.0 as u32 <= self.max_texture_size, - "atlas tile {size:?} exceeds the device texture limit {}", - self.max_texture_size - ); - let tile = self - .allocate(size, key.texture_kind()) - .context("failed to allocate")?; - self.upload_texture(tile.texture_id, tile.bounds, bytes); - self.tiles_by_key.insert(key, tile); - - Ok(tile) - } - - fn allocate( - &mut self, - size: Size, - texture_kind: AtlasTextureKind, - ) -> Option { - { - let textures = &mut self.storage[texture_kind]; - - if let Some(tile) = textures - .iter_mut() - .rev() - .find_map(|texture| texture.allocate(size)) - { - return Some(tile); - } - } - - let texture = self.push_texture(size, texture_kind); - texture.allocate(size) - } - - fn push_texture( - &mut self, - min_size: Size, - kind: AtlasTextureKind, - ) -> &mut WgpuAtlasTexture { - let identity = WgpuTextureIdentity(self.next_texture_identity); - self.next_texture_identity = self.next_texture_identity.wrapping_add(1); - const DEFAULT_ATLAS_SIZE: Size = Size { - width: DevicePixels(1024), - height: DevicePixels(1024), - }; - let max_texture_size = self.max_texture_size as i32; - let max_atlas_size = Size { - width: DevicePixels(max_texture_size), - height: DevicePixels(max_texture_size), - }; - - let size = min_size.max(&DEFAULT_ATLAS_SIZE).min(&max_atlas_size); - let format = match kind { - AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, - AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, - }; - - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("atlas"), - size: wgpu::Extent3d { - width: size.width.0 as u32, - height: size.height.0 as u32, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - let texture_list = &mut self.storage[kind]; - let index = texture_list.free_list.pop(); - - let atlas_texture = WgpuAtlasTexture { - id: AtlasTextureId { - index: index.unwrap_or(texture_list.textures.len()) as u32, - kind, - }, - allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), - format, - texture, - view, - identity, - live_atlas_keys: 0, - }; - - if let Some(ix) = index { - texture_list.textures[ix] = Some(atlas_texture); - texture_list - .textures - .get_mut(ix) - .and_then(|t| t.as_mut()) - .expect("texture must exist") - } else { - texture_list.textures.push(Some(atlas_texture)); - texture_list - .textures - .last_mut() - .and_then(|t| t.as_mut()) - .expect("texture must exist") - } - } - - fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { - let data = self - .storage - .get(id) - .map(|texture| swizzle_upload_data(bytes, texture.format)) - .unwrap_or_else(|| bytes.to_vec()); - - self.pending_uploads - .push(PendingUpload { id, bounds, data }); - } - - fn flush_uploads(&mut self) { - for upload in self.pending_uploads.drain(..) { - let Some(texture) = self.storage.get(upload.id) else { - continue; - }; - let bytes_per_pixel = texture.bytes_per_pixel(); - - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture.texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: upload.bounds.origin.x.0 as u32, - y: upload.bounds.origin.y.0 as u32, - z: 0, - }, - aspect: wgpu::TextureAspect::All, - }, - &upload.data, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(upload.bounds.size.width.0 as u32 * bytes_per_pixel as u32), - rows_per_image: None, - }, - wgpu::Extent3d { - width: upload.bounds.size.width.0 as u32, - height: upload.bounds.size.height.0 as u32, - depth_or_array_layers: 1, - }, - ); - } - } -} - -#[derive(Default)] -struct WgpuAtlasStorage { - monochrome_textures: AtlasTextureList, - subpixel_textures: AtlasTextureList, - polychrome_textures: AtlasTextureList, -} - -impl ops::Index for WgpuAtlasStorage { - type Output = AtlasTextureList; - fn index(&self, kind: AtlasTextureKind) -> &Self::Output { - match kind { - AtlasTextureKind::Monochrome => &self.monochrome_textures, - AtlasTextureKind::Subpixel => &self.subpixel_textures, - AtlasTextureKind::Polychrome => &self.polychrome_textures, - } - } -} - -impl ops::IndexMut for WgpuAtlasStorage { - fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { - match kind { - AtlasTextureKind::Monochrome => &mut self.monochrome_textures, - AtlasTextureKind::Subpixel => &mut self.subpixel_textures, - AtlasTextureKind::Polychrome => &mut self.polychrome_textures, - } - } -} - -impl WgpuAtlasStorage { - fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> { - self[id.kind] - .textures - .get(id.index as usize) - .and_then(|t| t.as_ref()) - } -} - -impl ops::Index for WgpuAtlasStorage { - type Output = WgpuAtlasTexture; - - fn index(&self, id: AtlasTextureId) -> &Self::Output { - self.get(id).expect("texture must exist") - } -} - -struct WgpuAtlasTexture { - id: AtlasTextureId, - allocator: BucketedAtlasAllocator, - texture: wgpu::Texture, - view: wgpu::TextureView, - identity: WgpuTextureIdentity, - format: wgpu::TextureFormat, - live_atlas_keys: u32, -} - -impl WgpuAtlasTexture { - fn allocate(&mut self, size: Size) -> Option { - let allocation = self.allocator.allocate(device_size_to_etagere(size))?; - let tile = AtlasTile { - texture_id: self.id, - tile_id: allocation.id.into(), - padding: 0, - bounds: Bounds { - origin: etagere_point_to_device(allocation.rectangle.min), - size, - }, - }; - self.live_atlas_keys += 1; - Some(tile) - } - - fn bytes_per_pixel(&self) -> u8 { - match self.format { - wgpu::TextureFormat::R8Unorm => 1, - wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm => 4, - _ => 4, - } - } - - fn decrement_ref_count(&mut self) { - self.live_atlas_keys -= 1; - } - - fn is_unreferenced(&self) -> bool { - self.live_atlas_keys == 0 - } -} - -fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { - match format { - wgpu::TextureFormat::Rgba8Unorm => { - let mut data = bytes.to_vec(); - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - data - } - _ => bytes.to_vec(), - } -} - -#[cfg(all(test, not(target_family = "wasm")))] -mod tests { - use super::*; - use gpui::block_on; - use gpui::{ImageId, RenderImageParams}; - use std::sync::Arc; - - fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { - block_on(async { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), - flags: wgpu::InstanceFlags::default(), - backend_options: wgpu::BackendOptions::default(), - memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), - display: None, - }); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::LowPower, - compatible_surface: None, - force_fallback_adapter: false, - }) - .await - .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("wgpu_atlas_test_device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults() - .using_resolution(adapter.limits()) - .using_alignment(adapter.limits()), - memory_hints: wgpu::MemoryHints::MemoryUsage, - trace: wgpu::Trace::Off, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - }) - .await - .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; - Ok((Arc::new(device), Arc::new(queue))) - }) - } - - #[test] - fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> { - let (device, queue) = test_device_and_queue()?; - - let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); - let key = AtlasKey::Image(RenderImageParams { - image_id: ImageId(1), - frame_index: 0, - }); - let size = Size { - width: DevicePixels(1), - height: DevicePixels(1), - }; - let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))); - - // Regression test: before the fix, this panicked in flush_uploads - atlas - .get_or_insert_with(&key, &mut build)? - .expect("tile should be created"); - atlas.remove(&key); - atlas.before_frame(); - Ok(()) - } - - #[test] - fn remove_deallocates_tile_space_for_reuse() -> anyhow::Result<()> { - let (device, queue) = test_device_and_queue()?; - let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); - - let small = Size { - width: DevicePixels(64), - height: DevicePixels(64), - }; - let big = Size { - width: DevicePixels(700), - height: DevicePixels(700), - }; - - let make_key = |image_id: usize| { - AtlasKey::Image(RenderImageParams { - image_id: ImageId(image_id), - frame_index: 0, - }) - }; - let insert = |key: &AtlasKey, size: Size| { - let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; - atlas - .get_or_insert_with(key, &mut || { - Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) - }) - .expect("allocation should succeed") - .expect("callback returns Some") - }; - - let keeper_key = make_key(1); - let big_key_a = make_key(2); - let big_key_b = make_key(3); - - let keeper_tile = insert(&keeper_key, small); - let tile_a = insert(&big_key_a, big); - assert_eq!(keeper_tile.texture_id, tile_a.texture_id); - - atlas.remove(&big_key_a); - let tile_b = insert(&big_key_b, big); - assert_eq!(tile_b.texture_id, keeper_tile.texture_id); - Ok(()) - } - - #[test] - fn reusing_an_atlas_texture_slot_changes_its_identity() -> anyhow::Result<()> { - let (device, queue) = test_device_and_queue()?; - let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); - let size = Size { - width: DevicePixels(1), - height: DevicePixels(1), - }; - let key = |image_id| { - AtlasKey::Image(RenderImageParams { - image_id: ImageId(image_id), - frame_index: 0, - }) - }; - let insert = |key: &AtlasKey| { - atlas - .get_or_insert_with(key, &mut || { - Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))) - }) - .expect("allocation should succeed") - .expect("callback returns Some") - }; - - let first = insert(&key(1)); - let identity = atlas.get_texture_info(first.texture_id).identity; - atlas.remove(&key(1)); - let second = insert(&key(2)); - - assert_eq!(first.texture_id, second.texture_id); - assert_ne!(atlas.get_texture_info(second.texture_id).identity, identity); - Ok(()) - } - - #[test] - fn swizzle_upload_data_preserves_bgra_uploads() { - let input = vec![0x10, 0x20, 0x30, 0x40]; - assert_eq!( - swizzle_upload_data(&input, wgpu::TextureFormat::Bgra8Unorm), - input - ); - } - - #[test] - fn swizzle_upload_data_converts_bgra_to_rgba() { - let input = vec![0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD]; - assert_eq!( - swizzle_upload_data(&input, wgpu::TextureFormat::Rgba8Unorm), - vec![0x30, 0x20, 0x10, 0x40, 0xCC, 0xBB, 0xAA, 0xDD] - ); - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_context.rs b/vendor/gpui-wgpu/src/wgpu_context.rs deleted file mode 100644 index 110a7633f..000000000 --- a/vendor/gpui-wgpu/src/wgpu_context.rs +++ /dev/null @@ -1,1123 +0,0 @@ -#[cfg(not(target_family = "wasm"))] -use anyhow::Context as _; -#[cfg(not(target_family = "wasm"))] -use gpui_util::ResultExt; -#[cfg(not(target_family = "wasm"))] -use smallvec::SmallVec; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use wgpu::TextureFormat; - -/// Environment variable that forces the HWND swapchain path on Windows. -pub const DISABLE_DIRECT_COMPOSITION_ENV: &str = "GPUI_DISABLE_DIRECT_COMPOSITION"; - -/// A single native graphics API that can back a [`WgpuContext`]. -/// -/// Keeping this as an enum, rather than passing a [`wgpu::Backends`] bit-set through the native -/// initialization path, makes it impossible for the renderer to accidentally initialize a -/// fallback API alongside the preferred one. In particular, constructing WGPU's GL backend -/// creates and retains EGL state even when a Vulkan adapter is eventually selected. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -#[cfg(not(target_family = "wasm"))] -pub(crate) enum NativeBackend { - #[cfg(target_vendor = "apple")] - Metal, - #[cfg(target_os = "windows")] - Dx12, - #[cfg(not(target_vendor = "apple"))] - Vulkan, - #[cfg(not(target_vendor = "apple"))] - Gl, -} - -/// A WGPU instance paired with the single native API it was created to load. -/// -/// Keeping the backend token attached prevents later initialization stages from -/// accidentally widening adapter discovery back to multiple APIs. -#[cfg(not(target_family = "wasm"))] -pub(crate) struct NativeInstance { - pub(crate) backend: NativeBackend, - pub(crate) raw: wgpu::Instance, -} - -#[cfg(not(target_family = "wasm"))] -impl NativeBackend { - #[cfg(target_vendor = "apple")] - const PREFERENCE: &'static [Self] = &[Self::Metal]; - #[cfg(target_os = "windows")] - const PREFERENCE: &'static [Self] = &[Self::Dx12, Self::Vulkan, Self::Gl]; - #[cfg(not(any(target_vendor = "apple", target_os = "windows")))] - const PREFERENCE: &'static [Self] = &[Self::Vulkan, Self::Gl]; - - pub(crate) fn instance( - self, - display: Option>, - ) -> NativeInstance { - #[cfg(not(target_os = "windows"))] - let backend_options = wgpu::BackendOptions::default(); - #[cfg(target_os = "windows")] - let backend_options = match self { - Self::Dx12 => { - let mut options = wgpu::BackendOptions::default(); - let direct_composition_disabled = std::env::var(DISABLE_DIRECT_COMPOSITION_ENV) - .is_ok_and(|value| value == "true" || value == "1"); - options.dx12.presentation_system = if direct_composition_disabled { - wgpu::Dx12SwapchainKind::DxgiFromHwnd - } else { - wgpu::Dx12SwapchainKind::DxgiFromVisual - }; - options - } - Self::Vulkan | Self::Gl => wgpu::BackendOptions::default(), - }; - - NativeInstance { - backend: self, - raw: wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: self.into(), - flags: wgpu::InstanceFlags::default(), - backend_options, - memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), - display, - }), - } - } - - pub(crate) fn try_in_preference_order( - operation: &'static str, - mut attempt: impl FnMut(Self) -> anyhow::Result, - ) -> anyhow::Result { - let mut failures = SmallVec::<[NativeBackendFailure; 3]>::new(); - for &backend in Self::PREFERENCE { - match attempt(backend) { - Ok(value) => return Ok(value), - Err(source) => failures.push(NativeBackendFailure { backend, source }), - } - } - Err(NativeBackendFallbackError { - operation, - failures, - } - .into()) - } -} - -#[cfg(not(target_family = "wasm"))] -impl From for wgpu::Backends { - fn from(backend: NativeBackend) -> Self { - match backend { - #[cfg(target_vendor = "apple")] - NativeBackend::Metal => Self::METAL, - #[cfg(target_os = "windows")] - NativeBackend::Dx12 => Self::DX12, - #[cfg(not(target_vendor = "apple"))] - NativeBackend::Vulkan => Self::VULKAN, - #[cfg(not(target_vendor = "apple"))] - NativeBackend::Gl => Self::GL, - } - } -} - -#[cfg(not(target_family = "wasm"))] -#[derive(Debug)] -struct NativeBackendFailure { - backend: NativeBackend, - source: anyhow::Error, -} - -#[cfg(not(target_family = "wasm"))] -#[derive(Debug)] -struct NativeBackendFallbackError { - operation: &'static str, - failures: SmallVec<[NativeBackendFailure; 3]>, -} - -#[cfg(not(target_family = "wasm"))] -impl std::fmt::Display for NativeBackendFallbackError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "failed to initialize {}; attempted backends in order: ", - self.operation - )?; - for (index, failure) in self.failures.iter().enumerate() { - if index > 0 { - formatter.write_str("; ")?; - } - write!(formatter, "{:?}: {:#}", failure.backend, failure.source)?; - } - Ok(()) - } -} - -#[cfg(not(target_family = "wasm"))] -impl std::error::Error for NativeBackendFallbackError {} - -#[cfg(not(target_family = "wasm"))] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum SoftwareAdapterPolicy { - Allow, - Reject, -} - -#[cfg(not(target_family = "wasm"))] -impl SoftwareAdapterPolicy { - fn accepts(self, device_type: wgpu::DeviceType) -> bool { - self == Self::Allow || device_type != wgpu::DeviceType::Cpu - } -} - -struct CreatedDevice { - device: wgpu::Device, - queue: wgpu::Queue, - dual_source_blending: bool, - color_texture_format: TextureFormat, - renderer_tier: RendererTier, -} - -#[cfg(not(target_family = "wasm"))] -struct SelectedAdapter { - adapter: wgpu::Adapter, - device: CreatedDevice, -} - -pub struct WgpuContext { - pub instance: wgpu::Instance, - pub adapter: wgpu::Adapter, - pub device: Arc, - pub queue: Arc, - backend: WgpuBackend, - dual_source_blending: bool, - color_texture_format: wgpu::TextureFormat, - renderer_tier: RendererTier, - device_lost: Arc, - uncaptured_error: Arc>>, - fatal_error: Arc>>, -} - -/// The resource transport selected for a device. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum RendererTier { - /// WebGPU-class devices use storage buffers for packed scene data. - Modern, - /// GLES 3.0 and WebGL2 use a build-generated data-texture transport. - WebGl2, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum WgpuBackend { - BrowserWebGpu, - Gl, - Native(wgpu::Backend), -} - -#[cfg(target_family = "wasm")] -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum WebBackendPreference { - #[default] - Auto, - WebGpu, - WebGl, -} - -#[cfg(target_family = "wasm")] -pub struct PreparedWebGraphics { - pub context: WgpuContext, - pub surface: wgpu::Surface<'static>, -} - -/// wgpu-core refuses to create a surface when neither the instance nor the surface -/// target carries a display handle, and `SurfaceTarget::Canvas` always passes `None`. -/// The WebGL2 backend never reads the handle (WebGPU bypasses wgpu-core entirely), so -/// a unit web display handle on the instance satisfies the check. -#[cfg(target_family = "wasm")] -#[derive(Debug)] -struct WebDisplaySource; - -#[cfg(target_family = "wasm")] -impl raw_window_handle::HasDisplayHandle for WebDisplaySource { - fn display_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { - Ok(raw_window_handle::DisplayHandle::web()) - } -} - -#[derive(Clone, Copy)] -pub struct CompositorGpuHint { - pub vendor_id: u32, - pub device_id: u32, -} - -/// A typed view of the GPU resources shared with GPUI. -/// -/// The device and queue are owned by GPUI and remain valid until -/// [`Self::device_lost`] returns `true`. A control should drop its GPU -/// resources and reacquire this handle after recovery. -#[derive(Clone)] -pub struct WgpuContextHandle { - device: Arc, - queue: Arc, - texture_format: wgpu::TextureFormat, - adapter_info: wgpu::AdapterInfo, - backend: WgpuBackend, - features: wgpu::Features, - limits: wgpu::Limits, - device_lost: Arc, -} - -impl WgpuContextHandle { - pub(crate) fn from_resources( - device: Arc, - queue: Arc, - texture_format: wgpu::TextureFormat, - adapter_info: wgpu::AdapterInfo, - backend: WgpuBackend, - device_lost: Arc, - ) -> Self { - let features = device.features(); - let limits = device.limits(); - Self { - device, - queue, - texture_format, - adapter_info, - backend, - features, - limits, - device_lost, - } - } - - /// Returns the shared device. - pub fn device(&self) -> &wgpu::Device { - &self.device - } - - /// Returns the shared queue. - pub fn queue(&self) -> &wgpu::Queue { - &self.queue - } - - /// Returns whether two handles refer to the same device instance. - pub fn is_same_device(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.device, &other.device) - } - - /// Returns the texture format used by GPUI's window surface. - pub fn texture_format(&self) -> wgpu::TextureFormat { - self.texture_format - } - - /// Returns information about the selected adapter. - pub fn adapter_info(&self) -> &wgpu::AdapterInfo { - &self.adapter_info - } - - /// Returns the backend selected for this window. - pub fn backend(&self) -> WgpuBackend { - self.backend - } - - /// Returns the features enabled on the shared device. - pub fn features(&self) -> wgpu::Features { - self.features - } - - /// Returns the limits used when creating the shared device. - pub fn limits(&self) -> &wgpu::Limits { - &self.limits - } - - /// Returns whether the device has been lost and custom resources must be - /// recreated after GPUI recovers. - pub fn device_lost(&self) -> bool { - self.device_lost.load(Ordering::Relaxed) - } - - /// Returns the typed wgpu context associated with a GPUI window. - #[cfg(any( - target_os = "linux", - target_os = "freebsd", - target_os = "windows", - all(target_os = "macos", feature = "custom-gpu"), - all(target_family = "wasm", feature = "custom-gpu") - ))] - pub fn from_window(window: &gpui::Window) -> Option { - window - .gpu_context_info()? - .downcast::() - .ok() - .map(|context| *context) - } -} - -/// A reusable offscreen render target suitable for [`gpui::Window::paint_surface`]. -pub struct WgpuRenderTarget { - texture: Arc, - view: wgpu::TextureView, - size: gpui::Size, - format: wgpu::TextureFormat, -} - -impl WgpuRenderTarget { - /// Creates a render target using GPUI's surface format. - pub fn new(context: &WgpuContextHandle, size: gpui::Size) -> Self { - Self::with_format(context, size, context.texture_format()) - } - - /// Creates a render target with an explicit texture format. - pub fn with_format( - context: &WgpuContextHandle, - size: gpui::Size, - format: wgpu::TextureFormat, - ) -> Self { - let size = normalize_target_size(size); - let texture = create_render_target_texture(context.device(), size, format); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - Self { - texture: Arc::new(texture), - view, - size, - format, - } - } - - /// Resizes the target, preserving its texture format. - pub fn resize(&mut self, context: &WgpuContextHandle, size: gpui::Size) { - let size = normalize_target_size(size); - if self.size == size { - return; - } - let texture = create_render_target_texture(context.device(), size, self.format); - self.view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - self.texture = Arc::new(texture); - self.size = size; - } - - /// Returns the target texture for use as a GPUI surface. - pub fn texture(&self) -> Arc { - Arc::clone(&self.texture) - } - - /// Creates a GPUI element that composites this target at its layout bounds. - #[cfg(any( - target_os = "linux", - target_os = "freebsd", - all(target_os = "macos", feature = "custom-gpu"), - all(target_family = "wasm", feature = "custom-gpu") - ))] - pub fn surface(&self) -> gpui::Surface { - gpui::surface(gpui::SurfaceSource::Texture { - texture: self.texture(), - size: self.size, - }) - } - - /// Returns the target texture view for rendering. - pub fn view(&self) -> &wgpu::TextureView { - &self.view - } - - /// Returns the target dimensions in device pixels. - pub fn size(&self) -> gpui::Size { - self.size - } - - /// Returns the target texture format. - pub fn format(&self) -> wgpu::TextureFormat { - self.format - } -} - -fn normalize_target_size(size: gpui::Size) -> gpui::Size { - gpui::size( - gpui::DevicePixels(size.width.0.max(1)), - gpui::DevicePixels(size.height.0.max(1)), - ) -} - -fn create_render_target_texture( - device: &wgpu::Device, - size: gpui::Size, - format: wgpu::TextureFormat, -) -> wgpu::Texture { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("gpui-custom-render-target"), - size: wgpu::Extent3d { - width: size.width.0 as u32, - height: size.height.0 as u32, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) -} - -/// Extra wgpu features and limits that an application can request on top of -/// gpui's baseline. Pass an instance to the platform via -/// [`gpui::App::set_gpu_requirements`] *before* opening any windows. -#[derive(Clone, Debug, Default)] -pub struct WgpuDeviceRequirements { - /// Additional [`wgpu::Features`] to enable. These are OR-ed with gpui's - /// own required features. - pub features: wgpu::Features, - /// Additional [`wgpu::Limits`] to request. Each field is merged by taking - /// `max(gpui_limit, app_limit)` for upper-bound limits and - /// `min(gpui_limit, app_limit)` for alignment/lower-bound limits. - pub limits: Option, -} - -impl WgpuContext { - pub(crate) fn handle(&self, texture_format: wgpu::TextureFormat) -> WgpuContextHandle { - WgpuContextHandle::from_resources( - Arc::clone(&self.device), - Arc::clone(&self.queue), - texture_format, - self.adapter.get_info(), - self.backend, - Arc::clone(&self.device_lost), - ) - } - - /// Creates a native device without a presentation surface. - #[cfg(not(target_family = "wasm"))] - pub fn new_headless( - extra_requirements: Option<&WgpuDeviceRequirements>, - ) -> anyhow::Result { - NativeBackend::try_in_preference_order("a headless GPU context", |backend| { - let instance = backend.instance(None); - let adapter = - gpui::block_on(instance.raw.request_adapter(&wgpu::RequestAdapterOptions { - // LowPower avoids waking a discrete GPU just for snapshots on dual-GPU - // systems. - power_preference: wgpu::PowerPreference::LowPower, - compatible_surface: None, - force_fallback_adapter: false, - })) - .map_err(|error| { - anyhow::anyhow!("failed to request headless GPU adapter: {error}") - })?; - let device = gpui::block_on(Self::create_device(&adapter, extra_requirements))?; - Self::from_created_device(instance.raw, adapter, device) - }) - } - - #[cfg(not(target_family = "wasm"))] - pub(crate) fn new_with_adapter_policy( - instance: NativeInstance, - surface: &wgpu::Surface<'_>, - compositor_gpu: Option, - adapter_policy: SoftwareAdapterPolicy, - extra_requirements: Option<&WgpuDeviceRequirements>, - ) -> anyhow::Result { - let device_id_filter = match std::env::var("ZED_DEVICE_ID") { - Ok(val) => parse_pci_id(&val) - .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") - .log_err(), - Err(std::env::VarError::NotPresent) => None, - err => { - err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") - .log_err(); - None - } - }; - - // Select an adapter by actually testing surface configuration with the real device. - // This is the only reliable way to determine compatibility on hybrid GPU systems. - let selection = gpui::block_on(Self::select_adapter_and_device( - &instance.raw, - instance.backend, - device_id_filter, - surface, - compositor_gpu.as_ref(), - adapter_policy, - extra_requirements, - ))?; - - Self::from_created_device(instance.raw, selection.adapter, selection.device) - } - - #[cfg(target_family = "wasm")] - pub async fn new_web( - canvas: &web_sys::HtmlCanvasElement, - preference: WebBackendPreference, - ) -> anyhow::Result { - Self::new_web_with_backend(canvas, preference).await - } - - #[cfg(target_family = "wasm")] - #[allow(clippy::arc_with_non_send_sync)] - async fn new_web_with_backend( - canvas: &web_sys::HtmlCanvasElement, - preference: WebBackendPreference, - ) -> anyhow::Result { - let backends = match preference { - WebBackendPreference::Auto => wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL, - WebBackendPreference::WebGpu => wgpu::Backends::BROWSER_WEBGPU, - WebBackendPreference::WebGl => wgpu::Backends::GL, - }; - let descriptor = wgpu::InstanceDescriptor { - backends, - flags: wgpu::InstanceFlags::default(), - backend_options: wgpu::BackendOptions::default(), - memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), - display: Some(Box::new(WebDisplaySource)), - }; - let instance = if preference == WebBackendPreference::Auto { - wgpu::util::new_instance_with_webgpu_detection(descriptor).await - } else { - wgpu::Instance::new(descriptor) - }; - let surface = instance - .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) - .map_err(|error| { - anyhow::anyhow!("Failed to create browser graphics surface: {error}") - })?; - - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - compatible_surface: Some(&surface), - force_fallback_adapter: false, - }) - .await - .map_err(|error| { - anyhow::anyhow!( - "Failed to request a {preference:?} adapter compatible with the canvas: {error}" - ) - })?; - let device = Self::create_device(&adapter, None).await?; - let context = Self::from_created_device(instance, adapter, device)?; - log::info!( - "Browser graphics initialized: requested={preference:?}, selected={backend:?}, \ - adapter={:?}, limits={:?}, dual_source_blending={dual_source_blending}", - context.adapter.get_info().name, - context.device.limits(), - backend = context.backend, - dual_source_blending = context.dual_source_blending, - ); - Ok(PreparedWebGraphics { context, surface }) - } - - fn from_created_device( - instance: wgpu::Instance, - adapter: wgpu::Adapter, - device: CreatedDevice, - ) -> anyhow::Result { - let (device_lost, uncaptured_error, fatal_error) = install_device_callbacks(&device.device); - let info = adapter.get_info(); - log::info!("Selected GPU adapter: {:?} ({:?})", info.name, info.backend); - #[cfg(target_family = "wasm")] - let backend = match info.backend { - wgpu::Backend::BrowserWebGpu => WgpuBackend::BrowserWebGpu, - wgpu::Backend::Gl => WgpuBackend::Gl, - backend => anyhow::bail!( - "Browser graphics initialization selected unexpected backend {backend:?}" - ), - }; - #[cfg(not(target_family = "wasm"))] - let backend = WgpuBackend::Native(info.backend); - - Ok(Self { - instance, - adapter, - backend, - device: Arc::new(device.device), - queue: Arc::new(device.queue), - dual_source_blending: device.dual_source_blending, - color_texture_format: device.color_texture_format, - renderer_tier: device.renderer_tier, - device_lost, - uncaptured_error, - fatal_error, - }) - } - - async fn create_device( - adapter: &wgpu::Adapter, - extra_requirements: Option<&WgpuDeviceRequirements>, - ) -> anyhow::Result { - let renderer_tier = renderer_tier(adapter); - // Our LCD shader uses storage buffers even when the adapter exposes dual-source - // blending. Downlevel devices must use grayscale and the data-texture dialect. - let dual_source_blending = renderer_tier == RendererTier::Modern - && adapter - .features() - .contains(wgpu::Features::DUAL_SOURCE_BLENDING); - - let mut required_features = wgpu::Features::empty(); - if dual_source_blending { - required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; - } else { - log::warn!( - "Dual-source blending not available on this GPU. \ - Subpixel text antialiasing will be disabled." - ); - } - - let color_texture_format = Self::select_color_texture_format(adapter)?; - - let baseline = match renderer_tier { - RendererTier::Modern => wgpu::Limits::downlevel_defaults(), - RendererTier::WebGl2 => wgpu::Limits::downlevel_webgl2_defaults(), - }; - let mut required_limits = baseline - .using_resolution(adapter.limits()) - .using_alignment(adapter.limits()); - - // Merge application-requested requirements. - if let Some(reqs) = extra_requirements { - required_features |= reqs.features; - if let Some(limits) = &reqs.limits { - required_limits = required_limits.or_better_values_from(limits); - } - } - - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("gpui_device"), - required_features, - required_limits, - memory_hints: wgpu::MemoryHints::MemoryUsage, - trace: wgpu::Trace::Off, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; - - Ok(CreatedDevice { - device, - queue, - dual_source_blending, - color_texture_format, - renderer_tier, - }) - } - - pub fn check_compatible_with_surface(&self, surface: &wgpu::Surface<'_>) -> anyhow::Result<()> { - let caps = surface.get_capabilities(&self.adapter); - if caps.formats.is_empty() { - let info = self.adapter.get_info(); - anyhow::bail!( - "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the \ - display surface for this window.", - info.name, - info.backend, - info.device, - ); - } - Ok(()) - } - - /// Select an adapter and create a device, testing that the surface can actually be configured. - /// This is the only reliable way to determine compatibility on hybrid GPU systems, where - /// adapters may report surface compatibility via get_capabilities() but fail when actually - /// configuring (e.g., NVIDIA reporting Vulkan Wayland support but failing because the - /// Wayland compositor runs on the Intel GPU). - #[cfg(not(target_family = "wasm"))] - async fn select_adapter_and_device( - instance: &wgpu::Instance, - backend: NativeBackend, - device_id_filter: Option, - surface: &wgpu::Surface<'_>, - compositor_gpu: Option<&CompositorGpuHint>, - adapter_policy: SoftwareAdapterPolicy, - extra_requirements: Option<&WgpuDeviceRequirements>, - ) -> anyhow::Result { - let mut adapters: Vec<_> = instance.enumerate_adapters(backend.into()).await; - - if adapters.is_empty() { - anyhow::bail!("No GPU adapters found"); - } - - if let Some(device_id) = device_id_filter { - log::info!("ZED_DEVICE_ID filter: {:#06x}", device_id); - } - - // Sort adapters into a single priority order. Tiers (from highest to lowest): - // - // 1. ZED_DEVICE_ID match — explicit user override - // 2. Compositor GPU match — the GPU the display server is rendering on - // 3. Device type (Discrete > Integrated > Other > Virtual > Cpu). - // "Other" ranks above "Virtual" because OpenGL seems to count as "Other". - // 4. Backend — prefer Vulkan/Metal/Dx12 over GL/etc. - adapters.sort_by_key(|adapter| { - let info = adapter.get_info(); - - // Backends like OpenGL report device=0 for all adapters, so - // device-based matching is only meaningful when non-zero. - let device_known = info.device != 0; - - let user_override: u8 = match device_id_filter { - Some(id) if device_known && info.device == id => 0, - _ => 1, - }; - - let compositor_match: u8 = match compositor_gpu { - Some(hint) - if device_known - && info.vendor == hint.vendor_id - && info.device == hint.device_id => - { - 0 - } - _ => 1, - }; - - #[cfg(target_vendor = "apple")] - let type_priority: u8 = match info.device_type { - // Preserves the native renderer's low-power preference on Intel Macs. - wgpu::DeviceType::IntegratedGpu => 0, - wgpu::DeviceType::DiscreteGpu => 1, - wgpu::DeviceType::Other => 2, - wgpu::DeviceType::VirtualGpu => 3, - wgpu::DeviceType::Cpu => 4, - }; - #[cfg(not(target_vendor = "apple"))] - let type_priority: u8 = if info.device_type == wgpu::DeviceType::Cpu { - 4 - } else { - match info.device_type { - wgpu::DeviceType::DiscreteGpu => 0, - wgpu::DeviceType::IntegratedGpu => 1, - wgpu::DeviceType::Other => 2, - wgpu::DeviceType::VirtualGpu => 3, - wgpu::DeviceType::Cpu => 4, - } - }; - - let backend_priority: u8 = match info.backend { - wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, - _ => 1, - }; - - ( - user_override, - compositor_match, - type_priority, - backend_priority, - ) - }); - - // Log all available adapters (in sorted order) - log::info!("Found {} GPU adapter(s):", adapters.len()); - for adapter in &adapters { - let info = adapter.get_info(); - log::info!( - " - {} (vendor={:#06x}, device={:#06x}, backend={:?}, type={:?})", - info.name, - info.vendor, - info.device, - info.backend, - info.device_type, - ); - } - - // Test each adapter by creating a device and configuring the surface - for adapter in adapters { - let info = adapter.get_info(); - - if !adapter_policy.accepts(info.device_type) { - log::info!( - "Skipping software renderer: {} ({:?})", - info.name, - info.backend - ); - continue; - } - - log::info!("Testing adapter: {} ({:?})...", info.name, info.backend); - - match Self::try_adapter_with_surface(&adapter, surface, extra_requirements).await { - Ok(device) => { - log::info!( - "Adapter passed surface configuration test: {} ({:?})", - info.name, - info.backend - ); - return Ok(SelectedAdapter { adapter, device }); - } - Err(e) => { - log::info!( - " Adapter {} ({:?}) failed: {}, trying next...", - info.name, - info.backend, - e - ); - } - } - } - - anyhow::bail!("No GPU adapter found that can configure the display surface") - } - - /// Try to use an adapter with a surface by creating a device and testing configuration. - /// Returns the device and queue if successful, allowing them to be reused. - #[cfg(not(target_family = "wasm"))] - async fn try_adapter_with_surface( - adapter: &wgpu::Adapter, - surface: &wgpu::Surface<'_>, - extra_requirements: Option<&WgpuDeviceRequirements>, - ) -> anyhow::Result { - let caps = surface.get_capabilities(adapter); - if caps.formats.is_empty() { - anyhow::bail!("no compatible surface formats"); - } - if caps.alpha_modes.is_empty() { - anyhow::bail!("no compatible alpha modes"); - } - - let device = Self::create_device(adapter, extra_requirements).await?; - let error_scope = device - .device - .push_error_scope(wgpu::ErrorFilter::Validation); - - let test_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: caps.formats[0], - width: 64, - height: 64, - present_mode: wgpu::PresentMode::Fifo, - desired_maximum_frame_latency: 2, - alpha_mode: caps.alpha_modes[0], - view_formats: vec![], - }; - - surface.configure(&device.device, &test_config); - - let error = error_scope.pop().await; - if let Some(e) = error { - anyhow::bail!("surface configuration failed: {e}"); - } - - Ok(device) - } - - fn select_color_texture_format(adapter: &wgpu::Adapter) -> anyhow::Result { - let required_usages = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; - let bgra_features = adapter.get_texture_format_features(wgpu::TextureFormat::Bgra8Unorm); - let rgba_features = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba8Unorm); - #[cfg(target_family = "wasm")] - if adapter.get_info().backend == wgpu::Backend::Gl - && rgba_features.allowed_usages.contains(required_usages) - { - return Ok(wgpu::TextureFormat::Rgba8Unorm); - } - if bgra_features.allowed_usages.contains(required_usages) { - return Ok(wgpu::TextureFormat::Bgra8Unorm); - } - if rgba_features.allowed_usages.contains(required_usages) { - let info = adapter.get_info(); - log::warn!( - "Adapter {} ({:?}) does not support Bgra8Unorm atlas textures with usages {:?}; \ - falling back to Rgba8Unorm atlas textures.", - info.name, - info.backend, - required_usages, - ); - return Ok(wgpu::TextureFormat::Rgba8Unorm); - } - - let info = adapter.get_info(); - Err(anyhow::anyhow!( - "Adapter {} ({:?}, device={:#06x}) does not support a usable color atlas texture \ - format with usages {:?}. Bgra8Unorm allowed usages: {:?}; \ - Rgba8Unorm allowed usages: {:?}.", - info.name, - info.backend, - info.device, - required_usages, - bgra_features.allowed_usages, - rgba_features.allowed_usages, - )) - } - pub fn backend(&self) -> WgpuBackend { - self.backend - } - - pub fn uses_webgl_instance_data(&self) -> bool { - matches!(self.backend, WgpuBackend::Gl) && cfg!(target_family = "wasm") - } - - pub fn supports_dual_source_blending(&self) -> bool { - self.dual_source_blending - } - - pub fn color_texture_format(&self) -> wgpu::TextureFormat { - self.color_texture_format - } - - pub fn renderer_tier(&self) -> RendererTier { - self.renderer_tier - } - - /// Returns true if the GPU device was lost (driver crash, suspend/resume). - /// When this returns true, the context should be recreated. - pub fn device_lost(&self) -> bool { - self.device_lost.load(Ordering::Relaxed) - } - - /// Returns a clone of the device_lost flag for sharing with renderers. - pub(crate) fn device_lost_flag(&self) -> Arc { - Arc::clone(&self.device_lost) - } - - pub(crate) fn fatal_error_slot(&self) -> Arc>> { - Arc::clone(&self.fatal_error) - } - - pub(crate) fn uncaptured_error_slot(&self) -> Arc>> { - Arc::clone(&self.uncaptured_error) - } -} - -fn renderer_tier(adapter: &wgpu::Adapter) -> RendererTier { - let limits = adapter.limits(); - let flags = adapter.get_downlevel_capabilities().flags; - if limits.max_storage_buffers_per_shader_stage > 0 - && flags.contains(wgpu::DownlevelFlags::VERTEX_STORAGE) - && flags.contains(wgpu::DownlevelFlags::FRAGMENT_STORAGE) - { - RendererTier::Modern - } else { - RendererTier::WebGl2 - } -} - -fn install_device_callbacks( - device: &wgpu::Device, -) -> ( - Arc, - Arc>>, - Arc>>, -) { - let device_lost = Arc::new(AtomicBool::new(false)); - device.set_device_lost_callback({ - let device_lost = Arc::clone(&device_lost); - move |reason, message| { - log::error!("wgpu device lost: reason={reason:?}, message={message}"); - if reason != wgpu::DeviceLostReason::Destroyed { - device_lost.store(true, Ordering::Relaxed); - } - } - }); - let uncaptured_error = Arc::new(Mutex::new(None)); - let fatal_error = Arc::new(Mutex::new(None)); - device.on_uncaptured_error(Arc::new({ - let uncaptured_error = Arc::clone(&uncaptured_error); - let fatal_error = Arc::clone(&fatal_error); - move |error| { - let message = error.to_string(); - log::error!("uncaptured wgpu error: {message}"); - if matches!( - error, - wgpu::Error::OutOfMemory { .. } | wgpu::Error::Internal { .. } - ) { - *fatal_error.lock().unwrap() = Some(message.clone()); - } - *uncaptured_error.lock().unwrap() = Some(message); - } - })); - (device_lost, uncaptured_error, fatal_error) -} - -#[cfg(not(target_family = "wasm"))] -fn parse_pci_id(id: &str) -> anyhow::Result { - let mut id = id.trim(); - - if id.starts_with("0x") || id.starts_with("0X") { - id = &id[2..]; - } - let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); - let is_4_chars = id.len() == 4; - anyhow::ensure!( - is_4_chars && is_hex_string, - "Expected a 4 digit PCI ID in hexadecimal format" - ); - - u32::from_str_radix(id, 16).context("parsing PCI ID as hex") -} - -#[cfg(all(test, not(target_family = "wasm")))] -mod tests { - use super::{NativeBackend, SoftwareAdapterPolicy, parse_pci_id}; - - #[test] - fn native_backend_fallbacks_are_individually_initialized() { - let backends = NativeBackend::PREFERENCE; - assert!(!backends.is_empty()); - assert!( - backends - .iter() - .all(|backend| wgpu::Backends::from(*backend).bits().count_ones() == 1), - "each fallback step must initialize exactly one WGPU backend" - ); - - #[cfg(target_os = "linux")] - assert_eq!(backends, &[NativeBackend::Vulkan, NativeBackend::Gl]); - #[cfg(target_os = "windows")] - assert_eq!( - backends, - &[ - NativeBackend::Dx12, - NativeBackend::Vulkan, - NativeBackend::Gl, - ] - ); - #[cfg(target_os = "macos")] - assert_eq!(backends, &[NativeBackend::Metal]); - } - - #[test] - fn native_backend_fallback_preserves_ordered_failures() { - let mut attempted = Vec::new(); - let error = NativeBackend::try_in_preference_order::<()>("test context", |backend| { - attempted.push(backend); - anyhow::bail!("unavailable") - }) - .unwrap_err(); - - assert_eq!(attempted, NativeBackend::PREFERENCE); - let message = format!("{error:#}"); - let mut previous = 0; - for backend in NativeBackend::PREFERENCE { - let index = message[previous..] - .find(&format!("{backend:?}: unavailable")) - .expect("each backend failure should be reported in preference order"); - previous += index; - } - } - - #[test] - fn software_adapter_policy_is_explicit() { - assert!(SoftwareAdapterPolicy::Allow.accepts(wgpu::DeviceType::Cpu)); - assert!(!SoftwareAdapterPolicy::Reject.accepts(wgpu::DeviceType::Cpu)); - assert!( - SoftwareAdapterPolicy::Reject.accepts(wgpu::DeviceType::IntegratedGpu), - "rejecting software must not reject lower-power hardware adapters" - ); - } - - #[test] - fn test_parse_device_id() { - assert!(parse_pci_id("0xABCD").is_ok()); - assert!(parse_pci_id("ABCD").is_ok()); - assert!(parse_pci_id("abcd").is_ok()); - assert!(parse_pci_id("1234").is_ok()); - assert!(parse_pci_id("123").is_err()); - assert_eq!( - parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), - ); - - assert_eq!( - parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), - ); - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer.rs b/vendor/gpui-wgpu/src/wgpu_renderer.rs deleted file mode 100644 index ae420652a..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer.rs +++ /dev/null @@ -1,1181 +0,0 @@ -use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext, WgpuContextHandle, WgpuDeviceRequirements}; -use gpui::{DevicePixels, GpuSpecs, Scene, Size}; - -#[cfg(test)] -use gpui::BackdropFilter; -#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] -use gpui::{Bounds, ColorExt, Quad, ScaledPixels, Underline, blue, red, white}; -#[cfg(test)] -use gpui_render::blur::{BlurKernel, MAX_GAUSSIAN_SAMPLES_PER_SIDE}; -#[cfg(test)] -use gpui_render::shaders::interface as shader_interface; -#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] -use smallvec::{SmallVec, smallvec}; - -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::{Arc, Mutex}; - -mod buffers; -mod drawing; -mod filters; -mod frame; -#[cfg(all(feature = "test-support", not(target_family = "wasm")))] -mod headless; -#[cfg(all(test, feature = "test-support", not(target_family = "wasm")))] -mod lazy_smoothing_tests; -mod path_types; -mod pipelines; -mod platform; -pub use platform::RecoveryPending; -mod resources; -mod retained; -mod settings; -mod surfaces; -mod target; - -#[cfg(all(feature = "test-support", not(target_family = "wasm")))] -pub use headless::WgpuHeadlessRenderer; -use pipelines::WgpuPipelines as ShaderPipelines; -use resources::{GlobalBufferLayout, ResourceMetadata, WgpuResources}; -use settings::RenderingParameters; -pub use settings::{FontRasterizationSettings, SubpixelOrder, WgpuSurfaceConfig}; -use target::RenderTarget; - -/// Shared GPU context reference, used to coordinate device recovery across multiple windows. -pub type GpuContext = Rc>>; - -struct GpuFaultState { - pending_error: Arc>>, - consecutive_failed_frames: u32, - terminal_error: Option, - fatal_error: Arc>>, - cleaned_up_failed_frames: bool, - device_lost: Arc, - #[cfg(not(target_family = "wasm"))] - recovery_not_before: Option, -} - -pub struct WgpuRenderer { - /// Shared GPU context for device recovery coordination (unused on WASM). - #[allow(dead_code)] - context: Option, - #[cfg(not(target_family = "wasm"))] - native_backend: Option, - /// Compositor GPU hint for adapter selection (unused on WASM). - #[allow(dead_code)] - compositor_gpu: Option, - /// Application-requested extra wgpu features/limits, stored for device recovery. - #[allow(dead_code)] - extra_requirements: Option, - resources: Option, - target: RenderTarget, - atlas: Arc, - globals: GlobalBufferLayout, - rendering_params: RenderingParameters, - subpixel_order: SubpixelOrder, - dual_source_blending: bool, - adapter_info: wgpu::AdapterInfo, - uploaded_globals: Option, - faults: GpuFaultState, - retained: retained::RetainedFrame, - #[cfg(test)] - last_retained_presentation: &'static str, -} - -impl WgpuRenderer { - fn resources(&self) -> &WgpuResources { - self.resources - .as_ref() - .expect("GPU resources not available") - } - - fn resources_mut(&mut self) -> &mut WgpuResources { - self.resources - .as_mut() - .expect("GPU resources not available") - } - - fn new_internal( - gpu_context: Option, - context: &WgpuContext, - surface: Option>, - config: WgpuSurfaceConfig, - compositor_gpu: Option, - extra_requirements: Option, - atlas: Arc, - ) -> anyhow::Result { - let adapter_info = context.adapter.get_info(); - let target = RenderTarget::new( - &context.adapter, - &context.device, - surface.as_ref(), - config, - adapter_info.backend, - )?; - if let Some(surface) = surface.as_ref() { - surface.configure(&context.device, target.configuration()); - } - - let dual_source_blending = context.supports_dual_source_blending(); - let rendering_params = RenderingParameters::new(&context.adapter, target.format()); - let (resources, metadata) = WgpuResources::new( - context, - surface, - target.configuration(), - &rendering_params, - dual_source_blending, - )?; - let ResourceMetadata { - globals, - last_error, - } = metadata; - - Ok(Self { - context: gpu_context, - #[cfg(not(target_family = "wasm"))] - native_backend: None, - compositor_gpu, - extra_requirements, - resources: Some(resources), - target, - atlas, - globals, - rendering_params, - subpixel_order: SubpixelOrder::RedGreenBlue, - dual_source_blending, - adapter_info, - uploaded_globals: None, - retained: retained::RetainedFrame::new(), - #[cfg(test)] - last_retained_presentation: "direct", - faults: GpuFaultState { - pending_error: last_error, - consecutive_failed_frames: 0, - terminal_error: None, - fatal_error: context.fatal_error_slot(), - cleaned_up_failed_frames: false, - device_lost: context.device_lost_flag(), - #[cfg(not(target_family = "wasm"))] - recovery_not_before: None, - }, - }) - } - - /// A confirmed GPU failure, consumed by platforms before their fatal error handling. - pub fn terminal_error(&self) -> Option { - self.faults - .terminal_error - .clone() - .or_else(|| self.faults.fatal_error.lock().unwrap().clone()) - } - - // Only the strict Windows integration delegates fatal handling to its application. - pub(super) fn reports_terminal_errors(&self) -> bool { - #[cfg(target_os = "windows")] - { - self.native_backend == Some(crate::NativeBackend::Dx12) - } - #[cfg(not(target_os = "windows"))] - { - false - } - } - - pub fn update_drawable_size(&mut self, size: Size) { - if !self.target.resize(size) { - return; - } - self.retained.invalidate(); - let config = self.target.configuration().clone(); - let Some(resources) = self.resources.as_mut() else { - return; - }; - resources.invalidate_intermediate_textures(); - if let Some(surface) = resources.surface.as_ref() { - surface.configure(&resources.device, &config); - } - } - - /// Selects the physical LCD component order used by subpixel glyph correction. - pub fn set_subpixel_order(&mut self, order: SubpixelOrder) { - if order != self.subpixel_order { - self.retained.invalidate(); - } - self.subpixel_order = order; - } - - /// Updates platform-specific text rasterization parameters. - pub fn set_font_rasterization_settings(&mut self, settings: FontRasterizationSettings) { - self.retained.invalidate(); - self.rendering_params.font_rasterization = settings; - self.subpixel_order = settings.subpixel_order; - } - - /// Compatibility wrapper for callers that still report the layout as a boolean. - pub fn set_subpixel_layout(&mut self, is_bgr: bool) { - self.set_subpixel_order(if is_bgr { - SubpixelOrder::BlueGreenRed - } else { - SubpixelOrder::RedGreenBlue - }); - } - - pub fn update_transparency(&mut self, transparent: bool) { - if !self.target.set_transparent(transparent) { - return; - } - self.retained.invalidate(); - let config = self.target.configuration().clone(); - if let Some(resources) = self.resources.as_ref() { - if let Some(surface) = resources.surface.as_ref() { - surface.configure(&resources.device, &config); - } - } - self.rebuild_pipelines(); - } - - pub(super) fn rebuild_pipelines(&mut self) { - let config = self.target.configuration(); - let Some(resources) = self.resources.as_mut() else { - return; - }; - resources.pipelines = ShaderPipelines::new( - &resources.device, - &resources.bind_group_layouts, - config.format, - config.alpha_mode, - self.rendering_params.path_sample_count, - self.dual_source_blending, - resources.renderer_tier, - ); - } - - #[allow(dead_code)] - pub fn viewport_size(&self) -> Size { - self.target.viewport_size() - } - - pub fn sprite_atlas(&self) -> &Arc { - &self.atlas - } - - pub fn supports_dual_source_blending(&self) -> bool { - self.dual_source_blending - } - - pub fn gpu_context(&self) -> (Arc, Arc) { - let resources = self.resources(); - (resources.device.clone(), resources.queue.clone()) - } - - pub fn gpu_context_info(&self) -> Option { - if let Some(context) = self.context.as_ref().and_then(|context| { - context - .borrow() - .as_ref() - .map(|context| context.handle(self.target.format())) - }) { - return Some(context); - } - self.resources.as_ref().map(|resources| { - WgpuContextHandle::from_resources( - resources.device.clone(), - resources.queue.clone(), - self.target.format(), - self.adapter_info.clone(), - match self.adapter_info.backend { - wgpu::Backend::BrowserWebGpu => crate::WgpuBackend::BrowserWebGpu, - wgpu::Backend::Gl => crate::WgpuBackend::Gl, - backend => crate::WgpuBackend::Native(backend), - }, - self.faults.device_lost.clone(), - ) - }) - } - - pub fn gpu_specs(&self) -> GpuSpecs { - GpuSpecs { - is_software_emulated: self.adapter_info.device_type == wgpu::DeviceType::Cpu, - device_name: self.adapter_info.name.clone(), - driver_name: self.adapter_info.driver.clone(), - driver_info: self.adapter_info.driver_info.clone(), - } - } - /// Release the retained frame when a window is deactivated. - pub fn release_retained_frame(&mut self) { - self.retained.invalidate(); - } - - pub fn max_texture_size(&self) -> u32 { - self.target.maximum_dimension() - } - - /// Encodes through the shared scene path with the exact presentation texture. - fn render_to_texture( - &mut self, - scene: &Scene, - texture: &wgpu::Texture, - view: &wgpu::TextureView, - ) -> bool { - frame::render_to_view(self, scene, view, texture, None).is_some() - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn render_to_view_with_readback( - &mut self, - scene: &Scene, - frame_view: &wgpu::TextureView, - readback: frame::ReadbackCopy<'_>, - ) -> Option { - frame::render_to_view(self, scene, frame_view, readback.texture, Some(readback)) - } -} - -fn begin_color_render_pass<'encoder>( - encoder: &'encoder mut wgpu::CommandEncoder, - label: &'encoder str, - target: &'encoder wgpu::TextureView, - load: wgpu::LoadOp, -) -> wgpu::RenderPass<'encoder> { - encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(label), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - resolve_target: None, - ops: wgpu::Operations { - load, - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - ..Default::default() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::wgpu_renderer::filters::FrameUniformRequirements; - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn dashed_border_scene(dash_length: f32, dash_gap: f32) -> Scene { - let full_bounds = test_bounds(0.0, 0.0, 40.0, 20.0); - let quad_bounds = |left| test_bounds(left, 4.0, 12.0, 12.0); - let mut scene = Scene::default(); - - for (bounds, corner_smoothing) in [(quad_bounds(4.0), 0.0), (quad_bounds(24.0), 0.6)] { - scene.insert_primitive(Quad { - bounds, - content_mask: gpui::ContentMask { - bounds: full_bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.05, 0.8, 0.45, 1.0)), - border_style: gpui::BorderStyle::Dashed, - border_dashed_length: dash_length, - border_dashed_gap: dash_gap, - border_color: gpui::hsla(0.6, 0.9, 0.7, 1.0).into(), - corner_radii: gpui::Corners::all(ScaledPixels(4.0)), - border_widths: gpui::Edges::all(ScaledPixels(2.0)), - corner_smoothing, - ..Default::default() - }); - } - - scene.finish(); - - scene - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn images_differ_in_region( - first: &image::RgbaImage, - second: &image::RgbaImage, - left: u32, - right: u32, - ) -> bool { - (4..16).any(|y| (left..right).any(|x| first.get_pixel(x, y) != second.get_pixel(x, y))) - } - - #[test] - fn rust_storage_types_match_shader_strides() { - fn module_layout(source: &wgsl_rs::Source) -> (naga::Module, naga::proc::Layouter) { - let source = source.wgsl_source().expect("shader must generate WGSL"); - let module = - naga::front::wgsl::parse_str(&format!("enable dual_source_blending;\n{source}")) - .expect("generated shader must parse as WGSL"); - let mut layouter = naga::proc::Layouter::default(); - layouter - .update(module.to_ctx()) - .expect("generated shader types must have valid layouts"); - (module, layouter) - } - - let (base, base_layout) = module_layout(&gpui_render::shaders::base::WGSL_SOURCE); - let (subpixel, subpixel_layout) = - module_layout(&gpui_render::shaders::subpixel_sprite::WGSL_SOURCE); - - for abi in shader_interface::SCENE_STORAGE_ABI - .iter() - .chain(path_types::STORAGE_ABI) - { - let base_handle = base - .types - .iter() - .find(|(_, ty)| ty.name.as_deref() == Some(abi.wgsl_type)) - .map(|(handle, _)| handle); - let shader_stride = if let Some(handle) = base_handle { - base_layout[handle].to_stride() - } else { - let (handle, _) = subpixel - .types - .iter() - .find(|(_, ty)| ty.name.as_deref() == Some(abi.wgsl_type)) - .unwrap_or_else(|| panic!("missing WGSL type {}", abi.wgsl_type)); - subpixel_layout[handle].to_stride() - }; - assert_eq!( - shader_stride as usize, abi.rust_stride, - "Rust and WGSL layouts differ for {}", - abi.wgsl_type, - ); - } - } - - #[test] - fn large_filter_scenes_reserve_unique_uniform_slots() { - let mut scene = Scene::default(); - scene - .backdrop_filters - .resize_with(100, BackdropFilter::default); - scene.finish(); - - assert_eq!( - frame::FrameRequirements::for_scene( - &scene, - super::buffers::InstanceTransport::StorageBuffer - ) - .uniforms, - FrameUniformRequirements { - filter_count: 401, - surface_count: 0, - } - ); - } - - #[test] - fn large_blur_radii_expand_tap_spacing_instead_of_truncating_the_kernel() { - let kernel = BlurKernel::for_radius(100.0).expect("positive radii have a blur kernel"); - assert_eq!(kernel.standard_deviation, 50.0); - assert_eq!(kernel.sample_count, MAX_GAUSSIAN_SAMPLES_PER_SIDE); - assert!(kernel.sample_step > 4.0); - } - - #[cfg(any(target_os = "macos", target_os = "windows"))] - #[test] - fn desktop_blending_preserves_source_over_alpha() { - let straight = pipelines::desktop_scene_blend_state(wgpu::CompositeAlphaMode::Opaque); - assert_eq!(straight.color.src_factor, wgpu::BlendFactor::SrcAlpha); - assert_eq!( - straight.alpha.dst_factor, - wgpu::BlendFactor::OneMinusSrcAlpha - ); - - let premultiplied = - pipelines::desktop_scene_blend_state(wgpu::CompositeAlphaMode::PreMultiplied); - assert_eq!(premultiplied.color.src_factor, wgpu::BlendFactor::One); - assert_eq!( - premultiplied.alpha.dst_factor, - wgpu::BlendFactor::OneMinusSrcAlpha - ); - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn opaque_black_quad(width: f32, height: f32) -> Quad { - let bounds = test_bounds(0.0, 0.0, width, height); - Quad { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), - ..Default::default() - } - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn underline_opacity_is_applied_once() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let size = gpui::size(DevicePixels(3), DevicePixels(3)); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - let bounds = test_bounds(0.0, 0.0, 3.0, 3.0); - let mut scene = Scene::default(); - // Forces a non-zero first-instance index in the mixed-type arena. - // The retired Metal baseline clears to black. Paint that background - // explicitly so Windows' white clear colour does not change the fixture. - scene.insert_primitive(opaque_black_quad(3.0, 3.0)); - scene.insert_primitive(Underline { - order: 0, - padding: 0, - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - color: gpui::hsla(0.0, 1.0, 0.5, 0.5).into(), - thickness: ScaledPixels(1.0), - wavy: false.into(), - }); - scene.finish(); - - let image = renderer.render_to_image(&scene)?; - let center = image.get_pixel(1, 1).0; - // UNORM8 blending can round half intensity to either adjacent value. - // A second opacity application would produce 64, far outside this bound. - assert!(center[0].abs_diff(128) <= 1, "half-opacity red: {center:?}"); - assert_eq!(¢er[1..], &[0, 0, 255]); - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn configurable_dashes_reach_both_wgpu_quad_pipelines() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let size = gpui::size(DevicePixels(40), DevicePixels(20)); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - let default_image = renderer.render_to_image(&dashed_border_scene(2.0, 1.0))?; - let custom_image = renderer.render_to_image(&dashed_border_scene(4.0, 0.5))?; - - assert!( - images_differ_in_region(&default_image, &custom_image, 4, 16), - "custom dash length and gap must change the ordinary quad pipeline" - ); - assert!( - images_differ_in_region(&default_image, &custom_image, 24, 36), - "custom dash length and gap must change the smoothed quad pipeline" - ); - - Ok(()) - } - - /// Gradients add up to two units of dither per channel, seeded by a sine - /// hash of the pixel position (`gradient_dither`). Software rasterizers - /// still differ in UNORM8 rounding across versions and backends; preserve - /// upgrades' two-unit tolerance there. Hardware GPUs can evaluate the - /// sine hash differently, so their gradients can differ by twice the - /// dither plus rounding. Alpha and same-backend cropped/full comparisons - /// remain exact. - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn gradient_tolerance(context: &WgpuContext) -> u8 { - if context.adapter.get_info().device_type == wgpu::DeviceType::Cpu { - 2 - } else { - 5 - } - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn quad_backgrounds_match_legacy_metal_pixels() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - const LEGACY: &[u8] = &[ - 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 38, 110, 217, 255, 255, 0, 0, 255, 255, - 0, 0, 255, 38, 110, 217, 255, 0, 0, 0, 255, 10, 34, 4, 255, 57, 196, 22, 255, 193, 114, - 152, 255, 223, 155, 105, 255, 57, 196, 22, 255, 10, 34, 4, 255, 165, 60, 182, 255, 193, - 114, 151, 255, - ]; - let bounds = |x, y| test_bounds(x, y, 2.0, 2.0); - let mut scene = Scene::default(); - scene.insert_primitive(opaque_black_quad(4.0, 4.0)); - for (order, (bounds, background)) in [ - ( - bounds(0.0, 0.0), - gpui::solid_background(gpui::hsla(0.0, 1.0, 0.5, 1.0)), - ), - ( - bounds(2.0, 0.0), - gpui::checkerboard(gpui::hsla(0.6, 0.7, 0.5, 1.0), 1.0), - ), - ( - bounds(0.0, 2.0), - gpui::pattern_slash(gpui::hsla(0.3, 0.8, 0.5, 1.0), 1.0, 1.0), - ), - ( - bounds(2.0, 2.0), - gpui::linear_gradient( - 45.0, - gpui::linear_color_stop(gpui::hsla(0.8, 0.9, 0.4, 1.0), 0.0), - gpui::linear_color_stop(gpui::hsla(0.1, 0.8, 0.6, 1.0), 1.0), - ), - ), - ] - .into_iter() - .enumerate() - { - scene.insert_primitive(Quad { - order: order as u32, - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background, - ..Default::default() - }); - } - scene.finish(); - let context = WgpuContext::new_headless(None)?; - let gradient_tolerance = gradient_tolerance(&context); - let mut renderer = - WgpuRenderer::new_headless(&context, gpui::size(DevicePixels(4), DevicePixels(4)))?; - let actual = renderer.render_to_image(&scene)?; - for (index, (actual, expected)) in actual.as_raw().iter().zip(LEGACY).enumerate() { - let (x, y) = ((index / 4) % 4, (index / 4) / 4); - // The gradient quad covers the bottom-right 2x2 pixels. - let tolerance = if index % 4 == 3 { - 0 - } else if x >= 2 && y >= 2 { - gradient_tolerance - } else { - // Preserve upgrades' cross-backend UNORM8 rounding tolerance. - 2 - }; - assert!( - actual.abs_diff(*expected) <= tolerance, - "legacy mismatch at pixel ({x}, {y}), channel {}: got {actual}, expected {expected} ± {tolerance}", - index % 4 - ); - } - assert_eq!(actual.as_raw().len(), LEGACY.len()); - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn quad_border_backgrounds_use_the_fill_coordinate_space() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let gradient_tolerance = gradient_tolerance(&context).max(1); - let size = gpui::size(DevicePixels(12), DevicePixels(12)); - let bounds = test_bounds(0.0, 0.0, 12.0, 12.0); - let render = |quad| -> anyhow::Result { - let mut scene = Scene::default(); - // A white background exposes unwanted fills behind transparent - // pattern pixels and keeps the comparison independent of OS clears. - scene.insert_primitive(Quad { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 1.0, 1.0)), - ..Default::default() - }); - scene.insert_primitive(quad); - scene.finish(); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - renderer.render_to_image(&scene) - }; - // Fills and patterns compare within one unit everywhere; gradients - // carry dither (see `gradient_tolerance`). - let backgrounds = [ - (gpui::solid_background(gpui::hsla(0.0, 1.0, 0.5, 1.0)), 1), - (gpui::checkerboard(gpui::hsla(0.6, 0.7, 0.5, 1.0), 2.0), 1), - ( - gpui::pattern_slash(gpui::hsla(0.3, 0.8, 0.5, 1.0), 2.0, 2.0), - 1, - ), - ( - gpui::linear_gradient( - 90.0, - gpui::linear_color_stop(gpui::hsla(0.8, 0.9, 0.4, 1.0), 0.0), - gpui::linear_color_stop(gpui::hsla(0.1, 0.8, 0.6, 1.0), 1.0), - ), - gradient_tolerance, - ), - ]; - let border_samples = [ - (1, 1), - (6, 1), - (10, 1), - (1, 6), - (10, 6), - (1, 10), - (6, 10), - (10, 10), - ]; - - for (background, tolerance) in backgrounds { - let filled = render(Quad { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background, - ..Default::default() - })?; - let bordered = render(Quad { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - border_color: background, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 0.0)), - border_widths: gpui::Edges::all(ScaledPixels(4.0)), - ..Default::default() - })?; - - for (x, y) in border_samples { - let border_pixel = bordered.get_pixel(x, y).0; - let fill_pixel = filled.get_pixel(x, y).0; - for (channel, (border, fill)) in - border_pixel.iter().zip(fill_pixel.iter()).enumerate() - { - assert!( - border.abs_diff(*fill) <= tolerance, - "border background must sample like a fill at ({x}, {y}), channel {channel}, for {background:?}: got {border_pixel:?}, expected {fill_pixel:?}" - ); - } - } - assert_eq!( - bordered.get_pixel(6, 6).0, - [255, 255, 255, 255], - "border background must not paint the quad interior for {background:?}" - ); - } - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn cached_filter_bindings_accept_frame_local_dynamic_offsets() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let size = gpui::size(DevicePixels(4), DevicePixels(4)); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - let bounds = test_bounds(0.0, 0.0, 4.0, 4.0); - let mut scene = Scene::default(); - scene.insert_primitive(BackdropFilter { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - filters: smallvec::smallvec![gpui::ScaledFilter::Blur(ScaledPixels(1.0))], - opacity: 1.0, - ..BackdropFilter::default() - }); - scene.finish(); - - renderer.render_to_image(&scene)?; - renderer.render_to_image(&scene)?; - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn odd_sized_blur_preserves_edge_symmetry() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let size = gpui::size(DevicePixels(5), DevicePixels(5)); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - let full_bounds = test_bounds(0.0, 0.0, 5.0, 5.0); - let center_bounds = test_bounds(2.0, 2.0, 1.0, 1.0); - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - order: 0, - bounds: full_bounds, - content_mask: gpui::ContentMask { - bounds: full_bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), - ..Default::default() - }); - scene.insert_primitive(Quad { - order: 1, - bounds: center_bounds, - content_mask: gpui::ContentMask { - bounds: center_bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 1.0, 1.0)), - ..Default::default() - }); - scene.insert_primitive(BackdropFilter { - order: 2, - bounds: full_bounds, - content_mask: gpui::ContentMask { - bounds: full_bounds, - ..Default::default() - }, - filters: smallvec::smallvec![gpui::ScaledFilter::Blur(ScaledPixels(2.0))], - opacity: 1.0, - ..Default::default() - }); - scene.finish(); - - let image = renderer.render_to_image(&scene)?; - for y in 0..5 { - for x in 0..5 { - let actual = image.get_pixel(x, y).0; - let horizontal = image.get_pixel(4 - x, y).0; - let vertical = image.get_pixel(x, 4 - y).0; - for channel in 0..4 { - assert!( - actual[channel].abs_diff(horizontal[channel]) <= 1, - "horizontal blur asymmetry at ({x}, {y}), channel {channel}: {actual:?} vs {horizontal:?}" - ); - assert!( - actual[channel].abs_diff(vertical[channel]) <= 1, - "vertical blur asymmetry at ({x}, {y}), channel {channel}: {actual:?} vs {vertical:?}" - ); - } - } - } - assert!( - image.get_pixel(1, 2).0[0] > 0, - "blur must spread from the center pixel" - ); - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn alternating_instance_batches_fit_the_exact_upload_arena() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let size = gpui::size(DevicePixels(1), DevicePixels(1)); - let mut renderer = WgpuRenderer::new_headless(&context, size)?; - let bounds = test_bounds(0.0, 0.0, 1.0, 1.0); - let mut scene = Scene::default(); - for index in 0..512 { - scene.insert_primitive(Quad { - order: index * 2, - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::hsla(0.0, 0.0, 0.0, 1.0)), - ..Default::default() - }); - scene.insert_primitive(Underline { - order: index * 2 + 1, - padding: 0, - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - color: gpui::hsla(0.0, 0.0, 1.0, 1.0).into(), - thickness: ScaledPixels(1.0), - wavy: false.into(), - }); - } - scene.finish(); - assert_eq!( - scene - .render_commands() - .iter() - .filter(|command| matches!(command, gpui::RenderCommand::Batch(_))) - .count(), - 1024 - ); - - renderer.render_to_image(&scene)?; - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn test_bounds(left: f32, top: f32, width: f32, height: f32) -> Bounds { - Bounds::new( - gpui::point(ScaledPixels(left), ScaledPixels(top)), - gpui::size(ScaledPixels(width), ScaledPixels(height)), - ) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn test_quad( - bounds: Bounds, - mask: Bounds, - color: gpui::Hsla, - ) -> Quad { - Quad { - bounds, - content_mask: gpui::ContentMask { - bounds: mask, - ..Default::default() - }, - background: gpui::solid_background(color), - ..Default::default() - } - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn test_group( - scene: &mut Scene, - bounds: Bounds, - opacity: f32, - filters: SmallVec<[gpui::ScaledFilter; 4]>, - paint: impl FnOnce(&mut Scene), - ) { - let boundary = gpui::FilterBoundary { - order: 0, - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - corner_radii: gpui::Corners::default(), - corner_smoothing: 0.0, - filters, - opacity, - is_start: true, - }; - scene.insert_primitive(boundary.clone()); - paint(scene); - scene.insert_primitive(gpui::FilterBoundary { - is_start: false, - ..boundary - }); - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - fn test_renderers() -> anyhow::Result>> { - let mut renderers: Vec> = - vec![Box::new(WgpuHeadlessRenderer::new()?)]; - - if let Some(native) = gpui_platform::current_headless_renderer() { - renderers.push(native); - } - - Ok(renderers) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn opacity_groups_preserve_overlap_alpha_and_deep_nesting() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let bounds = test_bounds(0.0, 0.0, 16.0, 8.0); - let target_size = gpui::size(DevicePixels(16), DevicePixels(8)); - let green = gpui::hsla(1.0 / 3.0, 1.0, 0.5, 1.0); - let mut slider = Scene::default(); - test_group(&mut slider, bounds, 0.5, smallvec![], |scene| { - scene.insert_primitive(test_quad(test_bounds(0.0, 3.0, 8.0, 2.0), bounds, red())); - scene.insert_primitive(test_quad(test_bounds(4.0, 1.0, 4.0, 6.0), bounds, white())); - }); - - let mut translucent = Scene::default(); - translucent.insert_primitive(test_quad(bounds, bounds, green)); - test_group(&mut translucent, bounds, 0.5, smallvec![], |scene| { - scene.insert_primitive(test_quad(bounds, bounds, red().opacity(0.5))); - scene.insert_primitive(test_quad(bounds, bounds, blue().opacity(0.5))); - }); - - let mut nested = Scene::default(); - test_group(&mut nested, bounds, 0.5, smallvec![], |scene| { - scene.insert_primitive(test_quad(bounds, bounds, red())); - test_group(scene, bounds, 0.5, smallvec![], |scene| { - scene.insert_primitive(test_quad(bounds, bounds, blue())); - test_group(scene, bounds, 0.5, smallvec![], |scene| { - scene.insert_primitive(test_quad(bounds, bounds, white())); - }); - }); - }); - nested.insert_primitive(test_quad(test_bounds(12.0, 0.0, 4.0, 8.0), bounds, green)); - - for scene in [&mut slider, &mut translucent, &mut nested] { - scene.finish(); - } - - let cases = [ - ( - "overlap", - &slider, - &[ - (6, 4, [128, 128, 128, 255]), - (1, 4, [128, 0, 0, 255]), - (6, 1, [128, 128, 128, 255]), - (8, 4, [0, 0, 0, 255]), - ][..], - ), - ( - "intrinsic alpha", - &translucent, - &[(6, 4, [32, 159, 64, 255])][..], - ), - ( - "nesting", - &nested, - &[(6, 4, [96, 32, 64, 255]), (14, 4, [0, 255, 0, 255])][..], - ), - ]; - - for (renderer_idx, renderer) in test_renderers()?.iter_mut().enumerate() { - for (name, scene, samples) in cases { - let image = renderer.render_scene_to_image(scene, target_size)?; - - for &(x, y, expected) in samples { - let actual = image.get_pixel(x, y).0; - assert!( - actual - .iter() - .zip(expected) - .all(|(actual, expected)| actual.abs_diff(expected) <= 2), - "renderer {renderer_idx}, {name}, pixel ({x}, {y}): {actual:?}, expected {expected:?}" - ); - } - } - } - - Ok(()) - } - - #[cfg(all(feature = "test-support", not(target_family = "wasm")))] - #[test] - fn opacity_groups_preserve_blur_and_inherited_backdrops() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let bounds = test_bounds(0.0, 0.0, 16.0, 8.0); - let target_size = gpui::size(DevicePixels(16), DevicePixels(8)); - let green = gpui::hsla(1.0 / 3.0, 1.0, 0.5, 1.0); - let blur = smallvec![gpui::ScaledFilter::Blur(ScaledPixels(2.0))]; - let backdrop = BackdropFilter { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - filters: blur.clone(), - opacity: 1.0, - ..Default::default() - }; - let paint_background = |scene: &mut Scene| { - scene.insert_primitive(test_quad(bounds, bounds, red())); - scene.insert_primitive(test_quad(test_bounds(8.0, 0.0, 8.0, 8.0), bounds, blue())); - }; - - let mut original = Scene::default(); - paint_background(&mut original); - original.finish(); - - for (renderer_idx, renderer) in test_renderers()?.iter_mut().enumerate() { - let background = renderer.render_scene_to_image(&original, target_size)?; - - for (name, depth, with_local_quad, content_blur) in [ - ("inherited backdrop", 2, false, false), - ("local backdrop", 1, true, false), - ("content blur", 1, false, true), - ] { - let paint = |scene: &mut Scene, blur_enabled: bool| { - if with_local_quad { - scene.insert_primitive(test_quad( - test_bounds(6.0, 0.0, 4.0, 8.0), - bounds, - green, - )); - } - - if content_blur { - let filters = if blur_enabled { - blur.clone() - } else { - smallvec![] - }; - test_group(scene, bounds, 1.0, filters, |scene| { - scene.insert_primitive(test_quad( - test_bounds(6.0, 2.0, 4.0, 4.0), - bounds, - white(), - )); - }); - } else if blur_enabled { - scene.insert_primitive(backdrop.clone()); - } - }; - - let mut reference = Scene::default(); - let mut grouped = Scene::default(); - let mut unfiltered = Scene::default(); - - if !content_blur { - paint_background(&mut reference); - paint_background(&mut grouped); - paint_background(&mut unfiltered); - } - - paint(&mut reference, true); - paint(&mut unfiltered, false); - test_group(&mut grouped, bounds, 0.5, smallvec![], |scene| { - if depth == 2 { - test_group(scene, bounds, 0.5, smallvec![], |scene| paint(scene, true)); - } else { - paint(scene, true); - } - }); - - reference.finish(); - grouped.finish(); - unfiltered.finish(); - - let reference = renderer.render_scene_to_image(&reference, target_size)?; - let actual = renderer.render_scene_to_image(&grouped, target_size)?; - let unfiltered = renderer.render_scene_to_image(&unfiltered, target_size)?; - let opacity = 0.5_f32.powi(depth); - - assert!( - reference - .pixels() - .zip(unfiltered.pixels()) - .any(|(blurred, plain)| { - (0..3).any(|channel_idx| { - blurred[channel_idx].abs_diff(plain[channel_idx]) > 8 - }) - }), - "renderer {renderer_idx}, {name}: blur must visibly change the unfiltered scene" - ); - - if content_blur { - let falloff = reference.get_pixel(5, 4).0; - assert!( - falloff[0] > 8 && falloff[1] > 8 && falloff[2] > 8, - "renderer {renderer_idx}: content blur must spread outside the white quad: {falloff:?}" - ); - } - - for (pixel_idx, ((actual, reference), background)) in actual - .pixels() - .zip(reference.pixels()) - .zip(background.pixels()) - .enumerate() - { - for channel_idx in 0..3 { - let original = if content_blur { - 0.0 - } else { - background[channel_idx] as f32 - }; - let expected = (reference[channel_idx] as f32 * opacity - + original * (1.0 - opacity)) - .round() as u8; - assert!( - actual[channel_idx].abs_diff(expected) <= 3, - "renderer {renderer_idx}, {name}, pixel {pixel_idx}, channel {channel_idx}: {}, expected {expected}", - actual[channel_idx] - ); - } - } - } - } - - Ok(()) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs b/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs deleted file mode 100644 index 0d0aebb8d..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/buffers.rs +++ /dev/null @@ -1,766 +0,0 @@ -use collections::FxHashMap; -use gpui::AtlasTextureId; -use gpui_render::InstanceRange; -use gpui_render::artifacts::DATA_TEXTURE_WIDTH; -use gpui_render::shaders::interface::{self as shader_interface, BufferData}; -use std::{ - cell::{Cell, RefCell}, - marker::PhantomData, - num::NonZeroU64, - ops::Range, -}; - -use crate::{RendererTier, WgpuTextureIdentity}; - -use super::pipelines::{InstanceBindingSource, WgpuBindGroupLayouts}; - -/// Size of one `rgba32uint` data-texture texel, the downlevel transport's unit. -const TEXEL_BYTES: u64 = 16; - -/// How scene instance payloads reach the shaders. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum InstanceTransport { - /// Vertex-stage read-only storage buffer (WebGPU-class devices). - StorageBuffer, - /// Data texture plus a per-batch dynamic-offset range uniform (WebGL2/GLES). - DataTexture, -} - -impl InstanceTransport { - pub(super) fn from_tier(tier: RendererTier) -> Self { - match tier { - RendererTier::Modern => Self::StorageBuffer, - RendererTier::WebGl2 => Self::DataTexture, - } - } - - /// Byte alignment between batches; the downlevel base must land on a texel. - pub(super) fn batch_alignment(self, element_stride: u64) -> u64 { - match self { - Self::StorageBuffer => element_stride, - Self::DataTexture => TEXEL_BYTES, - } - } -} - -pub(super) struct InstanceSlice { - /// The draw arguments. On the storage-buffer transport the base indexes the whole - /// arena; downlevel draws always start at instance zero and carry the base in - /// their range-uniform slot instead. - instances: InstanceRange, - transport: InstanceTransport, - /// Byte offset of this batch's range-uniform slot; downlevel transport only. - range_offset: u32, - value: PhantomData, -} - -impl InstanceSlice { - pub(super) fn range(&self) -> Range { - self.instances.as_range() - } - - /// Binds the group-1 data bind group, adding the dynamic offset downlevel. - pub(super) fn set_data_bind_group( - &self, - pass: &mut wgpu::RenderPass<'_>, - bind_group: &wgpu::BindGroup, - ) { - match self.transport { - InstanceTransport::StorageBuffer => { - pass.set_bind_group(shader_interface::DATA_BIND_GROUP, bind_group, &[]) - } - InstanceTransport::DataTexture => pass.set_bind_group( - shader_interface::DATA_BIND_GROUP, - bind_group, - &[self.range_offset], - ), - } - } -} - -/// One frame's mapped scene-data upload: staging for all payloads plus downlevel slots. -pub(super) struct InstanceUpload { - staging: Option, - cursor: u64, - size: u64, - transport: InstanceTransport, - /// Downlevel only: mapped `DATA_RANGE` region and its slot allocator. - range_upload: Option, - /// Downlevel only: staging-to-texture copy recorded at finish. - texture_copy: Option, -} - -impl InstanceUpload { - pub(super) fn write(&mut self, values: &[T]) -> Option> { - if values.is_empty() { - return Some(self.empty_batch()); - } - let stride = std::mem::size_of::() as u64; - let data = shader_interface::slice_as_bytes(values); - let count = u32::try_from(values.len()).ok()?; - let (offset, range_offset) = self.allocate_batch(data.len() as u64, stride, count)?; - let staging = self - .staging - .as_mut() - .expect("non-empty frame uploads have mapped staging memory"); - staging - .slice(offset as usize..(offset + data.len() as u64) as usize) - .copy_from_slice(data); - Some(self.batch(offset, stride, count, range_offset)) - } - - pub(super) fn write_iter( - &mut self, - count: usize, - values: impl IntoIterator, - ) -> Option> { - if count == 0 { - return Some(self.empty_batch()); - } - let count = u32::try_from(count).ok()?; - let stride = std::mem::size_of::() as u64; - let size = stride.checked_mul(count as u64)?; - let (offset, range_offset) = self.allocate_batch(size, stride, count)?; - let staging = self.staging.as_mut()?; - let mut destination = staging.slice(offset as usize..(offset + size) as usize); - let mut values = values.into_iter(); - for index in 0..count { - let value = values.next()?; - let index = index as u64; - destination - .slice((index * stride) as usize..((index + 1) * stride) as usize) - .copy_from_slice(shader_interface::bytes_of(&value)); - } - if values.next().is_some() { - return None; - } - Some(self.batch(offset, stride, count, range_offset)) - } - - /// Reserves an aligned batch region, allocating its downlevel range slot. - fn allocate_batch(&mut self, size: u64, stride: u64, elements: u32) -> Option<(u64, u32)> { - let align = self.transport.batch_alignment(stride); - let offset = self.cursor.next_multiple_of(align); - if offset + size > self.size { - return None; - } - self.cursor = offset + size; - let range_offset = match self.transport { - InstanceTransport::StorageBuffer => 0, - InstanceTransport::DataTexture => { - let base_texel = u32::try_from(offset / TEXEL_BYTES).ok()?; - self.range_upload.as_mut()?.write(base_texel, elements)? - } - }; - Some((offset, range_offset)) - } - - /// An empty batch: no bytes, no range slot, no draw. - fn empty_batch(&self) -> InstanceSlice { - InstanceSlice { - instances: InstanceRange::new(0..0).expect("the empty range is addressable"), - transport: self.transport, - range_offset: 0, - value: PhantomData, - } - } - - fn batch( - &self, - offset: u64, - stride: u64, - count: u32, - range_offset: u32, - ) -> InstanceSlice { - let first = match self.transport { - InstanceTransport::StorageBuffer => { - usize::try_from(offset / stride).expect("batch offsets are stride-aligned") - } - InstanceTransport::DataTexture => 0, - }; - InstanceSlice { - instances: InstanceRange::new(first..first + count as usize) - .expect("arena capacity is bounded by the 32-bit instance index"), - transport: self.transport, - range_offset, - value: PhantomData, - } - } - - pub(super) fn finish(&mut self, encoder: &mut wgpu::CommandEncoder) { - // Dropping the mapped views schedules queue writes before the next submission. - self.staging.take(); - self.range_upload.take(); - if let Some(copy) = &self.texture_copy { - let used_texels = self.cursor.div_ceil(TEXEL_BYTES); - if used_texels > 0 { - let height = used_texels.div_ceil(u64::from(DATA_TEXTURE_WIDTH)); - encoder.copy_buffer_to_texture( - wgpu::TexelCopyBufferInfo { - buffer: ©.staging, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(DATA_TEXTURE_WIDTH * TEXEL_BYTES as u32), - rows_per_image: Some(height as u32), - }, - }, - copy.texture.as_image_copy(), - wgpu::Extent3d { - width: DATA_TEXTURE_WIDTH, - height: height as u32, - depth_or_array_layers: 1, - }, - ); - } - } - } -} - -/// Mapped per-batch `DATA_RANGE` slots: batch base in texels plus element count. -/// -/// The shader reads only the first two fields, but browser/WebGL downlevel -/// backends require uniform binding sizes to be 16-byte aligned. -struct RangeUpload { - staging: wgpu::QueueWriteBufferView, - stride: u64, - slots: u64, - next_slot: u64, -} - -impl RangeUpload { - fn write(&mut self, base_texel: u32, elements: u32) -> Option { - let slot = self.next_slot; - if slot >= self.slots { - return None; - } - self.next_slot += 1; - let offset = slot * self.stride; - let mut bytes = [0u8; 16]; - bytes[..4].copy_from_slice(&base_texel.to_le_bytes()); - bytes[4..8].copy_from_slice(&elements.to_le_bytes()); - self.staging - .slice(offset as usize..(offset + 16) as usize) - .copy_from_slice(&bytes); - u32::try_from(offset).ok() - } -} - -/// Staging-to-texture copy destination recorded when the frame upload finishes. -struct TextureCopy { - staging: wgpu::Buffer, - texture: wgpu::Texture, -} - -/// Alignment-aware uniform storage with frame-local slot allocation. -pub(super) struct DynamicUniformBuffer { - pub(super) buffer: wgpu::Buffer, - label: &'static str, - stride: u64, - capacity: u64, - generation: u64, - next_slot: Cell, - staging: RefCell>, - value: PhantomData, -} - -impl DynamicUniformBuffer { - pub(super) fn new( - device: &wgpu::Device, - label: &'static str, - initial_capacity: u64, - uniform_alignment: u64, - ) -> Self { - let stride = (std::mem::size_of::() as u64).next_multiple_of(uniform_alignment); - let buffer = create_buffer( - device, - label, - stride * initial_capacity, - wgpu::BufferUsages::UNIFORM, - ); - Self { - buffer, - label, - stride, - capacity: initial_capacity, - generation: 0, - next_slot: Cell::new(0), - staging: RefCell::new(None), - value: PhantomData, - } - } - - pub(super) fn ensure_capacity( - &mut self, - device: &wgpu::Device, - required_capacity: u64, - maximum_buffer_size: u64, - ) -> bool { - if required_capacity <= self.capacity { - return true; - } - - let new_capacity = required_capacity.next_power_of_two(); - let Some(new_size) = self.stride.checked_mul(new_capacity) else { - return false; - }; - if new_size > maximum_buffer_size || new_size > u32::MAX as u64 { - return false; - } - - self.buffer = create_buffer(device, self.label, new_size, wgpu::BufferUsages::UNIFORM); - self.capacity = new_capacity; - self.generation = self.generation.wrapping_add(1); - true - } - - pub(super) fn begin_upload(&self, queue: &wgpu::Queue, required_capacity: u64) -> bool { - if required_capacity > self.capacity { - return false; - } - self.next_slot.set(0); - let Some(size) = NonZeroU64::new(self.stride * required_capacity) else { - self.staging.take(); - return true; - }; - let Some(mut staging) = queue.write_buffer_with(&self.buffer, 0, size) else { - return false; - }; - staging.slice(..).fill(0); - self.staging.replace(Some(staging)); - true - } - - pub(super) fn generation(&self) -> u64 { - self.generation - } - - pub(super) fn finish_upload(&self) { - self.staging.take(); - } - - pub(super) fn write(&self, value: &T) -> u32 { - let slot = self.next_slot.get(); - assert!( - slot < self.capacity, - "{} capacity must be ensured before rendering", - self.label - ); - self.next_slot.set(slot + 1); - let offset = slot * self.stride; - let bytes = shader_interface::bytes_of(value); - self.staging - .borrow_mut() - .as_mut() - .expect("uniform uploads must begin before rendering") - .slice(offset as usize..offset as usize + bytes.len()) - .copy_from_slice(bytes); - u32::try_from(offset).expect("dynamic uniform offsets are limited to u32") - } -} - -/// One frame's zero-copy scene data arena. -pub(super) struct InstanceBufferArena { - transport: InstanceTransport, - storage: InstanceStorage, - bind_group: wgpu::BindGroup, - textured_bind_groups: RefCell, - capacity: u64, - maximum_size: u64, -} - -/// Backing store for the arena's payload bytes. -enum InstanceStorage { - /// Read-only storage buffer consumed directly by the shaders. - Buffer(wgpu::Buffer), - /// Data texture fed from per-frame staging, plus range-uniform slots. - DataTexture { - texture: wgpu::Texture, - view: wgpu::TextureView, - staging: wgpu::Buffer, - ranges: RangeUniformArena, - }, -} - -/// Per-batch range slots in dynamic-offset-alignment stride, one bound per draw. -struct RangeUniformArena { - buffer: wgpu::Buffer, - stride: u64, - capacity: u64, - maximum: u64, -} - -impl RangeUniformArena { - const INITIAL_CAPACITY: u64 = 256; - - fn new(device: &wgpu::Device) -> Self { - let alignment = u64::from(device.limits().min_uniform_buffer_offset_alignment); - let stride = alignment.max(256); - // The whole-buffer limit constrains slot count, not the binding-size limit. - let maximum = device.limits().max_buffer_size / stride; - let capacity = Self::INITIAL_CAPACITY.min(maximum); - Self { - buffer: create_buffer( - device, - "downlevel_range_uniforms", - capacity * stride, - wgpu::BufferUsages::UNIFORM, - ), - stride, - capacity, - maximum, - } - } - - fn ensure_capacity(&mut self, device: &wgpu::Device, required_slots: u64) -> bool { - if required_slots <= self.capacity { - return true; - } - if required_slots > self.maximum { - return false; - } - self.capacity = required_slots.next_power_of_two().min(self.maximum); - self.buffer = create_buffer( - device, - "downlevel_range_uniforms", - self.capacity * self.stride, - wgpu::BufferUsages::UNIFORM, - ); - true - } - - fn binding(&self) -> wgpu::BufferBinding<'_> { - // One full slot stays inside max_uniform_buffer_binding_size. - wgpu::BufferBinding { - buffer: &self.buffer, - offset: 0, - size: NonZeroU64::new(self.stride), - } - } -} - -#[derive(Default)] -struct TexturedBindGroups { - groups: FxHashMap< - (shader_interface::DataLayout, AtlasTextureId), - (WgpuTextureIdentity, wgpu::BindGroup), - >, - path: FxHashMap, -} - -impl InstanceBufferArena { - // Large scenes grow geometrically; ordinary UI frames skip a permanent floor. - const INITIAL_CAPACITY: u64 = 64 * 1024; - - pub(super) fn new( - device: &wgpu::Device, - layouts: &WgpuBindGroupLayouts, - tier: RendererTier, - ) -> Self { - let transport = InstanceTransport::from_tier(tier); - let maximum_size = match transport { - InstanceTransport::StorageBuffer => device - .limits() - .max_buffer_size - .min(device.limits().max_storage_buffer_binding_size), - InstanceTransport::DataTexture => { - let texels = u64::from(DATA_TEXTURE_WIDTH) - * u64::from(device.limits().max_texture_dimension_2d); - (texels * TEXEL_BYTES).min(device.limits().max_buffer_size) / TEXEL_BYTES - * TEXEL_BYTES - } - }; - let capacity = Self::INITIAL_CAPACITY.min(maximum_size); - let storage = match transport { - InstanceTransport::StorageBuffer => InstanceStorage::Buffer(create_buffer( - device, - "instance_buffer", - capacity, - wgpu::BufferUsages::STORAGE, - )), - InstanceTransport::DataTexture => { - let texture = create_data_texture(device, capacity / TEXEL_BYTES); - let view = view_of(&texture); - InstanceStorage::DataTexture { - texture, - view, - staging: create_buffer( - device, - "instance_staging", - capacity, - wgpu::BufferUsages::COPY_SRC, - ), - ranges: RangeUniformArena::new(device), - } - } - }; - let bind_group = instance_bind_group(device, layouts, &storage); - Self { - transport, - storage, - bind_group, - textured_bind_groups: RefCell::default(), - capacity, - maximum_size, - } - } - - pub(super) fn transport(&self) -> InstanceTransport { - self.transport - } - - pub(super) fn ensure_capacity( - &mut self, - device: &wgpu::Device, - layouts: &WgpuBindGroupLayouts, - required: u64, - required_range_slots: u64, - ) -> bool { - if let InstanceStorage::DataTexture { ranges, .. } = &self.storage { - if required_range_slots > ranges.maximum { - log::error!( - "scene requires {required_range_slots} instance batches, exceeding the \ - range uniform limit of {} slots", - ranges.maximum - ); - return false; - } - } - let storage_grew = match &self.storage { - InstanceStorage::Buffer(_) => required > self.capacity, - InstanceStorage::DataTexture { ranges, .. } => { - required > self.capacity || required_range_slots > ranges.capacity - } - }; - if !storage_grew { - return true; - } - if required > self.maximum_size { - log::error!( - "scene requires {required} storage bytes, exceeding the GPU limit of {}", - self.maximum_size - ); - return false; - } - - match &mut self.storage { - InstanceStorage::Buffer(buffer) => { - self.capacity = required.next_power_of_two().min(self.maximum_size); - *buffer = create_buffer( - device, - "instance_buffer", - self.capacity, - wgpu::BufferUsages::STORAGE, - ); - } - InstanceStorage::DataTexture { - texture, - view, - staging, - ranges, - } => { - if required > self.capacity { - self.capacity = required.next_power_of_two().min(self.maximum_size); - *texture = create_data_texture(device, self.capacity / TEXEL_BYTES); - *view = view_of(texture); - *staging = create_buffer( - device, - "instance_staging", - self.capacity, - wgpu::BufferUsages::COPY_SRC, - ); - } - ranges.ensure_capacity(device, required_range_slots); - } - } - self.bind_group = instance_bind_group(device, layouts, &self.storage); - *self.textured_bind_groups.get_mut() = TexturedBindGroups::default(); - log::info!("increased instance buffer size to {}", self.capacity); - true - } - - pub(super) fn begin_upload( - &self, - queue: &wgpu::Queue, - required: u64, - required_range_slots: u64, - ) -> Option { - if required > self.capacity { - return None; - } - let staging = if let Some(size) = NonZeroU64::new(required) { - Some(match &self.storage { - InstanceStorage::Buffer(buffer) => queue.write_buffer_with(buffer, 0, size)?, - InstanceStorage::DataTexture { staging, .. } => { - queue.write_buffer_with(staging, 0, size)? - } - }) - } else { - None - }; - let range_upload = match &self.storage { - InstanceStorage::DataTexture { ranges, .. } if required_range_slots > 0 => { - Some(RangeUpload { - staging: queue.write_buffer_with( - &ranges.buffer, - 0, - NonZeroU64::new(required_range_slots.min(ranges.capacity) * ranges.stride)?, - )?, - stride: ranges.stride, - slots: required_range_slots.min(ranges.capacity), - next_slot: 0, - }) - } - _ => None, - }; - Some(InstanceUpload { - staging, - cursor: 0, - size: required, - transport: self.transport, - range_upload, - texture_copy: match &self.storage { - InstanceStorage::DataTexture { - texture, staging, .. - } => Some(TextureCopy { - staging: staging.clone(), - texture: texture.clone(), - }), - InstanceStorage::Buffer(_) => None, - }, - }) - } - - pub(super) fn bind_group(&self) -> &wgpu::BindGroup { - &self.bind_group - } - - pub(super) fn textured_bind_group( - &self, - device: &wgpu::Device, - layouts: &WgpuBindGroupLayouts, - data_layout: shader_interface::DataLayout, - texture_id: AtlasTextureId, - texture_identity: WgpuTextureIdentity, - texture: &wgpu::TextureView, - sampler: &wgpu::Sampler, - ) -> wgpu::BindGroup { - let mut cache = self.textured_bind_groups.borrow_mut(); - let key = (data_layout, texture_id); - if let Some((identity, bind_group)) = cache.groups.get(&key) - && *identity == texture_identity - { - return bind_group.clone(); - } - - let bind_group = layouts.create_textured_instances( - device, - data_layout, - self.binding_source(), - texture, - sampler, - ); - cache - .groups - .insert(key, (texture_identity, bind_group.clone())); - bind_group - } - - pub(super) fn path_bind_group( - &self, - device: &wgpu::Device, - layouts: &WgpuBindGroupLayouts, - data_layout: shader_interface::DataLayout, - texture: &wgpu::TextureView, - sampler: &wgpu::Sampler, - ) -> wgpu::BindGroup { - let mut cache = self.textured_bind_groups.borrow_mut(); - cache - .path - .entry(data_layout) - .or_insert_with(|| { - layouts.create_textured_instances( - device, - data_layout, - self.binding_source(), - texture, - sampler, - ) - }) - .clone() - } - - pub(super) fn binding_source(&self) -> InstanceBindingSource<'_> { - match &self.storage { - InstanceStorage::Buffer(buffer) => InstanceBindingSource::Buffer(whole_buffer(buffer)), - InstanceStorage::DataTexture { view, ranges, .. } => { - InstanceBindingSource::DataTexture { - texture: view, - range_uniforms: ranges.binding(), - } - } - } - } - - pub(super) fn invalidate_texture_bindings(&self) { - *self.textured_bind_groups.borrow_mut() = TexturedBindGroups::default(); - } -} - -/// Creates the group-1 bind group for the arena's current storage. -fn instance_bind_group( - device: &wgpu::Device, - layouts: &WgpuBindGroupLayouts, - storage: &InstanceStorage, -) -> wgpu::BindGroup { - let source = match storage { - InstanceStorage::Buffer(buffer) => InstanceBindingSource::Buffer(whole_buffer(buffer)), - InstanceStorage::DataTexture { view, ranges, .. } => InstanceBindingSource::DataTexture { - texture: view, - range_uniforms: ranges.binding(), - }, - }; - layouts.create_instances(device, source) -} - -fn create_data_texture(device: &wgpu::Device, texels: u64) -> wgpu::Texture { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("instance_data_texture"), - size: wgpu::Extent3d { - width: DATA_TEXTURE_WIDTH, - height: texels.div_ceil(u64::from(DATA_TEXTURE_WIDTH)) as u32, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba32Uint, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }) -} - -fn view_of(texture: &wgpu::Texture) -> wgpu::TextureView { - texture.create_view(&wgpu::TextureViewDescriptor::default()) -} - -fn whole_buffer(buffer: &wgpu::Buffer) -> wgpu::BufferBinding<'_> { - wgpu::BufferBinding { - buffer, - offset: 0, - size: None, - } -} - -fn create_buffer( - device: &wgpu::Device, - label: &str, - size: u64, - primary_usage: wgpu::BufferUsages, -) -> wgpu::Buffer { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some(label), - size, - usage: primary_usage | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }) -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs b/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs deleted file mode 100644 index b8642e5ba..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/drawing.rs +++ /dev/null @@ -1,298 +0,0 @@ -use gpui::{ - AtlasTextureId, MonochromeSprite, Path, PolychromeSprite, Quad, ScaledPixels, Shadow, - SubpixelSprite, Underline, -}; - -use crate::WgpuTextureInfo; -use gpui_render::shaders::interface::{self as shader_interface, BufferData}; - -use super::{ - WgpuRenderer, - buffers::{InstanceSlice, InstanceUpload}, - frame, path_types, pipelines, -}; - -impl WgpuRenderer { - /// Replace every RGBA channel in a damaged region before replaying its scene. - /// Source-over blending cannot erase old alpha with a transparent clear. - pub(super) fn draw_damage_clear( - &self, - quads: &[Quad], - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - self.draw_instances( - quads, - self.resources() - .pipelines - .damage_clear_quads - .as_ref() - .expect("damage clear pipeline is prepared before partial encoding"), - instances, - pass, - ) - } - - pub(super) fn draw_quads( - &self, - quads: &[Quad], - smoothed: bool, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let pipelines = &self.resources().pipelines; - let pipeline = if smoothed { - pipelines - .smoothed_quads - .as_ref() - .expect("smoothed quads prepared before encoding") - } else { - &pipelines.quads - }; - self.draw_instances(quads, pipeline, instances, pass) - } - - pub(super) fn draw_shadows( - &self, - shadows: &[Shadow], - smoothed: bool, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let pipelines = &self.resources().pipelines; - let pipeline = if smoothed { - pipelines - .smoothed_shadows - .as_ref() - .expect("smoothed shadows prepared before encoding") - } else { - &pipelines.shadows - }; - self.draw_instances(shadows, pipeline, instances, pass) - } - - pub(super) fn draw_underlines( - &self, - underlines: &[Underline], - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - self.draw_instances( - underlines, - &self.resources().pipelines.underlines, - instances, - pass, - ) - } - - pub(super) fn draw_monochrome_sprites( - &self, - sprites: &[MonochromeSprite], - texture_id: AtlasTextureId, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let texture = self.atlas.get_texture_info(texture_id); - self.draw_instances_with_texture( - sprites, - texture_id, - &texture, - &self.resources().pipelines.monochrome_sprites, - instances, - pass, - ) - } - - pub(super) fn draw_subpixel_sprites( - &self, - sprites: &[SubpixelSprite], - texture_id: AtlasTextureId, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let texture = self.atlas.get_texture_info(texture_id); - let resources = self.resources(); - let pipeline = resources - .pipelines - .subpixel_sprites - .as_ref() - .unwrap_or(&resources.pipelines.monochrome_sprites); - self.draw_instances_with_texture(sprites, texture_id, &texture, pipeline, instances, pass) - } - - pub(super) fn draw_polychrome_sprites( - &self, - sprites: &[PolychromeSprite], - texture_id: AtlasTextureId, - smoothed: bool, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let texture = self.atlas.get_texture_info(texture_id); - let pipelines = &self.resources().pipelines; - let pipeline = if smoothed { - pipelines - .smoothed_polychrome_sprites - .as_ref() - .expect("smoothed sprites prepared before encoding") - } else { - &pipelines.polychrome_sprites - }; - self.draw_instances_with_texture(sprites, texture_id, &texture, pipeline, instances, pass) - } - - fn draw_instances( - &self, - values: &[T], - pipeline: &pipelines::WgpuRenderPipeline, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - if values.is_empty() { - return Ok(()); - } - let resources = self.resources(); - self.draw_bound_instances( - values, - pipeline, - resources.instances.bind_group(), - instances, - pass, - ) - } - - fn draw_bound_instances( - &self, - values: &[T], - pipeline: &pipelines::WgpuRenderPipeline, - bind_group: &wgpu::BindGroup, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let Some(slice) = instances.write(values) else { - return Err(frame::DrawError::CapacityPlanningInvariant); - }; - self.draw_bound_slice(pipeline, bind_group, &slice, pass); - Ok(()) - } - - fn draw_bound_slice( - &self, - pipeline: &pipelines::WgpuRenderPipeline, - bind_group: &wgpu::BindGroup, - slice: &InstanceSlice, - pass: &mut wgpu::RenderPass<'_>, - ) { - pass.set_pipeline(pipeline); - slice.set_data_bind_group(pass, bind_group); - pass.draw(0..pipeline.fixed_vertex_count(), slice.range()); - } - - fn draw_instances_with_texture( - &self, - values: &[T], - texture_id: AtlasTextureId, - texture: &WgpuTextureInfo, - pipeline: &pipelines::WgpuRenderPipeline, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - if values.is_empty() { - return Ok(()); - } - let resources = self.resources(); - let bind_group = resources.instances.textured_bind_group( - &resources.device, - &resources.bind_group_layouts, - pipeline.data_layout(), - texture_id, - texture.identity, - &texture.view, - &resources.atlas_sampler, - ); - self.draw_bound_instances(values, pipeline, &bind_group, instances, pass) - } - - pub(super) fn draw_paths_from_intermediate( - &self, - paths: &[Path], - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - let sprite_count = path_types::sprite_count(paths); - let Some(sprite_slice) = instances.write_iter(sprite_count, path_types::sprites(paths)) - else { - return Err(frame::DrawError::CapacityPlanningInvariant); - }; - let resources = self.resources(); - let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { - return Err(frame::DrawError::MissingIntermediateTarget); - }; - let bind_group = resources.instances.path_bind_group( - &resources.device, - &resources.bind_group_layouts, - resources.pipelines.paths.data_layout(), - path_intermediate_view, - &resources.atlas_sampler, - ); - self.draw_bound_slice(&resources.pipelines.paths, &bind_group, &sprite_slice, pass); - Ok(()) - } - - pub(super) fn draw_paths_to_intermediate( - &self, - encoder: &mut wgpu::CommandEncoder, - paths: &[Path], - instances: &mut InstanceUpload, - ) -> frame::DrawResult { - let vertex_count = path_types::rasterization_vertex_count(paths); - if vertex_count == 0 { - return Ok(()); - } - - let Some(vertex_slice) = - instances.write_iter(vertex_count, path_types::rasterization_vertices(paths)) - else { - return Err(frame::DrawError::CapacityPlanningInvariant); - }; - let resources = self.resources(); - let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { - return Err(frame::DrawError::MissingIntermediateTarget); - }; - let (target_view, resolve_target) = if let Some(msaa_view) = &resources.path_msaa_view { - (msaa_view, Some(path_intermediate_view)) - } else { - (path_intermediate_view, None) - }; - - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("path_rasterization_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target_view, - resolve_target, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - // Only the resolved texture is sampled later. The MSAA - // attachment is transient and cannot use StoreOp::Store. - store: if resolve_target.is_some() { - wgpu::StoreOp::Discard - } else { - wgpu::StoreOp::Store - }, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - ..Default::default() - }); - pass.set_pipeline(&resources.pipelines.path_rasterization); - pass.set_bind_group( - shader_interface::GLOBAL_BIND_GROUP, - &resources.path_globals_bind_group, - &[], - ); - vertex_slice.set_data_bind_group(&mut pass, resources.instances.bind_group()); - pass.draw(vertex_slice.range(), 0..1); - Ok(()) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs b/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs deleted file mode 100644 index e69d8725a..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/filters.rs +++ /dev/null @@ -1,284 +0,0 @@ -use gpui::{Bounds, Corners, ScaledPixels, point, size}; -use gpui_render::shaders::interface as shader_interface; -use gpui_render::{ - blur::{ - BlurAxis, BlurKernel, FilterCompositeClip, FilterCompositeParameters, - GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS, ScissorRectangle, downsampled_dimension, - }, - shaders::blur::BlurUniforms, -}; - -use super::{WgpuRenderer, begin_color_render_pass, pipelines}; - -pub(super) const FILTER_UNIFORMS_PER_COMPOSITE: u64 = 4; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(super) struct FrameUniformRequirements { - pub(super) filter_count: u64, - pub(super) surface_count: u64, -} - -const _: () = assert!(std::mem::size_of::() == 112); - -impl WgpuRenderer { - fn draw_filter_pass( - &self, - encoder: &mut wgpu::CommandEncoder, - label: &str, - pipeline: &pipelines::WgpuRenderPipeline, - target: &wgpu::TextureView, - source: &wgpu::TextureView, - uniforms: BlurUniforms, - load: wgpu::LoadOp, - scissor: Option, - ) { - let resources = self.resources(); - let uniform_offset = resources.filter_uniforms.write(&uniforms); - let bind_group = resources.blur_bind_group(source); - let mut pass = begin_color_render_pass(encoder, label, target, load); - pass.set_pipeline(pipeline); - pass.set_bind_group( - shader_interface::GLOBAL_BIND_GROUP, - &resources.globals_bind_group, - &[], - ); - pass.set_bind_group( - shader_interface::DATA_BIND_GROUP, - &bind_group, - &[uniform_offset], - ); - - if let Some(scissor) = scissor { - pass.set_scissor_rect(scissor.x, scissor.y, scissor.width, scissor.height); - } - - pass.draw(0..pipeline.fixed_vertex_count(), 0..1); - } - - pub(super) fn blur_and_composite( - &self, - encoder: &mut wgpu::CommandEncoder, - source: &wgpu::TextureView, - target: &wgpu::TextureView, - parameters: FilterCompositeParameters, - ) { - if parameters.blur_radius <= 0.0 - && matches!(parameters.clip, FilterCompositeClip::RoundedBounds) - { - return; - } - - let full_size = [self.target.width() as f32, self.target.height() as f32]; - - let Some(kernel) = BlurKernel::for_radius(parameters.blur_radius) else { - self.composite_texture(encoder, source, target, parameters, full_size); - - return; - }; - let full_width = self.target.width(); - let full_height = self.target.height(); - let blur_size = [ - downsampled_dimension(full_width) as f32, - downsampled_dimension(full_height) as f32, - ]; - let dilation = GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS * parameters.blur_radius; - let scissor = ScissorRectangle::for_blurred_bounds( - parameters.bounds, - dilation, - full_width, - full_height, - ); - if scissor.is_empty() { - return; - } - - let (horizontal_target, vertical_target) = { - let resources = self.resources(); - match ( - resources.blur_ping_view.as_ref(), - resources.blur_pong_view.as_ref(), - ) { - (Some(horizontal), Some(vertical)) => (horizontal.clone(), vertical.clone()), - _ => return, - } - }; - - self.draw_filter_pass( - encoder, - "blur_downsample", - &self.resources().pipelines.blur_downsample, - &horizontal_target, - source, - BlurUniforms::downsample([full_width as f32, full_height as f32], blur_size), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - Some(scissor), - ); - self.draw_filter_pass( - encoder, - "blur_horizontal", - &self.resources().pipelines.blur, - &vertical_target, - &horizontal_target, - BlurUniforms::gaussian(BlurAxis::Horizontal, blur_size, kernel), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - Some(scissor), - ); - self.draw_filter_pass( - encoder, - "blur_vertical", - &self.resources().pipelines.blur, - &horizontal_target, - &vertical_target, - BlurUniforms::gaussian(BlurAxis::Vertical, blur_size, kernel), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - Some(scissor), - ); - - self.composite_texture(encoder, &horizontal_target, target, parameters, blur_size); - } - - fn composite_texture( - &self, - encoder: &mut wgpu::CommandEncoder, - source: &wgpu::TextureView, - target: &wgpu::TextureView, - parameters: FilterCompositeParameters, - source_size: [f32; 2], - ) { - let clips_to_bounds = matches!(parameters.clip, FilterCompositeClip::RoundedBounds); - let composite_bounds = if clips_to_bounds { - parameters.bounds - } else { - parameters.bounds.dilate(ScaledPixels( - GAUSSIAN_CUTOFF_STANDARD_DEVIATIONS * parameters.blur_radius, - )) - }; - let uniforms = BlurUniforms::composite( - composite_bounds, - parameters.content_mask, - parameters.corner_radii, - parameters.corner_smoothing, - parameters.opacity, - parameters.clip, - source_size, - [self.target.width() as f32, self.target.height() as f32], - ); - let resources = self.resources(); - let pipeline = if uniforms.corner_smoothing > 0.0 { - resources - .pipelines - .smoothed_blur_composite - .as_ref() - .expect("smoothed blur prepared before encoding") - } else { - &resources.pipelines.blur_composite - }; - - self.draw_filter_pass( - encoder, - "blur_composite", - pipeline, - target, - source, - uniforms, - wgpu::LoadOp::Load, - None, - ); - } - - pub(super) fn snapshot_backdrop<'a>( - &self, - encoder: &mut wgpu::CommandEncoder, - sources: impl Iterator, - ) -> wgpu::TextureView { - let target = self - .resources() - .backdrop_snapshot_view - .as_ref() - .expect("backdrop snapshot was prepared") - .clone(); - let full_size = [self.target.width() as f32, self.target.height() as f32]; - let bounds = Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(full_size[0]), ScaledPixels(full_size[1])), - ); - drop(begin_color_render_pass( - encoder, - "clear_backdrop_snapshot", - &target, - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - )); - - for source in sources { - self.composite_texture( - encoder, - source, - &target, - FilterCompositeParameters { - bounds, - content_mask: bounds, - corner_radii: Corners::default(), - corner_smoothing: 0.0, - blur_radius: 0.0, - opacity: 1.0, - clip: FilterCompositeClip::ContentShape, - }, - full_size, - ); - } - - target - } - - pub(super) fn blit_to_frame( - &self, - encoder: &mut wgpu::CommandEncoder, - source: &wgpu::TextureView, - frame_view: &wgpu::TextureView, - ) { - let size = [self.target.width() as f32, self.target.height() as f32]; - - self.draw_filter_pass( - encoder, - "scene_blit", - &self.resources().pipelines.blur_downsample, - frame_view, - source, - BlurUniforms::copy(size), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - None, - ); - } - - /// Draws the texture `bind_group` samples over `frame_view` with copy uniforms. - pub(super) fn draw_blit( - &self, - encoder: &mut wgpu::CommandEncoder, - bind_group: &wgpu::BindGroup, - uniform_offset: u32, - frame_view: &wgpu::TextureView, - ) { - let resources = self.resources(); - let mut pass = begin_color_render_pass( - encoder, - "scene_blit", - frame_view, - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - ); - pass.set_pipeline(&resources.pipelines.blur_downsample); - pass.set_bind_group( - shader_interface::GLOBAL_BIND_GROUP, - &resources.globals_bind_group, - &[], - ); - pass.set_bind_group( - shader_interface::DATA_BIND_GROUP, - bind_group, - &[uniform_offset], - ); - pass.draw( - 0..resources.pipelines.blur_downsample.fixed_vertex_count(), - 0..1, - ); - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs b/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs deleted file mode 100644 index 06b0ba90e..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/frame.rs +++ /dev/null @@ -1,1104 +0,0 @@ -use super::{ - WgpuRenderer, begin_color_render_pass, - buffers::{InstanceTransport, InstanceUpload}, - filters::{FILTER_UNIFORMS_PER_COMPOSITE, FrameUniformRequirements}, - path_types, - pipelines::SmoothingRequirements, -}; -use gpui::{ - FilterRenderTarget, MonochromeSprite, PolychromeSprite, PrimitiveBatch, Quad, RenderCommand, - Scene, ScenePlanRequirements, Shadow, SubpixelSprite, Underline, -}; -use gpui_render::TargetStack; -use gpui_render::blur::ScissorRectangle; -use gpui_render::blur::{FilterCompositeClip, FilterCompositeParameters}; -use gpui_render::damage::{Damage, batch_intersects, clear_quad}; -use gpui_render::shaders::{ - common::{FontRasterizationUniforms, GlobalUniforms, ShaderBool}, - interface as shader_interface, -}; - -pub(super) fn render_to_view( - renderer: &mut WgpuRenderer, - scene: &Scene, - frame_view: &wgpu::TextureView, - frame_texture: &wgpu::Texture, - readback: Option>, -) -> Option { - if !begin_frame(renderer) { - return None; - } - let atlas_generation = if renderer.retained.enabled() { - gpui::PlatformAtlas::generation(renderer.atlas.as_ref()) - } else { - 0 - }; - let mut retained = renderer.retained.prepare( - scene, - atlas_generation, - renderer.target.width(), - renderer.target.height(), - renderer.target.retained_copy_enabled(), - renderer.resources.as_ref()?, - renderer.target.format(), - renderer.target.alpha_mode(), - renderer.target.clear_color(), - ); - let transport = renderer.resources().instances.transport(); - let requirements = FrameRequirements::for_damage(scene, &retained.damage, transport); - let resources = renderer.resources_mut(); - if let Err(error) = resources - .pipelines - .prepare_smoothing(&resources.device, requirements.smoothing) - { - let message = format!("creating smoothing pipelines: {error}"); - if matches!( - error, - wgpu::Error::OutOfMemory { .. } | wgpu::Error::Internal { .. } - ) { - *renderer.faults.fatal_error.lock().unwrap() = Some(message.clone()); - } - // Let the next begin_frame consume this error. Handling it here and - // clearing the slot would reset the retry counter at the next frame. - *renderer.faults.pending_error.lock().unwrap() = Some(message); - renderer.retained.complete(retained, false); - renderer.target.request_redraw(); - return None; - } - let mut targets = PreparedTargets::prepare(renderer, scene, frame_view, &requirements)?; - // The retained texture matches the target by construction. The frame - // texture may not: it can predate a resize or lack COPY_DST. - let retained_copy = (retained.copy_texture.take()) - .filter(|source| { - targets.offscreen.is_none() - && frame_texture - .usage() - .contains(wgpu::TextureUsages::COPY_DST) - && frame_texture.size() == source.size() - && frame_texture.format() == source.format() - }) - .map(|source| (source, frame_texture.clone())); - if let Some(view) = &retained.view { - targets.active = view.clone(); - // Create the overwrite-clear PSO with the target's first full frame, so - // its first partial frame doesn't compile a pipeline on hover. - if retained.damage != Damage::Reuse { - let device = renderer.resources().device.clone(); - renderer - .resources_mut() - .pipelines - .ensure_damage_clear(&device); - } - } - // Prune once against the complete scene. Doing this inside each draw batch would evict a - // texture that reappears after an intervening primitive and recreate its platform view. - renderer.retain_surface_cache(&scene.surfaces); - - let retained_blit = retained.blit.take().filter(|_| retained_copy.is_none()); - #[cfg(test)] - { - renderer.last_retained_presentation = if retained_copy.is_some() { - "copy" - } else if retained_blit.is_some() { - "blit" - } else { - "direct" - }; - } - let result = FrameEncoder::new( - renderer, - scene, - targets, - retained.damage, - retained_blit, - retained_copy, - ) - .encode(readback); - renderer.retained.complete(retained, result.is_ok()); - match result { - Ok(command_buffer) => { - let submission = renderer.resources().queue.submit([command_buffer]); - // A cleanup skipped a frame. Only a real submission without a new validation - // error can reset that retry budget; merely starting the next frame cannot. - if renderer.faults.cleaned_up_failed_frames - && renderer.faults.pending_error.lock().unwrap().is_none() - { - renderer.faults.consecutive_failed_frames = 0; - renderer.faults.cleaned_up_failed_frames = false; - } - Some(submission) - } - Err(DrawError::ExternalSurface) => None, - Err(DrawError::CapacityPlanningInvariant) => { - log::error!("frame storage exceeded its precomputed capacity"); - None - } - Err(DrawError::MissingIntermediateTarget) => { - log::error!("frame preparation did not create a required intermediate target"); - None - } - } -} - -pub(super) struct ReadbackCopy<'a> { - pub(super) texture: &'a wgpu::Texture, - pub(super) buffer: &'a wgpu::Buffer, - pub(super) bytes_per_row: u32, - pub(super) width: u32, - pub(super) height: u32, -} - -struct PreparedTargets { - active: wgpu::TextureView, - presentation: wgpu::TextureView, - offscreen: Option, - instances: InstanceUpload, -} - -impl PreparedTargets { - fn prepare( - renderer: &mut WgpuRenderer, - scene: &Scene, - frame_view: &wgpu::TextureView, - requirements: &FrameRequirements, - ) -> Option { - let device = renderer.resources().device.clone(); - let resources = renderer.resources_mut(); - if !resources.instances.ensure_capacity( - &device, - &resources.bind_group_layouts, - requirements.storage_bytes, - requirements.instance_batches, - ) { - return None; - } - let instances = { - let resources = renderer.resources(); - resources.instances.begin_upload( - &resources.queue, - requirements.storage_bytes, - requirements.instance_batches, - )? - }; - if !renderer.ensure_uniform_capacity(requirements.uniforms) { - return None; - } - - if requirements.planned.uses_path_target { - renderer.ensure_path_textures(scene); - } - if requirements.planned.uses_offscreen_target { - renderer.ensure_filter_textures(&requirements.planned); - } - write_shader_globals(renderer); - - if requirements.planned.uses_offscreen_target { - let resources = renderer.resources(); - let offscreen = resources - .scene_color_view - .as_ref() - .expect("blur texture preparation must create a scene target") - .clone(); - Some(Self { - active: offscreen.clone(), - presentation: frame_view.clone(), - offscreen: Some(offscreen), - instances, - }) - } else { - Some(Self { - active: frame_view.clone(), - presentation: frame_view.clone(), - offscreen: None, - instances, - }) - } - } -} - -fn begin_frame(renderer: &mut WgpuRenderer) -> bool { - // Release the callback mutex before atlas uploads can report another GPU error. - let pending_error = renderer.faults.pending_error.lock().unwrap().take(); - let Some(error) = pending_error else { - if !renderer.faults.cleaned_up_failed_frames { - renderer.faults.consecutive_failed_frames = 0; - } - renderer.atlas.before_frame(); - return true; - }; - - handle_gpu_error(renderer, error) -} - -pub(super) fn handle_gpu_error(renderer: &mut WgpuRenderer, error: String) -> bool { - renderer.release_retained_frame(); - renderer.faults.consecutive_failed_frames += 1; - log::error!( - "GPU error during frame (failure {} of 10): {error}", - renderer.faults.consecutive_failed_frames - ); - if renderer.faults.consecutive_failed_frames > 10 { - if renderer.reports_terminal_errors() { - renderer.faults.terminal_error = Some(format!( - "too many consecutive GPU errors; last error: {error}" - )); - return false; - } - panic!("too many consecutive GPU errors; last error: {error}"); - } - if renderer.faults.consecutive_failed_frames > 5 && !renderer.faults.cleaned_up_failed_frames { - if let Some(resources) = renderer.resources.as_mut() { - resources.invalidate_intermediate_textures(); - } - renderer.atlas.clear(); - renderer.target.request_redraw(); - renderer.faults.cleaned_up_failed_frames = true; - if !renderer.reports_terminal_errors() { - // Preserve the existing retry policy for other platform integrations. - renderer.faults.consecutive_failed_frames = 0; - renderer.faults.cleaned_up_failed_frames = false; - } - return false; - } - - renderer.atlas.before_frame(); - true -} - -#[cfg(all(test, feature = "test-support", target_os = "windows"))] -mod failure_tests { - use super::*; - - #[test] - fn persistent_dx12_errors_become_terminal_and_a_healthy_frame_resets_the_count() { - let context = crate::WgpuContext::new_headless(None).unwrap(); - let mut renderer = WgpuRenderer::new_headless( - &context, - gpui::size(gpui::DevicePixels(16), gpui::DevicePixels(16)), - ) - .unwrap(); - for _ in 0..18 { - handle_gpu_error(&mut renderer, "injected validation failure".into()); - } - assert_eq!(renderer.faults.consecutive_failed_frames, 0); - assert!(renderer.terminal_error().is_none()); - renderer.native_backend = Some(crate::NativeBackend::Dx12); - for _ in 0..6 { - handle_gpu_error(&mut renderer, "injected validation failure".into()); - } - assert!(renderer.faults.cleaned_up_failed_frames); - assert!(begin_frame(&mut renderer)); - assert_eq!(renderer.faults.consecutive_failed_frames, 6); - let mut scene = Scene::default(); - scene.finish(); - renderer.render_to_image(&scene).unwrap(); - assert_eq!(renderer.faults.consecutive_failed_frames, 0); - for _ in 0..10 { - handle_gpu_error(&mut renderer, "injected validation failure".into()); - } - assert!(renderer.terminal_error().is_none()); - assert!(!handle_gpu_error( - &mut renderer, - "injected validation failure".into() - )); - assert!( - renderer - .terminal_error() - .unwrap() - .contains("validation failure") - ); - // The error can be consumed without holding a callback/GPU mutex across application notification. - assert!(renderer.faults.fatal_error.try_lock().is_ok()); - } -} - -#[derive(Clone, Copy, PartialEq)] -pub(super) struct GlobalUniformState { - globals: GlobalUniforms, - path_globals: GlobalUniforms, - font_rasterization: FontRasterizationUniforms, -} - -fn write_shader_globals(renderer: &mut WgpuRenderer) { - let font = renderer.rendering_params.font_rasterization; - let font_rasterization = FontRasterizationUniforms { - gamma_ratios: wgsl_rs::std::vec4f( - font.gamma_ratios[0], - font.gamma_ratios[1], - font.gamma_ratios[2], - font.gamma_ratios[3], - ), - grayscale_enhanced_contrast: font.grayscale_enhanced_contrast, - subpixel_enhanced_contrast: font.subpixel_enhanced_contrast, - uses_blue_green_red_subpixel_order: ShaderBool::from( - renderer.subpixel_order == super::SubpixelOrder::BlueGreenRed, - ), - padding: 0, - }; - let globals = GlobalUniforms { - viewport_size: wgsl_rs::std::vec2f( - renderer.target.width() as f32, - renderer.target.height() as f32, - ), - premultiplied_alpha: ShaderBool::from( - renderer.target.alpha_mode() == wgpu::CompositeAlphaMode::PreMultiplied, - ), - padding: 0, - }; - let path_globals = GlobalUniforms { - viewport_size: renderer - .resources() - .path_intermediate_texture - .as_ref() - .map(|texture| wgsl_rs::std::vec2f(texture.width() as f32, texture.height() as f32)) - .unwrap_or(globals.viewport_size), - premultiplied_alpha: ShaderBool::Disabled, - ..globals - }; - let state = GlobalUniformState { - globals, - path_globals, - font_rasterization, - }; - if renderer.uploaded_globals == Some(state) { - return; - } - - let resources = renderer.resources(); - let globals_size = std::mem::size_of::(); - let font_size = std::mem::size_of::(); - let upload_size = renderer.globals.font_offset + font_size as u64; - let mut upload = resources - .queue - .write_buffer_with( - &resources.globals_buffer, - 0, - std::num::NonZeroU64::new(upload_size).expect("global uniforms are non-empty"), - ) - .expect("global uniform upload must fit its buffer"); - upload.slice(..).fill(0); - upload - .slice(..globals_size) - .copy_from_slice(shader_interface::bytes_of(&globals)); - upload - .slice( - renderer.globals.path_offset as usize - ..renderer.globals.path_offset as usize + globals_size, - ) - .copy_from_slice(shader_interface::bytes_of(&path_globals)); - upload - .slice( - renderer.globals.font_offset as usize - ..renderer.globals.font_offset as usize + font_size, - ) - .copy_from_slice(shader_interface::bytes_of(&font_rasterization)); - drop(upload); - renderer.uploaded_globals = Some(state); -} - -#[derive(Clone, Copy, Default)] -pub(super) struct FrameRequirements { - storage_bytes: u64, - /// Instance batches this frame; one downlevel range-uniform slot per batch. - instance_batches: u64, - pub(super) uniforms: FrameUniformRequirements, - planned: ScenePlanRequirements, - smoothing: SmoothingRequirements, -} - -impl FrameRequirements { - /// What a frame uploads: the whole scene, the batches a partial frame - /// repaints, or nothing when it reuses the retained frame. - fn for_damage(scene: &Scene, damage: &Damage, transport: InstanceTransport) -> Self { - match damage { - Damage::Full => Self::for_scene(scene, transport), - Damage::Partial(region) => Self::for_partial_scene(scene, *region, transport), - Damage::Reuse => Self::default(), - } - } - - pub(super) fn for_scene(scene: &Scene, transport: InstanceTransport) -> Self { - let planned = scene.render_plan().requirements(); - let mut requirements = Self::scene_metadata(scene); - - for command in scene.render_commands() { - if let RenderCommand::Batch(batch) = command { - requirements.reserve_batch(batch, transport); - } - requirements.include_filter_smoothing(scene, command); - } - debug_assert_eq!( - requirements.instance_batches as usize, - planned.instance_batch_count - ); - - requirements - } - - fn scene_metadata(scene: &Scene) -> Self { - let planned = scene.render_plan().requirements(); - Self { - uniforms: FrameUniformRequirements { - filter_count: FILTER_UNIFORMS_PER_COMPOSITE - * (planned.backdrop_filter_count + planned.isolated_filter_count) as u64 - + u64::from(planned.uses_offscreen_target) - + if planned.isolated_target_count > 0 { - (planned.backdrop_filter_count * (planned.isolated_target_count + 1)) as u64 - } else { - 0 - }, - surface_count: planned.surface_count as u64, - }, - planned: *planned, - ..Default::default() - } - } - - fn for_partial_scene( - scene: &Scene, - region: ScissorRectangle, - transport: InstanceTransport, - ) -> Self { - let mut requirements = Self::scene_metadata(scene); - // Encoding prepends a clear and skips nonintersecting batches. Either - // changes the alignment of every following type, so a full-frame - // estimate can't be reused. - requirements.reserve(std::mem::size_of::(), 1, transport); - for command in scene.render_commands() { - if let RenderCommand::Batch(batch) = command { - if !batch_intersects(scene, batch, region) { - continue; - } - requirements.reserve_batch(batch, transport); - } - requirements.include_filter_smoothing(scene, command); - } - requirements - } - - fn include_filter_smoothing(&mut self, scene: &Scene, command: &RenderCommand) { - if self.smoothing.blur_composite { - return; - } - self.smoothing.blur_composite = match command { - RenderCommand::Batch(PrimitiveBatch::BackdropFilters(range)) => scene.backdrop_filters - [range.clone()] - .iter() - .any(|filter| filter.corner_smoothing > 0.0 && filter.max_blur_radius() > 0.0), - RenderCommand::EndFilter { - boundary_index, - target: FilterRenderTarget::Isolated(_), - .. - } => { - let boundary = &scene.filter_boundaries[*boundary_index]; - // Isolated groups composite even without blur (for example, opacity - // groups), and select the smoothing variant from this value alone. - boundary.corner_smoothing > 0.0 - } - _ => false, - }; - } - - fn reserve(&mut self, element_size: usize, count: usize, transport: InstanceTransport) { - if count > 0 { - let stride = element_size as u64; - self.storage_bytes = self - .storage_bytes - .next_multiple_of(transport.batch_alignment(stride)); - self.storage_bytes = self - .storage_bytes - .saturating_add(stride.saturating_mul(count as u64)); - self.instance_batches += 1; - } - } - - fn reserve_batch(&mut self, batch: &PrimitiveBatch, transport: InstanceTransport) { - match batch { - PrimitiveBatch::Shadows { range, smoothed } => { - self.smoothing.shadows |= *smoothed && !range.is_empty(); - self.reserve(std::mem::size_of::(), range.len(), transport) - } - PrimitiveBatch::Quads { range, smoothed } => { - self.smoothing.quads |= *smoothed && !range.is_empty(); - self.reserve(std::mem::size_of::(), range.len(), transport) - } - PrimitiveBatch::Paths { - rasterization_vertex_count, - sprite_count, - .. - } if *rasterization_vertex_count > 0 => { - self.reserve( - std::mem::size_of::(), - *rasterization_vertex_count, - transport, - ); - self.reserve( - std::mem::size_of::(), - *sprite_count, - transport, - ); - } - PrimitiveBatch::Underlines(range) => { - self.reserve(std::mem::size_of::(), range.len(), transport) - } - PrimitiveBatch::MonochromeSprites { range, .. } => self.reserve( - std::mem::size_of::(), - range.len(), - transport, - ), - PrimitiveBatch::SubpixelSprites { range, .. } => self.reserve( - std::mem::size_of::(), - range.len(), - transport, - ), - PrimitiveBatch::PolychromeSprites { - range, smoothed, .. - } => { - self.smoothing.polychrome_sprites |= *smoothed && !range.is_empty(); - self.reserve( - std::mem::size_of::(), - range.len(), - transport, - ) - } - PrimitiveBatch::Paths { .. } - | PrimitiveBatch::Surfaces(_) - | PrimitiveBatch::BackdropFilters(_) - | PrimitiveBatch::FilterBoundary(_) => {} - } - } -} - -#[cfg(test)] -mod planning_tests { - use super::*; - - #[test] - fn partial_alignment_includes_the_clear() { - let transport = InstanceTransport::StorageBuffer; - let sequence = [ - (std::mem::size_of::(), 1), - (std::mem::size_of::(), 1), - (std::mem::size_of::(), 1), - (std::mem::size_of::(), 1), - ( - std::mem::size_of::(), - 3, - ), - (std::mem::size_of::(), 1), - (std::mem::size_of::(), 1), - (std::mem::size_of::(), 1), - ]; - let mut full = FrameRequirements::default(); - for &(stride, count) in &sequence { - full.reserve(stride, count, transport); - } - assert_eq!(full.storage_bytes, 1984); - let old_estimate = full.storage_bytes + 2 * std::mem::size_of::() as u64; - assert_eq!(old_estimate, 2480); - let mut partial = FrameRequirements::default(); - partial.reserve(std::mem::size_of::(), 1, transport); - for &(stride, count) in &sequence { - partial.reserve(stride, count, transport); - } - assert_eq!(partial.storage_bytes, 2728); - assert_eq!(partial.instance_batches, 9); - assert!(partial.storage_bytes > old_estimate); - } - - #[test] - fn partial_planner_counts_only_intersecting_batches_and_clear_slots() { - use gpui::{Bounds, ContentMask, ScaledPixels, point, size}; - let bounds = |x, y| { - Bounds::new( - point(ScaledPixels(x), ScaledPixels(y)), - size(ScaledPixels(10.0), ScaledPixels(10.0)), - ) - }; - let mask = ContentMask { - bounds: Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(100.0), ScaledPixels(100.0)), - ), - ..Default::default() - }; - let mut scene = Scene::default(); - for (index, (x, y)) in [(0.0, 0.0), (20.0, 20.0)].into_iter().enumerate() { - scene.insert_primitive(Quad { - bounds: bounds(x, y), - content_mask: mask, - corner_smoothing: if index == 0 { 0.0 } else { 0.65 }, - ..Default::default() - }); - } - scene.finish(); - let region = |x, y| ScissorRectangle { - x, - y, - width: 5, - height: 5, - }; - for transport in [ - InstanceTransport::StorageBuffer, - InstanceTransport::DataTexture, - ] { - // The clear, then only the batch under the region: the second quad's. - let requirements = - FrameRequirements::for_partial_scene(&scene, region(24, 24), transport); - let mut expected = FrameRequirements::default(); - expected.reserve(std::mem::size_of::(), 1, transport); - expected.reserve(std::mem::size_of::(), 1, transport); - assert_eq!(requirements.instance_batches, 2); - assert_eq!(requirements.storage_bytes, expected.storage_bytes); - assert!(!requirements.planned.uses_path_target); - assert!(!requirements.planned.uses_offscreen_target); - assert!(requirements.smoothing.quads); - assert_eq!( - FrameRequirements::for_partial_scene(&scene, region(2, 2), transport).smoothing, - SmoothingRequirements::default(), - "an ordinary batch must not prepare the distant smoothing pipeline" - ); - // A region over neither quad replays only the clear. - let empty = FrameRequirements::for_partial_scene(&scene, region(60, 60), transport); - assert_eq!(empty.instance_batches, 1); - assert_eq!(empty.smoothing, SmoothingRequirements::default()); - // A frame uploads once, sized by its damage; a reused frame uploads nothing. - let partial = - FrameRequirements::for_damage(&scene, &Damage::Partial(region(24, 24)), transport); - assert_eq!(partial.storage_bytes, requirements.storage_bytes); - let reuse = FrameRequirements::for_damage(&scene, &Damage::Reuse, transport); - assert_eq!((reuse.storage_bytes, reuse.instance_batches), (0, 0)); - assert_eq!(reuse.smoothing, SmoothingRequirements::default()); - } - } - - #[test] - fn smoothing_planner_includes_backdrop_and_isolated_filter_composites() { - use gpui::{ - BackdropFilter, Bounds, ContentMask, Corners, FilterBoundary, ScaledFilter, - ScaledPixels, point, size, - }; - let bounds = Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(40.0), ScaledPixels(40.0)), - ); - let content_mask = ContentMask { - bounds, - ..Default::default() - }; - for isolated in [false, true] { - for (smoothing, radius, expected) in - [(0.0, 4.0, false), (0.7, 0.0, isolated), (0.7, 4.0, true)] - { - let mut scene = Scene::default(); - let filters = smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(radius))]; - if isolated { - let boundary = FilterBoundary { - order: 0, - bounds, - content_mask, - corner_radii: Corners::all(ScaledPixels(8.0)), - corner_smoothing: smoothing, - filters, - opacity: 1.0, - is_start: true, - }; - scene.insert_primitive(boundary.clone()); - scene.raise_order_floor(); - scene.insert_primitive(Quad { - bounds, - content_mask, - background: gpui::white().into(), - ..Default::default() - }); - scene.raise_order_floor(); - scene.insert_primitive(FilterBoundary { - is_start: false, - ..boundary - }); - } else { - scene.insert_primitive(BackdropFilter { - order: 0, - bounds, - content_mask, - corner_radii: Corners::all(ScaledPixels(8.0)), - corner_smoothing: smoothing, - filters, - opacity: 1.0, - }); - } - scene.finish(); - let required = - FrameRequirements::for_scene(&scene, InstanceTransport::StorageBuffer); - assert_eq!( - required.smoothing.blur_composite, expected, - "isolated={isolated}, smoothing={smoothing}, radius={radius}" - ); - assert!(!required.smoothing.quads); - assert_eq!( - FrameRequirements::for_damage( - &scene, - &Damage::Reuse, - InstanceTransport::StorageBuffer - ) - .smoothing, - SmoothingRequirements::default() - ); - } - } - } -} - -struct FrameEncoder<'a> { - renderer: &'a WgpuRenderer, - scene: &'a Scene, - encoder: wgpu::CommandEncoder, - targets: TargetStack, - offscreen: Option, - presentation: wgpu::TextureView, - instances: InstanceUpload, - damage: Damage, - retained_blit: Option, - retained_copy: Option<(wgpu::Texture, wgpu::Texture)>, -} - -impl<'a> FrameEncoder<'a> { - fn new( - renderer: &'a WgpuRenderer, - scene: &'a Scene, - targets: PreparedTargets, - damage: Damage, - retained_blit: Option, - retained_copy: Option<(wgpu::Texture, wgpu::Texture)>, - ) -> Self { - let encoder = - renderer - .resources() - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpui_frame"), - }); - Self { - renderer, - scene, - encoder, - targets: TargetStack::new(targets.active), - offscreen: targets.offscreen, - presentation: targets.presentation, - instances: targets.instances, - damage, - retained_blit, - retained_copy, - } - } - - fn encode( - mut self, - readback: Option>, - ) -> Result { - let result = self.encode_commands(); - if result.is_ok() { - if let Some((source, destination)) = &self.retained_copy { - self.encoder.copy_texture_to_texture( - source.as_image_copy(), - destination.as_image_copy(), - wgpu::Extent3d { - width: self.renderer.target.width(), - height: self.renderer.target.height(), - depth_or_array_layers: 1, - }, - ); - } - if let Some(bind_group) = &self.retained_blit { - self.renderer - .draw_blit(&mut self.encoder, bind_group, 0, &self.presentation); - } - if let Some(offscreen) = &self.offscreen { - self.renderer - .blit_to_frame(&mut self.encoder, offscreen, &self.presentation); - } - if let Some(readback) = readback { - self.encoder.copy_texture_to_buffer( - readback.texture.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: readback.buffer, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(readback.bytes_per_row), - rows_per_image: Some(readback.height), - }, - }, - wgpu::Extent3d { - width: readback.width, - height: readback.height, - depth_or_array_layers: 1, - }, - ); - } - } - self.instances.finish(&mut self.encoder); - self.renderer.resources().finish_frame_uploads(); - let command_buffer = self.encoder.finish(); - result.map(|()| command_buffer) - } - - fn encode_commands(&mut self) -> DrawResult { - match std::mem::replace(&mut self.damage, Damage::Reuse) { - Damage::Reuse => Ok(()), - Damage::Partial(region) => self.encode_region(Some(region)), - Damage::Full => self.encode_region(None), - } - } - fn encode_region(&mut self, region: Option) -> DrawResult { - let mut pass = begin_scene_render_pass( - self.renderer, - &mut self.encoder, - "main_pass", - self.targets.current(), - if region.is_some() { - wgpu::LoadOp::Load - } else { - wgpu::LoadOp::Clear(self.renderer.target.clear_color()) - }, - region, - ); - if let Some(region) = region { - let clear = self.renderer.target.clear_color(); - let clear = [clear.r, clear.g, clear.b, clear.a].map(|channel| channel as f32); - self.renderer.draw_damage_clear( - &[clear_quad(region, clear)], - &mut self.instances, - &mut pass, - )?; - } - - for command in self.scene.render_commands() { - if let (Some(region), RenderCommand::Batch(batch)) = (region, command) { - if !batch_intersects(self.scene, batch, region) { - continue; - } - } - match command { - RenderCommand::Batch(PrimitiveBatch::Paths { - range, - rasterization_vertex_count, - .. - }) => { - if *rasterization_vertex_count == 0 { - continue; - } - let paths = &self.scene.paths[range.clone()]; - drop(pass); - let rasterized = self.renderer.draw_paths_to_intermediate( - &mut self.encoder, - paths, - &mut self.instances, - ); - pass = begin_scene_render_pass( - self.renderer, - &mut self.encoder, - "after_paths", - self.targets.current(), - wgpu::LoadOp::Load, - region, - ); - rasterized?; - self.renderer.draw_paths_from_intermediate( - paths, - &mut self.instances, - &mut pass, - )?; - } - RenderCommand::Batch(PrimitiveBatch::BackdropFilters(range)) => { - drop(pass); - for filter in &self.scene.backdrop_filters[range.clone()] { - let snapshot = self.targets.inherits_backdrop().then(|| { - self.renderer.snapshot_backdrop( - &mut self.encoder, - self.targets.backdrop_layers(), - ) - }); - let source = snapshot.as_ref().unwrap_or_else(|| self.targets.current()); - self.renderer.blur_and_composite( - &mut self.encoder, - source, - self.targets.current(), - FilterCompositeParameters { - bounds: filter.bounds, - content_mask: filter.content_mask.bounds, - corner_radii: filter.corner_radii, - corner_smoothing: filter.corner_smoothing, - blur_radius: filter.max_blur_radius(), - opacity: filter.opacity, - clip: FilterCompositeClip::RoundedBounds, - }, - ); - } - pass = begin_scene_render_pass( - self.renderer, - &mut self.encoder, - "after_backdrop_filter", - self.targets.current(), - wgpu::LoadOp::Load, - region, - ); - } - RenderCommand::Batch(PrimitiveBatch::FilterBoundary(_)) => { - unreachable!("filter boundaries must be compiled into render commands") - } - RenderCommand::Batch(batch) => encode_inline_batch( - self.renderer, - self.scene, - batch, - &mut self.instances, - &mut pass, - )?, - RenderCommand::BeginFilter { - boundary_index, - target: FilterRenderTarget::Isolated(index), - } => { - drop(pass); - let target = - self.renderer.resources().filter_group_views[index.as_usize()].clone(); - self.targets.enter( - target, - self.scene.filter_boundaries[*boundary_index] - .filters - .is_empty(), - ); - pass = begin_scene_render_pass( - self.renderer, - &mut self.encoder, - "filter_group", - self.targets.current(), - wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - region, - ); - } - RenderCommand::EndFilter { - boundary_index, - target: FilterRenderTarget::Isolated(_), - .. - } => { - drop(pass); - let (filtered, parent) = self.targets.exit(); - let boundary = &self.scene.filter_boundaries[*boundary_index]; - self.renderer.blur_and_composite( - &mut self.encoder, - &filtered, - parent, - FilterCompositeParameters { - bounds: boundary.bounds, - content_mask: boundary.content_mask.bounds, - corner_radii: boundary.corner_radii, - corner_smoothing: boundary.corner_smoothing, - blur_radius: boundary.max_blur_radius(), - opacity: boundary.opacity, - clip: FilterCompositeClip::ContentShape, - }, - ); - pass = begin_scene_render_pass( - self.renderer, - &mut self.encoder, - "after_content_filter", - self.targets.current(), - wgpu::LoadOp::Load, - region, - ); - } - RenderCommand::BeginFilter { - target: FilterRenderTarget::Inline, - .. - } - | RenderCommand::EndFilter { - target: FilterRenderTarget::Inline, - .. - } => {} - } - } - drop(pass); - self.targets.assert_balanced(); - Ok(()) - } -} - -fn begin_scene_render_pass<'a>( - renderer: &'a WgpuRenderer, - encoder: &'a mut wgpu::CommandEncoder, - label: &'a str, - target: &'a wgpu::TextureView, - load: wgpu::LoadOp, - region: Option, -) -> wgpu::RenderPass<'a> { - let mut pass = begin_color_render_pass(encoder, label, target, load); - if let Some(region) = region { - pass.set_scissor_rect(region.x, region.y, region.width, region.height); - } - pass.set_bind_group( - shader_interface::GLOBAL_BIND_GROUP, - &renderer.resources().globals_bind_group, - &[], - ); - pass -} - -#[derive(Debug)] -pub(super) enum DrawError { - CapacityPlanningInvariant, - ExternalSurface, - MissingIntermediateTarget, -} - -pub(super) type DrawResult = Result<(), DrawError>; - -fn encode_inline_batch( - renderer: &WgpuRenderer, - scene: &Scene, - batch: &PrimitiveBatch, - instances: &mut InstanceUpload, - pass: &mut wgpu::RenderPass<'_>, -) -> DrawResult { - match batch { - PrimitiveBatch::Quads { range, smoothed } => { - renderer.draw_quads(&scene.quads[range.clone()], *smoothed, instances, pass) - } - PrimitiveBatch::Shadows { range, smoothed } => { - renderer.draw_shadows(&scene.shadows[range.clone()], *smoothed, instances, pass) - } - PrimitiveBatch::Underlines(range) => { - renderer.draw_underlines(&scene.underlines[range.clone()], instances, pass) - } - PrimitiveBatch::MonochromeSprites { texture_id, range } => renderer - .draw_monochrome_sprites( - &scene.monochrome_sprites[range.clone()], - *texture_id, - instances, - pass, - ), - PrimitiveBatch::SubpixelSprites { texture_id, range } => renderer.draw_subpixel_sprites( - &scene.subpixel_sprites[range.clone()], - *texture_id, - instances, - pass, - ), - PrimitiveBatch::PolychromeSprites { - texture_id, - range, - smoothed, - } => renderer.draw_polychrome_sprites( - &scene.polychrome_sprites[range.clone()], - *texture_id, - *smoothed, - instances, - pass, - ), - PrimitiveBatch::Surfaces(range) => { - renderer.draw_surfaces(&scene.surfaces[range.clone()], pass) - } - PrimitiveBatch::Paths { .. } - | PrimitiveBatch::BackdropFilters(_) - | PrimitiveBatch::FilterBoundary(_) => { - unreachable!("pass-interrupting batches are handled by FrameEncoder") - } - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs b/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs deleted file mode 100644 index fdb26bd27..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/headless.rs +++ /dev/null @@ -1,2203 +0,0 @@ -use std::sync::Arc; - -use gpui::{DevicePixels, Scene, Size}; - -use crate::{WgpuAtlas, WgpuContext}; - -use super::{WgpuRenderer, WgpuSurfaceConfig}; - -struct OffscreenTarget { - texture: wgpu::Texture, - view: wgpu::TextureView, - readback: wgpu::Buffer, - padded_bytes_per_row: u32, - size: Size, -} - -impl WgpuRenderer { - pub(super) fn new_headless( - context: &WgpuContext, - size: Size, - ) -> anyhow::Result { - let mut renderer = Self::new_internal( - None, - context, - None, - WgpuSurfaceConfig { - size, - transparent: false, - preferred_present_mode: None, - }, - None, - None, - Arc::new(WgpuAtlas::from_context(context)), - )?; - - renderer.target.clear_color = wgpu::Color::BLACK; - - Ok(renderer) - } - - fn create_offscreen_target(&self) -> OffscreenTarget { - let width = self.target.width(); - let height = self.target.height(); - let padded_bytes_per_row = (width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); - let texture = self - .resources() - .device - .create_texture(&wgpu::TextureDescriptor { - label: Some("gpui_offscreen_target"), - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: self.target.format(), - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC - | if self.target.retained_copy_enabled() { - wgpu::TextureUsages::COPY_DST - } else { - wgpu::TextureUsages::empty() - }, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let readback = self - .resources() - .device - .create_buffer(&wgpu::BufferDescriptor { - label: Some("gpui_offscreen_readback"), - size: u64::from(padded_bytes_per_row) * u64::from(height), - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - OffscreenTarget { - texture, - view, - readback, - padded_bytes_per_row, - size: self.viewport_size(), - } - } - - fn read_offscreen_target( - &self, - target: &OffscreenTarget, - submission: wgpu::SubmissionIndex, - ) -> anyhow::Result { - let width = target.size.width.0 as u32; - let height = target.size.height.0 as u32; - let bytes_per_row = width * 4; - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - target - .readback - .slice(..) - .map_async(wgpu::MapMode::Read, move |result| { - let _ = sender.send(result); - }); - self.resources() - .device - .poll(wgpu::PollType::Wait { - submission_index: Some(submission), - timeout: None, - }) - .map_err(|error| anyhow::anyhow!("failed to poll offscreen readback: {error}"))?; - receiver - .recv() - .map_err(|error| anyhow::anyhow!("offscreen readback callback dropped: {error}"))? - .map_err(|error| anyhow::anyhow!("failed to map offscreen readback: {error}"))?; - - let mapped = target.readback.slice(..).get_mapped_range(); - let mut pixels = Vec::with_capacity(bytes_per_row as usize * height as usize); - for row in mapped.chunks_exact(target.padded_bytes_per_row as usize) { - pixels.extend_from_slice(&row[..bytes_per_row as usize]); - } - drop(mapped); - target.readback.unmap(); - if self.target.format() == wgpu::TextureFormat::Bgra8Unorm { - for pixel in pixels.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - } - image::RgbaImage::from_raw(width, height, pixels) - .ok_or_else(|| anyhow::anyhow!("offscreen readback dimensions did not match its data")) - } - - /// Renders through the normal scene path and reads back without presenting. - pub fn render_to_image(&mut self, scene: &Scene) -> anyhow::Result { - let target = self.create_offscreen_target(); - let submission = self - .render_to_view_with_readback(scene, &target.view, target.readback_copy()) - .ok_or_else(|| anyhow::anyhow!("failed to render scene into the offscreen target"))?; - self.read_offscreen_target(&target, submission) - } -} - -impl OffscreenTarget { - fn readback_copy(&self) -> super::frame::ReadbackCopy<'_> { - super::frame::ReadbackCopy { - texture: &self.texture, - buffer: &self.readback, - bytes_per_row: self.padded_bytes_per_row, - width: self.size.width.0 as u32, - height: self.size.height.0 as u32, - } - } -} - -/// Surface-free renderer used by GPUI visual tests and benchmarks. -pub struct WgpuHeadlessRenderer { - renderer: WgpuRenderer, - target: Option, -} - -impl WgpuHeadlessRenderer { - pub fn new() -> anyhow::Result { - let context = WgpuContext::new_headless(None)?; - let renderer = WgpuRenderer::new_headless( - &context, - Size { - width: DevicePixels(1), - height: DevicePixels(1), - }, - )?; - Ok(Self { - renderer, - target: None, - }) - } - - fn ensure_target(&mut self, size: Size) -> anyhow::Result<()> { - anyhow::ensure!( - size.width.0 > 0 && size.height.0 > 0, - "headless render target must have positive dimensions" - ); - if self - .target - .as_ref() - .is_some_and(|target| target.size == size) - { - return Ok(()); - } - self.renderer.update_drawable_size(size); - self.target = Some(self.renderer.create_offscreen_target()); - Ok(()) - } - - /// Renders through the normal submission path and waits for that work to finish. - pub fn render_scene_and_wait( - &mut self, - scene: &Scene, - size: Size, - ) -> anyhow::Result<()> { - self.ensure_target(size)?; - let target = self.target.as_ref().expect("target was just ensured"); - let submission = super::frame::render_to_view( - &mut self.renderer, - scene, - &target.view, - &target.texture, - None, - ) - .ok_or_else(|| anyhow::anyhow!("failed to render headless scene"))?; - self.renderer - .resources() - .device - .poll(wgpu::PollType::Wait { - submission_index: Some(submission), - timeout: None, - }) - .map_err(|error| anyhow::anyhow!("failed to wait for headless render: {error}"))?; - Ok(()) - } -} - -impl gpui::PlatformHeadlessRenderer for WgpuHeadlessRenderer { - fn render_scene_to_image( - &mut self, - scene: &Scene, - size: Size, - ) -> anyhow::Result { - self.ensure_target(size)?; - let target = self.target.as_ref().expect("target was just ensured"); - let submission = self - .renderer - .render_to_view_with_readback(scene, &target.view, target.readback_copy()) - .ok_or_else(|| anyhow::anyhow!("failed to render headless scene"))?; - self.renderer.read_offscreen_target(target, submission) - } - - fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { - self.ensure_target(size)?; - let target = self.target.as_ref().expect("target was just ensured"); - anyhow::ensure!( - self.renderer - .render_to_texture(scene, &target.texture, &target.view), - "failed to render headless scene" - ); - Ok(()) - } - - fn sprite_atlas(&self) -> Arc { - self.renderer.sprite_atlas().clone() - } -} - -#[cfg(test)] -mod path_target_tests { - use super::*; - use gpui::{ - AtlasKey, AtlasTile, Bounds, ContentMask, FontId, GlyphId, GlyphRenderMode, - MonochromeSprite, PathBuilder, PlatformAtlas, PreparedRasterStyle, Quad, - RasterizedGlyphFormat, RenderGlyphParams, ScaledPixels, Shadow, point, px, size, - solid_background, - }; - use std::borrow::Cow; - - fn scaled(x: f32, y: f32, width: f32, height: f32) -> Bounds { - Bounds::new( - point(ScaledPixels(x), ScaledPixels(y)), - size(ScaledPixels(width), ScaledPixels(height)), - ) - } - fn full_mask() -> ContentMask { - ContentMask { - bounds: scaled(0.0, 0.0, 200.0, 100.0), - ..Default::default() - } - } - - /// A clipped quad covers the same pixels as a straight underline, which - /// only fragment masking clips. Both follow a rasterized rectangle's rule - /// at the mask: a pixel centered on its left or top edge is inside, on its - /// right or bottom edge outside. - #[test] - fn clipped_quads_and_underlines_share_mask_edge_pixels() -> anyhow::Result<()> { - fn render( - renderer: &mut WgpuRenderer, - primitive: gpui::Primitive, - ) -> anyhow::Result { - let mut scene = Scene::default(); - scene.insert_primitive(primitive); - scene.finish(); - renderer.render_to_image(&scene) - } - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let mut renderer = - WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; - renderer.retained.disable_for_tests(); - let geometry = scaled(4.0, 4.0, 56.0, 56.0); - // Every edge runs through pixel centers, at 8.5 and 24.5. - let mask = ContentMask { - bounds: scaled(8.5, 8.5, 16.0, 16.0), - ..Default::default() - }; - let quad = Quad { - bounds: geometry, - content_mask: mask, - background: solid_background(gpui::white()), - ..Default::default() - }; - let underline = gpui::Underline { - order: 0, - padding: 0, - bounds: geometry, - content_mask: mask, - color: gpui::white().into(), - thickness: ScaledPixels(1.0), - wavy: Default::default(), - }; - let quad = render(&mut renderer, quad.into())?; - let underline = render(&mut renderer, underline.into())?; - let (white, black) = ( - image::Rgba([255, 255, 255, 255]), - image::Rgba([0, 0, 0, 255]), - ); - for (what, image) in [("quad", &quad), ("underline", &underline)] { - assert_eq!(*image.get_pixel(8, 12), white, "{what}: left edge"); - assert_eq!(*image.get_pixel(12, 8), white, "{what}: top edge"); - assert_eq!(*image.get_pixel(24, 12), black, "{what}: right edge"); - assert_eq!(*image.get_pixel(12, 24), black, "{what}: bottom edge"); - } - assert!( - quad == underline, - "the quad and the underline cover different pixels" - ); - Ok(()) - } - - /// Vertex clipping to the content mask must draw exactly what fragment - /// masking alone draws: every pixel centered inside the half-open mask - /// (its left and top edges included, right and bottom excluded) matches - /// the unmasked frame, and every other pixel is untouched. Mask edges land - /// on pixel centers whenever an odd logical coordinate is scaled by 1.5. - #[test] - fn vertex_clipping_matches_fragment_masking_on_pixel_center_edges() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let mut renderer = - WgpuRenderer::new_headless(&context, size(DevicePixels(200), DevicePixels(100)))?; - renderer.retained.disable_for_tests(); - let element = scaled(40.0, 30.0, 100.0, 40.0); - let radii = gpui::Corners::all(ScaledPixels(8.0)); - let scene = |kind: usize, content_mask: Option>| { - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds: scaled(0.0, 0.0, 200.0, 100.0), - content_mask: full_mask(), - background: solid_background(gpui::rgb(0x18212b)), - ..Default::default() - }); - if let Some(content_mask) = content_mask { - let shadow = Shadow { - order: 0, - blur_radius: ScaledPixels(6.0), - bounds: element, - corner_radii: radii, - content_mask, - color: gpui::hsla(0.6, 0.5, 0.1, 0.7).into(), - element_bounds: element, - element_corner_radii: radii, - inset: Default::default(), - corner_smoothing: 0.0, - }; - match kind { - 0 => scene.insert_primitive(Quad { - bounds: scaled(4.0, 3.0, 190.0, 92.0), - content_mask, - background: solid_background(gpui::hsla(0.33, 0.7, 0.55, 0.8)), - corner_radii: radii, - ..Default::default() - }), - 1 => scene.insert_primitive(shadow), - 2 => scene.insert_primitive(Shadow { - blur_radius: ScaledPixels(0.0), - ..shadow - }), - // A 1px inset ring, as interaction outlines paint. - 3 => scene.insert_primitive(Shadow { - blur_radius: ScaledPixels(0.0), - bounds: scaled(41.0, 31.0, 98.0, 38.0), - inset: true.into(), - ..shadow - }), - _ => scene.insert_primitive(Shadow { - blur_radius: ScaledPixels(3.0), - bounds: scaled(44.0, 34.0, 92.0, 32.0), - inset: true.into(), - ..shadow - }), - } - } - scene.finish(); - scene - }; - let background = renderer.render_to_image(&scene(0, None))?; - let mut failures = Vec::new(); - for kind in 0..5 { - let full = renderer.render_to_image(&scene(kind, Some(full_mask())))?; - // Both frames share the shader, so check its blur reaches past the - // shadow's bounds, 3px left of the element here. - if kind == 1 { - assert_ne!(full.get_pixel(37, 50), background.get_pixel(37, 50)); - } - for mask in [ - scaled(10.5, 10.5, 100.0, 50.0), - scaled(52.5, 37.5, 30.0, 21.0), - scaled(36.0, 26.0, 40.5, 50.5), - scaled(37.25, 27.75, 60.0, 30.0), - scaled(250.0, 140.0, 1.0, 1.0), - ] { - let masked = renderer.render_to_image(&scene( - kind, - Some(ContentMask { - bounds: mask, - ..Default::default() - }), - ))?; - let (left, top) = (mask.origin.x.0, mask.origin.y.0); - let (right, bottom) = (left + mask.size.width.0, top + mask.size.height.0); - let difference = masked.enumerate_pixels().find(|(x, y, pixel)| { - let (cx, cy) = (*x as f32 + 0.5, *y as f32 + 0.5); - let inside = cx >= left && cx < right && cy >= top && cy < bottom; - if inside { - *pixel != full.get_pixel(*x, *y) - } else { - *pixel != background.get_pixel(*x, *y) - } - }); - if let Some((x, y, _)) = difference { - failures.push(format!("kind={kind} mask={mask:?} first pixel=({x}, {y})")); - } - } - } - assert!(failures.is_empty(), "{failures:#?}"); - Ok(()) - } - - fn triangle( - x: f32, - y: f32, - edge: f32, - mask: ContentMask, - ) -> gpui::Path { - let mut builder = PathBuilder::fill(); - builder.move_to(point(px(x), px(y))); - builder.line_to(point(px(x + edge), px(y))); - builder.line_to(point(px(x), px(y + edge))); - builder.close(); - let mut path = builder.build().unwrap().scale(1.0); - path.content_mask = mask; - path.color = gpui::linear_gradient( - 90.0, - gpui::linear_color_stop(gpui::rgb(0xff0000), 0.0), - gpui::linear_color_stop(gpui::rgb(0x00ff00), 1.0), - ); - path - } - - fn hover_scene(row: usize, resting_fills: bool) -> Scene { - let mut scene = Scene::default(); - // The headless target clears to black. Leave uncovered pixels around each - // translucent row so a wrong retained clear color is observable. - for index in 0..3 { - if !resting_fills && index != row { - continue; - } - scene.insert_primitive(Quad { - bounds: scaled(4.0, 10.0 + index as f32 * 25.0, 192.0, 14.0), - content_mask: full_mask(), - background: solid_background(if row == index { - gpui::hsla(0.6, 0.4, 0.4, 0.7) - } else { - gpui::hsla(0.6, 0.1, 0.15, 0.7) - }), - ..Default::default() - }); - } - let mut graph = triangle( - 20.0, - 8.0, - 18.0, - ContentMask { - bounds: scaled(20.0, 10.0, 18.0, 14.0), - ..Default::default() - }, - ); - graph.color = solid_background(if row == 0 { - gpui::hsla(0.6, 0.4, 0.4, 0.7) - } else { - gpui::black() - }); - scene.insert_primitive(graph); - scene.insert_primitive(Quad { - bounds: scaled(80.0, 5.0, 15.0, 70.0), - content_mask: ContentMask { - bounds: scaled(82.0, 0.0, 10.0, 100.0), - ..Default::default() - }, - background: solid_background(gpui::hsla(0.02, 0.8, 0.6, 0.4)), - corner_radii: gpui::Corners::all(ScaledPixels(4.0)), - ..Default::default() - }); - scene.finish(); - scene - } - - #[test] - fn retained_hover_matches_full_pixels_for_rows_graph_clip_and_invalidation() - -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - for resting_fills in [true, false] { - let target = size(DevicePixels(200), DevicePixels(400)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - let mut retained = WgpuRenderer::new_headless(&context, target)?; - retained.retained.enable_for_tests(); - let mut revision = 0; - let mut previous = None; - for row in [0, 0, 1, 2, 0] { - if previous != Some(row) { - revision += 1; - } - let scene = hover_scene(row, resting_fills); - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); - let expected = full.render_to_image(&scene)?; - let actual = retained.render_to_image(&scene)?; - assert_eq!( - actual, expected, - "retained pixels differ for row {row}, revision {revision}, resting fills {resting_fills}" - ); - assert_eq!( - retained.retained.last_damage(), - if previous.is_none() { - "full" - } else if previous == Some(row) { - "reuse" - } else { - "partial" - }, - "damage selection for row {row}, previous {previous:?}, revision {revision}, resting fills {resting_fills}" - ); - previous = Some(row); - } - let scene = hover_scene(0, resting_fills); - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); - retained.atlas.clear(); - assert_eq!( - retained.render_to_image(&scene)?, - full.render_to_image(&scene)? - ); - assert_eq!( - retained.retained.last_damage(), - "full", - "atlas invalidates unchanged scene revision" - ); - let target = size(DevicePixels(190), DevicePixels(100)); - retained.update_drawable_size(target); - full.update_drawable_size(target); - assert_eq!( - retained.render_to_image(&scene)?, - full.render_to_image(&scene)? - ); - assert_eq!( - retained.retained.last_damage(), - "full", - "resize invalidates unchanged scene revision" - ); - retained.update_transparency(true); - full.update_transparency(true); - assert_eq!( - retained.render_to_image(&scene)?, - full.render_to_image(&scene)? - ); - assert_eq!( - retained.retained.last_damage(), - "full", - "appearance transition invalidates the previous opaque target" - ); - assert_eq!( - retained.render_to_image(&scene)?, - full.render_to_image(&scene)? - ); - assert_eq!(retained.retained.last_damage(), "reuse"); - } - Ok(()) - } - - #[test] - fn retained_regrouped_coincident_paths_match_full_pixels() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let target = size(DevicePixels(512), DevicePixels(512)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - let mask = ContentMask { - bounds: scaled(0.0, 0.0, 512.0, 512.0), - ..Default::default() - }; - // A quad in a tile of its own splits four paths into two batches. - // Coincident paths in one batch share MSAA samples; in two batches - // they composite twice, so regrouping them changes their edges. - let scene = |paths: [gpui::Path; 4]| { - let mut scene = Scene::default(); - // A layer each gives every primitive its own draw order. - let mut paint = |primitive: gpui::Primitive| { - scene.push_layer(mask.bounds); - scene.insert_primitive(primitive); - scene.pop_layer(); - }; - for (index, path) in paths.into_iter().enumerate() { - if index == 2 { - paint( - Quad { - bounds: scaled(420.0, 420.0, 20.0, 20.0), - content_mask: mask, - background: solid_background(gpui::white()), - ..Default::default() - } - .into(), - ); - } - paint(path.into()); - } - scene.finish(); - scene - }; - let moved = |x| triangle(x, 12.25, 24.75, mask); - // One wide triangle clipped to the left or the right: every path has - // the same bounds, so only the masks tell the batches apart. - let clipped = |left| { - let mask = ContentMask { - bounds: scaled(left, 0.0, 56.0, 256.0), - ..Default::default() - }; - triangle(12.25, 12.25, 288.0, mask) - }; - for (what, paired, crossed) in [ - ( - "moved", - scene([moved(12.25), moved(12.25), moved(204.25), moved(204.25)]), - scene([moved(12.25), moved(204.25), moved(12.25), moved(204.25)]), - ), - ( - "clipped", - scene([clipped(0.0), clipped(0.0), clipped(200.0), clipped(200.0)]), - scene([clipped(0.0), clipped(200.0), clipped(0.0), clipped(200.0)]), - ), - ] { - retained.release_retained_frame(); - let before = assert_retained_rgba(&mut full, &mut retained, &paired, 1, "full", what)?; - let after = - assert_retained_rgba(&mut full, &mut retained, &crossed, 2, "partial", what)?; - assert_ne!(before, after, "{what}: regrouping must change the edges"); - } - Ok(()) - } - - #[test] - fn retained_moves_shadows_rings_paths_and_rotated_sprites_exactly() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let target = size(DevicePixels(512), DevicePixels(512)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - let glyph = retained_glyph_tile(&full)?; - assert_eq!(glyph, retained_glyph_tile(&retained)?); - let mask = ContentMask { - bounds: scaled(0.0, 0.0, 512.0, 512.0), - ..Default::default() - }; - // Each step moves a blurred shadow, an inset ring and a path by a - // fractional offset and rotates a sprite, as floating surfaces, focus - // rings, graph edges and spinners do. - let scene = |step: usize| { - let offset = step as f32 * 3.25; - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds: scaled(0.0, 0.0, 512.0, 512.0), - content_mask: mask, - background: solid_background(gpui::rgb(0x18212b)), - ..Default::default() - }); - let card = scaled(100.25 + offset, 100.5, 30.0, 20.0); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(4.5), - bounds: card, - corner_radii: gpui::Corners::all(ScaledPixels(4.0)), - content_mask: mask, - color: solid_background(gpui::hsla(0.0, 0.0, 0.0, 0.6)), - element_bounds: card, - element_corner_radii: gpui::Corners::all(ScaledPixels(4.0)), - inset: gpui::ShaderBool::Disabled, - corner_smoothing: 0.0, - }); - let ring = scaled(100.5, 300.25 + offset, 40.0, 16.0); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(0.0), - bounds: scaled(101.5, 301.25 + offset, 38.0, 14.0), - corner_radii: gpui::Corners::all(ScaledPixels(3.0)), - content_mask: mask, - color: solid_background(gpui::hsla(0.6, 0.8, 0.6, 1.0)), - element_bounds: ring, - element_corner_radii: gpui::Corners::all(ScaledPixels(4.0)), - inset: gpui::ShaderBool::Enabled, - corner_smoothing: 0.0, - }); - let mut path = gpui::Path::new(point(px(300.25 + offset), px(100.5))); - path.line_to(point(px(320.5 + offset), px(110.25))); - path.line_to(point(px(305.75 + offset), px(125.5))); - let mut path = path.scale(1.0); - path.content_mask = mask; - for vertex in &mut path.vertices { - vertex.content_mask = mask; - } - path.color = solid_background(gpui::hsla(0.3, 0.7, 0.6, 0.8)); - scene.insert_primitive(path); - let center = point(ScaledPixels(304.0), ScaledPixels(304.0)); - scene.insert_primitive(MonochromeSprite { - order: 0, - padding: 0, - bounds: scaled(300.0, 300.0, 8.0, 8.0), - content_mask: mask, - color: gpui::hsla(0.1, 0.6, 0.7, 1.0).into(), - tile: glyph, - transformation: gpui::TransformationMatrix::unit() - .translate(center) - .rotate(gpui::radians(step as f32 * 0.7)) - .translate(point(ScaledPixels(-304.0), ScaledPixels(-304.0))), - }); - scene.finish(); - scene - }; - for step in 0..4 { - assert_retained_rgba( - &mut full, - &mut retained, - &scene(step), - step as u64 + 1, - if step == 0 { "full" } else { "partial" }, - &format!("step {step}"), - )?; - } - Ok(()) - } - - fn assert_retained_rgba( - full: &mut WgpuRenderer, - retained: &mut WgpuRenderer, - scene: &Scene, - revision: u64, - damage: &str, - context: &str, - ) -> anyhow::Result { - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); - let expected = full - .render_to_image(scene) - .map_err(|error| anyhow::anyhow!("{context}: full renderer: {error:#}"))?; - let actual = retained - .render_to_image(scene) - .map_err(|error| anyhow::anyhow!("{context}: retained renderer: {error:#}"))?; - assert_eq!(actual.dimensions(), expected.dimensions(), "{context}"); - let first_difference = actual - .as_raw() - .iter() - .zip(expected.as_raw()) - .enumerate() - .find(|(_, (actual, expected))| actual != expected); - assert!( - first_difference.is_none(), - "{context}: exact RGBA comparison failed at byte {first_difference:?}" - ); - assert_eq!( - retained.retained.last_damage(), - damage, - "{context}: the requested retained path must actually execute" - ); - Ok(actual) - } - - fn empty_transparent_hover_scene(row: Option) -> Scene { - let mut scene = Scene::default(); - if let Some(row) = row { - scene.insert_primitive(Quad { - bounds: scaled(12.25, 10.5 + row as f32 * 25.0, 120.5, 13.25), - content_mask: full_mask(), - background: gpui::rgba(0x4488cc70).into(), - corner_radii: gpui::Corners::all(ScaledPixels(3.25)), - ..Default::default() - }); - } - // A zero-alpha anchor preserves an exact following painter slot when the - // only visible primitive moves; every resting-frame pixel is still clear. - scene.insert_primitive(Quad { - bounds: scaled(190.0, 90.0, 2.0, 2.0), - content_mask: full_mask(), - background: gpui::rgba(0x00000000).into(), - ..Default::default() - }); - scene.finish(); - scene - } - - #[test] - fn retained_transparent_clear_insert_move_erase_is_exact_rgba() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let target = size(DevicePixels(200), DevicePixels(400)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - full.update_transparency(true); - retained.update_transparency(true); - let mut revision = 0; - let mut previous = None; - for (index, row) in [None, None, Some(0), Some(0), Some(1), Some(1), None, None] - .into_iter() - .enumerate() - { - if index == 0 || row != previous { - revision += 1; - } - let image = assert_retained_rgba( - &mut full, - &mut retained, - &empty_transparent_hover_scene(row), - revision, - if index == 0 { - "full" - } else if row == previous { - "reuse" - } else { - "partial" - }, - &format!("empty transparent target, step {index}, row {row:?}"), - )?; - if row.is_none() { - assert!( - image.as_raw().iter().all(|byte| *byte == 0), - "initial clear and erased hover must leave transparent black RGBA" - ); - } else { - assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); - } - previous = row; - } - Ok(()) - } - - #[cfg(target_os = "windows")] - #[test] - fn retained_damage_clear_prewarm_precedes_partial_and_tracks_pipeline_generation() - -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let adapter = context.adapter.get_info(); - assert_eq!(adapter.backend, wgpu::Backend::Dx12); - let target = size(DevicePixels(201), DevicePixels(403)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.disable_for_tests(); - full.update_transparency(true); - retained.update_transparency(true); - let resting = empty_transparent_hover_scene(None); - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - 1, - "full", - "retained off", - )?; - assert!( - retained.resources().pipelines.damage_clear_quads.is_none(), - "full frames must not create the damage-clear PSO" - ); - retained.retained.enable_for_tests(); - { - let _scope = gpui::profiler::gpu::set_gpu_frame_context(false, 1); - assert_eq!( - retained.render_to_image(&resting)?, - full.render_to_image(&resting)? - ); - } - assert!( - retained.resources().pipelines.damage_clear_quads.is_none(), - "inactive-window fallback must not prewarm" - ); - { - let _budget = gpui_render::retained::reserve_retained_bytes( - "wgpu", - Arc::as_ptr(&context.device) as usize, - gpui_render::retained::RETAINED_DEVICE_LIMIT, - ) - .expect("the private device has no retained textures yet"); - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - 1, - "full", - "budget fallback", - )?; - assert!( - retained.resources().pipelines.damage_clear_quads.is_none(), - "budget fallback must not create a PSO for an unallocated retained texture" - ); - } - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - 1, - "full", - "qualified retained initialization", - )?; - let warmed = (**retained - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .expect("the qualified Full must create the clear PSO before any Partial")) - .clone(); - let hovered = empty_transparent_hover_scene(Some(0)); - for damage in ["partial", "reuse"] { - assert_retained_rgba( - &mut full, - &mut retained, - &hovered, - 2, - damage, - "warm first hover", - )?; - assert_eq!( - &**retained - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap(), - &warmed, - "Partial/Reuse must reuse the prewarmed pipeline handle" - ); - } - for renderer in [&mut full, &mut retained] { - renderer.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); - } - assert_retained_rgba( - &mut full, - &mut retained, - &hovered, - 2, - "full", - "same-format resize", - )?; - assert_eq!( - &**retained - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap(), - &warmed, - "same-format resize must not recreate the clear PSO" - ); - for renderer in [&mut full, &mut retained] { - renderer - .target - .set_format_for_tests(wgpu::TextureFormat::Rgba8Unorm); - renderer.rebuild_pipelines(); - } - assert!(retained.resources().pipelines.damage_clear_quads.is_none()); - assert_retained_rgba( - &mut full, - &mut retained, - &hovered, - 2, - "full", - "format generation rebuild", - )?; - let rebuilt = (**retained - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap()) - .clone(); - assert_ne!( - rebuilt, warmed, - "format rebuild must create a pipeline for the new target format" - ); - assert_retained_rgba( - &mut full, - &mut retained, - &empty_transparent_hover_scene(Some(1)), - 3, - "partial", - "new format first hover", - )?; - assert_eq!( - &**retained - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap(), - &rebuilt - ); - - let fresh_context = WgpuContext::new_headless(None)?; - let adapter = fresh_context.adapter.get_info(); - assert_eq!(adapter.backend, wgpu::Backend::Dx12); - let mut recreated = - WgpuRenderer::new_headless(&fresh_context, size(DevicePixels(203), DevicePixels(405)))?; - recreated.retained.enable_for_tests(); - recreated.update_transparency(true); - assert!( - recreated.resources().pipelines.damage_clear_quads.is_none(), - "a new device must not reuse a pipeline belonging to the previous device" - ); - assert_retained_rgba( - &mut full, - &mut recreated, - &resting, - 1, - "full", - "device recreation qualified Full", - )?; - let recreated_pipeline = (**recreated - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap()) - .clone(); - assert_ne!(recreated_pipeline, rebuilt); - assert_retained_rgba( - &mut full, - &mut recreated, - &hovered, - 2, - "partial", - "recreated device first hover", - )?; - assert_eq!( - &**recreated - .resources() - .pipelines - .damage_clear_quads - .as_ref() - .unwrap(), - &recreated_pipeline - ); - Ok(()) - } - - #[test] - fn replaced_retained_targets_leave_no_blit_bind_groups_behind() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let mut renderer = - WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; - renderer.retained.enable_for_tests(); - // A surface without COPY_DST blits on DX12 too. - renderer - .target - .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[]); - let mut scene = Scene::default(); - scene.finish(); - for revision in 1..=3 { - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); - renderer.render_to_image(&scene)?; - assert_eq!(renderer.last_retained_presentation, "blit"); - // A cached bind group would keep each released target alive. - renderer.release_retained_frame(); - } - assert_eq!(renderer.resources().blur_bind_group_count(), 0); - Ok(()) - } - - #[test] - fn retained_targets_return_their_budget_at_once() -> anyhow::Result<()> { - use gpui_render::retained::{RETAINED_DEVICE_LIMIT, reserve_retained_bytes}; - - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let device_key = Arc::as_ptr(&context.device) as usize; - let mut scene = Scene::default(); - scene.finish(); - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, 1); - let target_bytes = 64 * 64 * 4; - for leave in ["release", "unfocused repaint", "close"] { - let mut renderer = - WgpuRenderer::new_headless(&context, size(DevicePixels(64), DevicePixels(64)))?; - renderer.retained.enable_for_tests(); - let target = renderer.create_offscreen_target(); - assert!(renderer.render_to_texture(&scene, &target.texture, &target.view)); - // Other windows hold the rest of the device budget. - let _others = - reserve_retained_bytes("wgpu", device_key, RETAINED_DEVICE_LIMIT - target_bytes) - .expect("the retained target fits beside the other windows"); - assert!(reserve_retained_bytes("wgpu", device_key, 1).is_none()); - match leave { - "release" => renderer.release_retained_frame(), - // The pointer left an unfocused window, which repaints once. - "unfocused repaint" => { - let _scope = gpui::profiler::gpu::set_gpu_frame_context(false, 2); - assert!(renderer.render_to_texture(&scene, &target.texture, &target.view)); - } - _ => drop(renderer), - } - // An idle or closed window renders and polls nothing more. - assert!( - reserve_retained_bytes("wgpu", device_key, target_bytes).is_some(), - "{leave}" - ); - } - Ok(()) - } - - #[cfg(target_os = "windows")] - #[test] - fn retained_copy_full_partial_reuse_preserves_exact_rgba_and_odd_extents() -> anyhow::Result<()> - { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let adapter = context.adapter.get_info(); - assert_eq!( - adapter.backend, - wgpu::Backend::Dx12, - "this fixture must exercise the native DX12 copy implementation" - ); - for format in [ - wgpu::TextureFormat::Bgra8Unorm, - wgpu::TextureFormat::Rgba8Unorm, - ] { - for transparent in [false, true] { - let target = size(DevicePixels(201), DevicePixels(403)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - for renderer in [&mut full, &mut retained] { - renderer.target.set_format_for_tests(format); - renderer.rebuild_pipelines(); - renderer.update_transparency(transparent); - } - assert!(retained.target.retained_copy_enabled()); - let glyph = retained_glyph_tile(&full)?; - assert_eq!(glyph, retained_glyph_tile(&retained)?); - let mut revision = 0; - let mut previous = None; - for (index, row) in [None, None, Some(0), Some(0), Some(2), Some(1), None, None] - .into_iter() - .enumerate() - { - if index == 0 || row != previous { - revision += 1; - } - let scene = - transparent_composite_hover_scene(row, 0.25, 0.65, 0.45, glyph, Some(true)); - let label = format!( - "copy format={format:?} alpha={transparent}, step={index}, row={row:?}" - ); - let image = assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - if index == 0 { - "full" - } else if row == previous { - "reuse" - } else { - "partial" - }, - &label, - )?; - assert_eq!( - retained.last_retained_presentation, "copy", - "{label}: no silent shader fallback" - ); - if transparent { - assert_eq!( - *image.get_pixel(200, 102), - image::Rgba([0, 0, 0, 0]), - "{label}: untouched transparent corner" - ); - assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); - } - previous = row; - } - let scene = - transparent_composite_hover_scene(None, 0.25, 0.65, 0.45, glyph, Some(true)); - full.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); - retained.update_drawable_size(size(DevicePixels(203), DevicePixels(405))); - for damage in ["full", "reuse"] { - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - damage, - "odd resize invalidation", - )?; - assert_eq!(retained.last_retained_presentation, "copy"); - } - full.update_transparency(!transparent); - retained.update_transparency(!transparent); - for damage in ["full", "reuse"] { - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - damage, - "alpha transition", - )?; - assert_eq!(retained.last_retained_presentation, "copy"); - } - retained.release_retained_frame(); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - "full", - "released texture rebuild", - )?; - assert_eq!(retained.last_retained_presentation, "copy"); - - let _scope = gpui::profiler::gpu::set_gpu_frame_context(true, revision); - // A texture made while the surface lacked COPY_DST. - let copy = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST; - retained - .target - .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[format]); - let target = retained.create_offscreen_target(); - retained.target.update_surface_copy_support(copy, &[format]); - assert!( - !target - .texture - .usage() - .contains(wgpu::TextureUsages::COPY_DST) - ); - let submission = super::super::frame::render_to_view( - &mut retained, - &scene, - &target.view, - &target.texture, - Some(target.readback_copy()), - ) - .unwrap(); - assert_eq!( - retained.read_offscreen_target(&target, submission)?, - full.render_to_image(&scene)? - ); - assert_eq!( - retained.last_retained_presentation, "blit", - "a texture without COPY_DST must not be copied" - ); - - // Surface replacement must drop unsupported COPY_DST usage and capability. - retained - .target - .update_surface_copy_support(wgpu::TextureUsages::RENDER_ATTACHMENT, &[format]); - assert!(!retained.target.retained_copy_enabled()); - assert!( - !retained - .target - .configuration() - .usage - .contains(wgpu::TextureUsages::COPY_DST) - ); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - "full", - "replacement without copy support", - )?; - assert_eq!(retained.last_retained_presentation, "blit"); - retained.target.update_surface_copy_support(copy, &[format]); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - "full", - "replacement restores copy support", - )?; - assert_eq!(retained.last_retained_presentation, "copy"); - } - } - Ok(()) - } - - #[test] - fn retained_raw_clear_endpoints_and_colored_fallback_match_full_rgba() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let target = size(DevicePixels(200), DevicePixels(400)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - for transparent in [false, true] { - full.update_transparency(transparent); - retained.update_transparency(transparent); - for (clear, supported, label) in [ - (wgpu::Color::TRANSPARENT, true, "transparent black"), - ( - wgpu::Color { - a: 0.25, - ..wgpu::Color::BLACK - }, - true, - "translucent black", - ), - (wgpu::Color::BLACK, true, "opaque black"), - (wgpu::Color::WHITE, true, "opaque white"), - ( - wgpu::Color { - r: 0.04, - g: 0.03, - b: 0.02, - a: 0.25, - }, - false, - "translucent colored clear", - ), - ( - wgpu::Color { - r: 0.04, - g: 0.03, - b: 0.02, - a: 0.0, - }, - false, - "zero-alpha colored raw clear", - ), - ( - wgpu::Color { - r: 0.04, - g: 0.03, - b: 0.02, - a: 1.0, - }, - false, - "opaque colored raw clear", - ), - ] { - full.target.clear_color = clear; - retained.target.clear_color = clear; - retained.retained.invalidate(); - for (index, row) in [None, Some(0), Some(1), None].into_iter().enumerate() { - let scene = empty_transparent_hover_scene(row); - let revision = index as u64 + 1; - let label = format!("alpha={transparent}, clear={label}, step={index}"); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - if supported && index != 0 { - "partial" - } else { - "full" - }, - &label, - )?; - // Unsupported clears must never retain faulty pixels, including - // an unchanged repeat immediately after an inserted/erased fill. - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - if supported { "reuse" } else { "full" }, - &format!("repeat: {label}"), - )?; - } - } - } - Ok(()) - } - - fn retained_glyph_tile(renderer: &WgpuRenderer) -> anyhow::Result { - let key = AtlasKey::Glyph { - params: RenderGlyphParams { - font_id: FontId(0), - glyph_id: GlyphId(65), - font_size: px(8.0), - subpixel_variant: point(0, 0), - scale_factor: 1.0, - raster_style: PreparedRasterStyle::independent(GlyphRenderMode::Grayscale), - }, - format: RasterizedGlyphFormat::AlphaMask, - }; - // Deterministic glyph-shaped coverage exercises the real monochrome atlas - // pipeline without depending on a machine's font rasterizer or font files. - let coverage = [ - 0, 0, 96, 255, 255, 96, 0, 0, 0, 64, 255, 96, 96, 255, 64, 0, 0, 128, 255, 0, 0, 255, - 128, 0, 32, 224, 160, 0, 0, 160, 224, 32, 96, 255, 255, 255, 255, 255, 255, 96, 160, - 224, 64, 0, 0, 64, 224, 160, 224, 160, 0, 0, 0, 0, 160, 224, 255, 96, 0, 0, 0, 0, 96, - 255, - ]; - renderer - .atlas - .get_or_insert_with(&key, &mut || { - Ok(Some(( - size(DevicePixels(8), DevicePixels(8)), - Cow::Borrowed(&coverage), - ))) - })? - .ok_or_else(|| anyhow::anyhow!("retained glyph fixture must produce an atlas tile")) - } - - fn retained_tiled_glyph( - renderer: &WgpuRenderer, - mode: GlyphRenderMode, - ) -> anyhow::Result { - let key = AtlasKey::Glyph { - params: RenderGlyphParams { - font_id: FontId(0), - glyph_id: GlyphId(90), - font_size: px(8.0), - subpixel_variant: point(0, 0), - scale_factor: 1.0, - raster_style: PreparedRasterStyle::independent(mode), - }, - format: mode.rasterized_format(), - }; - let coverage: Vec = (0..64) - .map(|index| match (index / 8, index % 8) { - (4, 1..=6) | (1..=6, 1 | 6) => 255, - (_, 2 | 5) => 96, - _ => 0, - }) - .collect(); - let bytes = if mode == GlyphRenderMode::Subpixel { - coverage - .iter() - .flat_map(|&value| [value / 2, value, value.saturating_add(24), 255]) - .collect() - } else { - coverage - }; - renderer - .atlas - .get_or_insert_with(&key, &mut || { - Ok(Some(( - size(DevicePixels(8), DevicePixels(8)), - Cow::Borrowed(&bytes), - ))) - })? - .ok_or_else(|| anyhow::anyhow!("tiled glyph fixture must allocate an atlas tile")) - } - - fn tiled_history_scene( - hover: Option, - fraction: f32, - glyph: AtlasTile, - mode: GlyphRenderMode, - ) -> Scene { - let mut scene = Scene::default(); - let pane = scaled(20.0, 12.0, 460.0, 840.0); - let mask = ContentMask { - bounds: pane, - ..Default::default() - }; - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(2.25), - bounds: pane, - corner_radii: gpui::Corners::all(ScaledPixels(8.25)), - content_mask: ContentMask { - bounds: scaled(0.0, 0.0, 512.0, 1024.0), - ..Default::default() - }, - color: gpui::rgba(0x17243850).into(), - element_bounds: pane, - element_corner_radii: gpui::Corners::all(ScaledPixels(8.25)), - inset: false.into(), - corner_smoothing: 0.65, - }); - scene.insert_primitive(Quad { - bounds: pane, - content_mask: mask, - background: gpui::rgba(0x223344bb).into(), - corner_radii: gpui::Corners::all(ScaledPixels(8.25)), - corner_smoothing: 0.65, - ..Default::default() - }); - // Stable graph raster groups precede ordinary bounds-tree row paints. - // No per-row push_layer/raise_order_floor masks the live rank permutation. - for row in [0, 5, 10, 19] { - scene.insert_primitive(triangle( - 24.25 + fraction, - 18.25 + row as f32 * 40.0, - 10.5, - mask, - )); - } - let paint_glyph = |scene: &mut Scene, x: f32, y: f32, color: gpui::Hsla| { - if mode == GlyphRenderMode::Subpixel { - scene.insert_primitive(gpui::SubpixelSprite { - order: 0, - padding: 0, - bounds: scaled(x, y, 8.5, 10.25), - content_mask: mask, - color: color.into(), - tile: glyph, - transformation: Default::default(), - }); - } else { - scene.insert_primitive(MonochromeSprite { - order: 0, - padding: 0, - bounds: scaled(x, y, 8.5, 10.25), - content_mask: mask, - color: color.into(), - tile: glyph, - transformation: Default::default(), - }); - } - }; - for row in 0..20 { - let y = 16.25 + fraction + row as f32 * 40.0; - if hover == Some(row) { - scene.insert_primitive(Quad { - bounds: scaled(20.0, y, 460.0, 28.5), - content_mask: mask, - background: gpui::rgba(0x55779944).into(), - ..Default::default() - }); - } - // Live graph cutouts have unchanged geometry but hover-dependent - // backing colors. Color changes must not consume structural edits. - for (x, width) in [(134.0, 66.0), (200.0, 4.0), (410.0, 24.0)] { - scene.insert_primitive(Quad { - bounds: scaled(x, y, width, 28.5), - content_mask: mask, - background: if hover == Some(row) { - gpui::rgba(0x55779999) - } else { - gpui::rgba(0x22334499) - } - .into(), - ..Default::default() - }); - } - for index in 0..20 { - paint_glyph( - &mut scene, - 32.25 + fraction + index as f32 * 18.5, - y + 3.25, - gpui::hsla(0.12, 0.6, 0.65, 0.85), - ); - } - if hover == Some(row) { - for index in 0..7 + row % 3 { - paint_glyph( - &mut scene, - 282.25 + fraction + index as f32 * 7.0, - y + 15.0, - gpui::hsla(0.6, 0.4, 0.6, 0.6), - ); - } - } - } - scene.insert_primitive(Quad { - bounds: scaled(250.25 + fraction, 14.5, 8.5, 832.25), - content_mask: mask, - background: gpui::rgba(0xda624d60).into(), - ..Default::default() - }); - scene.finish(); - scene - } - - #[test] - fn retained_tiled_history_bounds_tree_matches_exact_rgba_for_rank_permutations() - -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - for mode in [GlyphRenderMode::Grayscale, GlyphRenderMode::Subpixel] { - for (transparent, fraction) in [(false, 0.0), (true, 0.25), (true, 0.75)] { - let target = size(DevicePixels(512), DevicePixels(1024)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - full.update_transparency(transparent); - retained.update_transparency(transparent); - let glyph = retained_tiled_glyph(&full, mode)?; - assert_eq!(glyph, retained_tiled_glyph(&retained, mode)?); - let first = tiled_history_scene(Some(0), fraction, glyph, mode); - let last = tiled_history_scene(Some(1), fraction, glyph, mode); - let first_snapshot = - gpui_render::damage::SceneSnapshot::capture(&first, 1, 512, 1024, Vec::new()) - .unwrap(); - let last_snapshot = - gpui_render::damage::SceneSnapshot::capture(&last, 1, 512, 1024, Vec::new()) - .unwrap(); - assert!( - matches!( - last_snapshot.compare(&first_snapshot), - gpui_render::damage::Damage::Partial(_) - ), - "rank permutations must stay partial" - ); - let mut previous = None; - let mut revision = 0; - // One box spans every changed tile. A jump across the list repaints - // in full, and so does starting or ending a hover: that raises the - // draw order of the strip crossing every row, which moves it among - // paints it doesn't touch, so all its tiles repaint. - for (index, (row, damage)) in [ - (None, "full"), - (None, "reuse"), - (Some(0), "full"), - (Some(0), "reuse"), - (Some(1), "partial"), - (Some(2), "partial"), - (Some(19), "full"), - (Some(18), "partial"), - (None, "full"), - (None, "reuse"), - ] - .into_iter() - .enumerate() - { - if index == 0 || row != previous { - revision += 1; - } - let scene = tiled_history_scene(row, fraction, glyph, mode); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - damage, - &format!( - "tiled history mode={mode:?}, transparent={transparent}, fraction={fraction}, step={index}, row={row:?}" - ), - )?; - previous = row; - } - - // Identical overlapping descriptors around a third translucent - // paint must change pixels, rather than being mistaken for Reuse. - let mask = ContentMask { - bounds: scaled(0.0, 0.0, 512.0, 1024.0), - ..Default::default() - }; - let a = Quad { - bounds: scaled(62.25, 62.25, 24.5, 24.5), - content_mask: mask, - background: gpui::rgba(0xff000080).into(), - ..Default::default() - }; - let b = Quad { - background: gpui::rgba(0x0000ff80).into(), - ..a - }; - let mut overlap = Scene::default(); - for quad in [a, a, b] { - overlap.insert_primitive(quad); - } - overlap.finish(); - // Recreate targets to ensure this independent order proof starts - // with an initial Full rather than an unrelated previous scene. - retained.retained.invalidate(); - revision += 1; - let original = assert_retained_rgba( - &mut full, - &mut retained, - &overlap, - revision, - "full", - "tiled overlap initial", - )?; - overlap.quads.swap(1, 2); // Counts/pipelines stay unchanged. - revision += 1; - let changed = assert_retained_rgba( - &mut full, - &mut retained, - &overlap, - revision, - "partial", - "tiled overlap reordered", - )?; - assert_ne!( - original.as_raw(), - changed.as_raw(), - "overlapping painter reorder must visibly change RGBA" - ); - assert_retained_rgba( - &mut full, - &mut retained, - &overlap, - revision, - "reuse", - "tiled overlap repeated", - )?; - } - } - Ok(()) - } - - fn transparent_composite_hover_scene( - row: Option, - fraction: f32, - smoothing: f32, - backing_alpha: f32, - glyph: AtlasTile, - badge_before_graph: Option, - ) -> Scene { - let mut scene = Scene::default(); - let backing_bounds = scaled(4.25 + fraction, 3.5 + fraction, 190.5, 90.25); - let radii = gpui::Corners::all(ScaledPixels(9.25)); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(2.5), - bounds: backing_bounds, - corner_radii: radii, - content_mask: full_mask(), - color: gpui::rgba(0x17243850).into(), - element_bounds: backing_bounds, - element_corner_radii: radii, - inset: false.into(), - corner_smoothing: smoothing, - }); - scene.raise_order_floor(); - scene.insert_primitive(Quad { - bounds: backing_bounds, - content_mask: full_mask(), - background: solid_background(gpui::hsla(0.62, 0.3, 0.35, backing_alpha)), - corner_radii: radii, - corner_smoothing: smoothing, - ..Default::default() - }); - scene.raise_order_floor(); - // A shifted/shrunk inset hole is far from the first hovered row. Its - // actual raster geometry is the whole element, so pruning by the hole - // bounds would lose static inset pixels when that row is damage-cleared. - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(0.5), - bounds: scaled(140.25 + fraction, 62.5, 26.5, 14.0), - corner_radii: gpui::Corners::all(ScaledPixels(2.0)), - content_mask: full_mask(), - color: gpui::rgba(0x10172870).into(), - element_bounds: backing_bounds, - element_corner_radii: radii, - inset: true.into(), - corner_smoothing: smoothing, - }); - scene.raise_order_floor(); - if let Some(row) = row { - scene.insert_primitive(Quad { - bounds: scaled( - 10.25 + fraction, - 12.5 + row as f32 * 24.0, - 165.5 - row as f32 * 0.75, - 13.25, - ), - content_mask: ContentMask { - bounds: scaled( - 11.5 + fraction, - 12.75 + row as f32 * 24.0, - 162.5 - row as f32 * 0.25, - 12.5, - ), - fade_out: gpui::Edges { - left: ScaledPixels(1.25), - right: ScaledPixels(1.75), - ..Default::default() - }, - }, - background: gpui::rgba(0x4f98df70).into(), - corner_radii: gpui::Corners::all(ScaledPixels(3.5 + row as f32 * 0.25)), - corner_smoothing: smoothing, - ..Default::default() - }); - } - let paint_badge = |scene: &mut Scene| { - if let Some(row) = row { - // The live history row adds an entire lane-name badge only while - // hovered. Variable run lengths and distant row moves must repaint - // both the old and new badge without losing the static text anchor. - for index in 0..7 + row { - scene.insert_primitive(MonochromeSprite { - order: 0, - padding: 0, - bounds: scaled( - 112.25 + fraction + index as f32 * 6.5, - 14.5 + row as f32 * 24.0, - 6.5, - 10.75, - ), - content_mask: full_mask(), - color: gpui::hsla(0.6, 0.4, 0.6, 0.6).into(), - tile: glyph, - transformation: Default::default(), - }); - } - } - }; - scene.raise_order_floor(); - if badge_before_graph == Some(true) { - paint_badge(&mut scene); - scene.raise_order_floor(); - } - scene.insert_primitive(triangle( - 17.25 + fraction, - 10.5, - 17.5, - ContentMask { - bounds: scaled(19.5 + fraction, 13.25, 13.75, 12.5), - fade_out: gpui::Edges::all(ScaledPixels(0.75)), - }, - )); - scene.raise_order_floor(); - if badge_before_graph == Some(false) { - paint_badge(&mut scene); - } - scene.raise_order_floor(); - scene.insert_primitive(MonochromeSprite { - order: 0, - padding: 0, - bounds: scaled(44.25 + fraction, 14.5, 12.5, 10.75), - content_mask: ContentMask { - bounds: scaled(45.0 + fraction, 14.25, 11.5, 11.5), - fade_out: gpui::Edges::all(ScaledPixels(0.75)), - }, - color: gpui::hsla(0.12, 0.8, 0.65, 0.85).into(), - tile: glyph, - transformation: Default::default(), - }); - scene.raise_order_floor(); - scene.insert_primitive(Quad { - bounds: scaled(80.25 + fraction, 6.5, 16.5, 78.25), - content_mask: full_mask(), - background: gpui::rgba(0xda624d60).into(), - corner_radii: gpui::Corners::all(ScaledPixels(4.25)), - corner_smoothing: smoothing, - ..Default::default() - }); - scene.finish(); - scene - } - - #[test] - fn retained_transparent_composite_hover_matches_exact_rgba_and_invalidates_appearance() - -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - for badge_before_graph in [None, Some(false), Some(true)] { - for (fraction, smoothing, backing_alpha) in [ - (0.0, 0.0, 0.45), - (0.25, 0.65, 1.0), - (0.5, 0.0, 1.0), - (0.75, 0.65, 0.45), - ] { - let target = size(DevicePixels(200), DevicePixels(400)); - let mut full = WgpuRenderer::new_headless(&context, target)?; - let mut retained = WgpuRenderer::new_headless(&context, target)?; - full.retained.disable_for_tests(); - retained.retained.enable_for_tests(); - full.update_transparency(true); - retained.update_transparency(true); - let glyph = retained_glyph_tile(&full)?; - assert_eq!(glyph, retained_glyph_tile(&retained)?); - let label = format!( - "fraction={fraction}, smoothing={smoothing}, backing={backing_alpha}, badge_before_graph={badge_before_graph:?}" - ); - let mut revision = 0; - let mut previous = None; - let resting = transparent_composite_hover_scene( - None, - fraction, - smoothing, - backing_alpha, - glyph, - badge_before_graph, - ); - let mut resting_pixels = None; - for (index, row) in [ - None, - None, - Some(0), - Some(0), - Some(2), - Some(0), - Some(1), - Some(2), - None, - None, - ] - .into_iter() - .enumerate() - { - if index == 0 || row != previous { - revision += 1; - } - let scene = transparent_composite_hover_scene( - row, - fraction, - smoothing, - backing_alpha, - glyph, - badge_before_graph, - ); - if badge_before_graph == Some(true) - && let Some(row) = row - { - assert!(scene.render_commands().windows(2).any(|commands| - matches!((&commands[0], &commands[1]), - (gpui::RenderCommand::Batch(gpui::PrimitiveBatch::Quads { range: quad, .. }), - gpui::RenderCommand::Batch(gpui::PrimitiveBatch::MonochromeSprites { range: glyphs, .. })) - if quad.len() == 1 && glyphs.len() == 7 + row)), - "fixture must prove fill directly precedes the changed badge: {label}"); - } - let image = assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - if index == 0 { - "full" - } else if row == previous { - "reuse" - } else { - "partial" - }, - &format!("{label}, step={index}, row={row:?}"), - )?; - assert!(image.pixels().any(|pixel| pixel[3] > 0 && pixel[3] < 255)); - assert!( - image.get_pixel(0, 0)[3] < 255, - "rounded outside corner: {label}" - ); - if backing_alpha == 1.0 { - assert_eq!(image.get_pixel(150, 85)[3], 255, "opaque interior: {label}"); - } - if index == 0 { - resting_pixels = Some(image.clone()); - } else if row.is_none() { - assert_eq!( - Some(&image), - resting_pixels.as_ref(), - "erase restores every byte: {label}" - ); - } else { - assert_ne!( - Some(&image), - resting_pixels.as_ref(), - "hover must visibly alter the fixture: {label}" - ); - } - previous = row; - } - // Invalidate independently of scene revision: resize, alpha mode, - // clear color, and atlas generation must never reuse stale pixels. - let resized = size(DevicePixels(193), DevicePixels(97)); - full.update_drawable_size(resized); - retained.update_drawable_size(resized); - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "full", - &format!("resize: {label}"), - )?; - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "reuse", - &format!("resized reuse: {label}"), - )?; - for transparent in [false, true] { - full.update_transparency(transparent); - retained.update_transparency(transparent); - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "full", - &format!("alpha transition to {transparent}: {label}"), - )?; - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "reuse", - &format!("alpha reuse {transparent}: {label}"), - )?; - } - // A direct clear-color change must be detected by the retained - // appearance key even when no public alpha toggle fires. - full.target.clear_color = wgpu::Color { - r: 0.04, - g: 0.03, - b: 0.02, - a: 0.25, - }; - retained.target.clear_color = full.target.clear_color; - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "full", - &format!("clear-color transition: {label}"), - )?; - assert_retained_rgba( - &mut full, - &mut retained, - &resting, - revision, - "full", - &format!("unsupported clear-color fallback: {label}"), - )?; - // Restore an exact endpoint so atlas invalidation still tests - // a qualified retained target rather than the unsupported clear. - full.target.clear_color = wgpu::Color::TRANSPARENT; - retained.target.clear_color = full.target.clear_color; - full.atlas.clear(); - retained.atlas.clear(); - let glyph = retained_glyph_tile(&full)?; - assert_eq!(glyph, retained_glyph_tile(&retained)?); - let scene = transparent_composite_hover_scene( - None, - fraction, - smoothing, - backing_alpha, - glyph, - badge_before_graph, - ); - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - "full", - &format!("atlas invalidation: {label}"), - )?; - assert_retained_rgba( - &mut full, - &mut retained, - &scene, - revision, - "reuse", - &format!("atlas reuse: {label}"), - )?; - } - } - Ok(()) - } - - #[test] - fn cropped_path_targets_match_full_viewport_pixels_when_growing_and_resizing() { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None).expect("hardware or software GPU"); - let target = size(DevicePixels(512), DevicePixels(320)); - let mask = ContentMask { - bounds: Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(1024.0), ScaledPixels(640.0)), - ), - ..Default::default() - }; - let mut full = WgpuRenderer::new_headless(&context, target).unwrap(); - let mut cropped = WgpuRenderer::new_headless(&context, target).unwrap(); - for (x, y, target) in [ - (16.0, 16.0, target), - (180.0, 120.0, target), - (16.0, 16.0, target), - (40.0, 35.0, size(DevicePixels(360), DevicePixels(240))), - ] { - full.update_drawable_size(target); - cropped.update_drawable_size(target); - let mut force_full = Scene::default(); - force_full.paths.push(triangle( - 0.0, - 0.0, - target.width.0.max(target.height.0) as f32, - mask, - )); - full.ensure_path_textures(&force_full); - - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds: mask.bounds, - content_mask: mask, - background: solid_background(gpui::black()), - ..Default::default() - }); - scene.insert_primitive(triangle(x, y, 47.5, mask)); - // A later path beyond the viewport makes the combined sprite span - // empty space past the cropped target. That space must stay black. - scene.insert_primitive(Quad { - bounds: Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(1.0), ScaledPixels(1.0)), - ), - content_mask: mask, - background: solid_background(gpui::black()), - ..Default::default() - }); - scene.insert_primitive(triangle(800.0, 10.0, 40.0, mask)); - // Negative geometry and a clipped curve exercise coordinate/clip - // preservation independently of the viewport's dimensions. - let mut curve = PathBuilder::stroke(px(1.6)); - curve.move_to(point(px(-10.0), px(30.0))); - curve.cubic_bezier_to( - point(px(40.0), px(70.0)), - point(px(24.0), px(22.0)), - point(px(5.0), px(60.0)), - ); - let mut curve = curve.build().unwrap().scale(1.0); - curve.content_mask = ContentMask { - bounds: Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(30.0), ScaledPixels(60.0)), - ), - ..Default::default() - }; - curve.color = solid_background(gpui::white()); - scene.insert_primitive(curve); - scene.finish(); - let expected = full.render_to_image(&scene).expect("full target frame"); - let actual = cropped - .render_to_image(&scene) - .expect("cropped target frame"); - assert!( - actual.get_pixel(x as u32 + 8, y as u32 + 8)[0] > 100, - "triangle must render" - ); - assert_eq!( - &actual.get_pixel(300, 200).0[..3], - &[0, 0, 0], - "no clamped edge smear" - ); - let max_diff = actual - .as_raw() - .iter() - .zip(expected.as_raw()) - .map(|(a, b)| a.abs_diff(*b)) - .max() - .unwrap(); - assert!( - max_diff <= 1, - "cropped rendering changed a pixel by {max_diff}" - ); - let texture = cropped - .resources() - .path_intermediate_texture - .as_ref() - .unwrap(); - assert!( - texture.width() < target.width.0 as u32, - "narrow scene must keep a narrow target" - ); - assert!(texture.height() < target.height.0 as u32); - if let Ok(directory) = std::env::var("GPUI_PATH_TEST_IMAGES") { - actual - .save( - std::path::Path::new(&directory) - .join(format!("cropped-{x}-{y}-{}.png", target.width.0)), - ) - .unwrap(); - expected - .save( - std::path::Path::new(&directory) - .join(format!("full-{x}-{y}-{}.png", target.width.0)), - ) - .unwrap(); - } - } - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs b/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs deleted file mode 100644 index e4553e400..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/lazy_smoothing_tests.rs +++ /dev/null @@ -1,227 +0,0 @@ -use super::{WgpuRenderer, pipelines::SmoothingRequirements}; -use crate::WgpuContext; -use gpui::{ - AtlasKey, AtlasTile, BackdropFilter, Bounds, ContentMask, Corners, DevicePixels, - FilterBoundary, ImageId, PlatformAtlas, PolychromeSprite, Quad, RenderImageParams, - ScaledFilter, ScaledPixels, Scene, Shadow, point, size, solid_background, -}; -use std::borrow::Cow; - -fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { - Bounds::new( - point(ScaledPixels(x), ScaledPixels(y)), - size(ScaledPixels(width), ScaledPixels(height)), - ) -} - -fn handles(renderer: &WgpuRenderer) -> [Option; 4] { - let pipelines = &renderer.resources().pipelines; - [ - &pipelines.smoothed_quads, - &pipelines.smoothed_shadows, - &pipelines.smoothed_polychrome_sprites, - &pipelines.smoothed_blur_composite, - ] - .map(|pipeline| { - pipeline - .as_ref() - .map(|pipeline| std::ops::Deref::deref(pipeline).clone()) - }) -} - -fn image_tile(renderer: &WgpuRenderer) -> anyhow::Result { - renderer - .atlas - .get_or_insert_with( - &AtlasKey::Image(RenderImageParams { - image_id: ImageId(9081), - frame_index: 0, - }), - &mut || { - Ok(Some(( - size(DevicePixels(8), DevicePixels(8)), - Cow::Owned(vec![255; 8 * 8 * 4]), - ))) - }, - )? - .ok_or_else(|| anyhow::anyhow!("test image must have pixels")) -} - -fn scene(kind: usize, tile: AtlasTile) -> Scene { - let mut scene = Scene::default(); - let shape = bounds(20.0, 20.0, 56.0, 56.0); - let content_mask = ContentMask { - bounds: bounds(0.0, 0.0, 96.0, 96.0), - ..Default::default() - }; - let corner_radii = Corners::all(ScaledPixels(18.0)); - match kind { - 0 => scene.insert_primitive(Quad { - bounds: shape, - content_mask, - corner_radii, - corner_smoothing: 0.75, - background: solid_background(gpui::white()), - ..Default::default() - }), - 1 => scene.insert_primitive(Shadow { - order: 0, - bounds: shape, - element_bounds: shape, - content_mask, - corner_radii, - element_corner_radii: corner_radii, - corner_smoothing: 0.75, - color: solid_background(gpui::white()), - blur_radius: ScaledPixels(5.0), - inset: false.into(), - }), - 2 => scene.insert_primitive(PolychromeSprite { - order: 0, - bounds: shape, - content_mask, - corner_radii, - corner_smoothing: 0.75, - grayscale: false.into(), - opacity: 1.0, - tile, - }), - 3 => { - scene.insert_primitive(Quad { - bounds: shape, - content_mask, - background: gpui::checkerboard(gpui::white(), 2.0), - ..Default::default() - }); - scene.raise_order_floor(); - scene.insert_primitive(BackdropFilter { - order: 0, - bounds: shape, - content_mask, - corner_radii, - corner_smoothing: 0.75, - filters: smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(5.0))], - opacity: 1.0, - }); - } - 4 | 5 => { - let boundary = FilterBoundary { - order: 0, - bounds: shape, - content_mask, - corner_radii, - corner_smoothing: 0.75, - filters: if kind == 4 { - smallvec::smallvec![ScaledFilter::Blur(ScaledPixels(5.0))] - } else { - // Upstream also composites zero-blur opacity groups. - smallvec::smallvec![] - }, - opacity: 1.0, - is_start: true, - }; - scene.insert_primitive(boundary.clone()); - scene.raise_order_floor(); - scene.insert_primitive(Quad { - bounds: shape, - content_mask, - background: solid_background(gpui::white()), - ..Default::default() - }); - scene.raise_order_floor(); - scene.insert_primitive(FilterBoundary { - is_start: false, - ..boundary - }); - } - _ => unreachable!(), - } - scene.finish(); - scene -} - -#[test] -fn smoothing_pipelines_are_absent_until_first_use_and_match_prepared_pixels() -> anyhow::Result<()> -{ - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let target = size(DevicePixels(96), DevicePixels(96)); - let mut lazy = WgpuRenderer::new_headless(&context, target)?; - let mut prepared = WgpuRenderer::new_headless(&context, target)?; - lazy.retained.disable_for_tests(); - prepared.retained.disable_for_tests(); - let tile = image_tile(&lazy)?; - assert_eq!(tile, image_tile(&prepared)?); - assert!(handles(&lazy).iter().all(Option::is_none)); - - let mut ordinary = Scene::default(); - ordinary.insert_primitive(Quad { - bounds: bounds(0.0, 0.0, 96.0, 96.0), - content_mask: ContentMask { - bounds: bounds(0.0, 0.0, 96.0, 96.0), - ..Default::default() - }, - background: solid_background(gpui::white()), - ..Default::default() - }); - ordinary.finish(); - assert_eq!( - lazy.render_to_image(&ordinary)?.get_pixel(48, 48).0, - [255; 4] - ); - assert!( - handles(&lazy).iter().all(Option::is_none), - "ordinary frames need no smoothing" - ); - - prepared.resources_mut().pipelines.prepare_smoothing( - &context.device, - SmoothingRequirements { - quads: true, - shadows: true, - polychrome_sprites: true, - blur_composite: true, - }, - )?; - for kind in 0..6 { - if kind >= 4 { - // Target/device resource rebuilds must not retain old pipeline handles. - lazy.rebuild_pipelines(); - assert!(handles(&lazy).iter().all(Option::is_none)); - } - let scene = scene(kind, tile); - let first_use = lazy.render_to_image(&scene)?; - let reference = prepared.render_to_image(&scene)?; - assert_eq!( - first_use, reference, - "first-use pixels for smoothing kind {kind}" - ); - assert!( - first_use.pixels().any(|pixel| pixel.0[..3] != [0; 3]), - "kind {kind} must draw" - ); - if kind == 0 || kind == 2 { - assert_eq!(first_use.get_pixel(48, 48).0, [255; 4]); - assert_eq!( - first_use.get_pixel(20, 20).0, - [0, 0, 0, 255], - "the first smoothed fill/image must exclude its corner" - ); - } - let created = handles(&lazy); - let expected = if kind >= 4 { - [false, false, false, true] - } else { - std::array::from_fn(|index| index <= kind) - }; - assert_eq!(created.each_ref().map(Option::is_some), expected); - assert_eq!(lazy.render_to_image(&scene)?, first_use); - assert_eq!( - handles(&lazy), - created, - "second use must reuse pipeline handles" - ); - } - assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); - Ok(()) -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs b/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs deleted file mode 100644 index 83d0ce543..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/path_types.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[cfg(test)] -pub(super) use gpui_render::path_types::STORAGE_ABI; -pub(super) use gpui_render::path_types::{ - PathRasterizationVertex, PathSprite, rasterization_vertex_count, rasterization_vertices, - sprite_count, sprites, -}; diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs b/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs deleted file mode 100644 index bb4bfc4a2..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/pipelines.rs +++ /dev/null @@ -1,1432 +0,0 @@ -use crate::RendererTier; -use gpui_render::{ - artifacts::{ - BASE_DOWNLEVEL_WGSL, BASE_WGSL, BLUR_BINDINGS, DOWNLEVEL_BLUR_BINDINGS, - DOWNLEVEL_INSTANCE_BINDINGS, DOWNLEVEL_RANGE_BINDING, DOWNLEVEL_SURFACE_BINDINGS, - DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, GLOBAL_BINDINGS, GeneratedBinding, - GeneratedBindingKind, INSTANCE_BINDINGS, MONOCHROME_INSTANCE_BINDINGS, - SUBPIXEL_DUAL_SOURCE_WGSL, SUBPIXEL_INSTANCE_BINDINGS, SURFACE_BINDINGS, - TEXTURED_INSTANCE_BINDINGS, - }, - shaders::interface as shader, -}; -use std::num::NonZeroU64; - -/// Group-1 payload: a storage buffer on modern tiers, a data texture plus per-batch -/// range uniform on downlevel. -pub(super) enum InstanceBindingSource<'a> { - Buffer(wgpu::BufferBinding<'a>), - DataTexture { - texture: &'a wgpu::TextureView, - range_uniforms: wgpu::BufferBinding<'a>, - }, -} - -fn instance_binding_entries(source: InstanceBindingSource<'_>) -> Vec> { - match source { - InstanceBindingSource::Buffer(instances) => { - vec![wgpu::BindGroupEntry { - binding: shader::DATA_BUFFER_BINDING, - resource: wgpu::BindingResource::Buffer(instances), - }] - } - InstanceBindingSource::DataTexture { - texture, - range_uniforms, - } => { - vec![ - wgpu::BindGroupEntry { - binding: shader::DATA_BUFFER_BINDING, - resource: wgpu::BindingResource::TextureView(texture), - }, - wgpu::BindGroupEntry { - binding: DOWNLEVEL_RANGE_BINDING, - resource: wgpu::BindingResource::Buffer(range_uniforms), - }, - ] - } - } -} - -pub(super) struct WgpuPipelines { - pub(super) quads: WgpuRenderPipeline, - pub(super) damage_clear_quads: Option, - // Handles already used by the ordinary Quad pipelines; retaining their references - // creates no additional GPU allocation or shader compilation at startup. - base_shader: wgpu::ShaderModule, - instance_layout: wgpu::PipelineLayout, - damage_clear_format: wgpu::TextureFormat, - pub(super) smoothed_quads: Option, - pub(super) shadows: WgpuRenderPipeline, - pub(super) smoothed_shadows: Option, - pub(super) path_rasterization: WgpuRenderPipeline, - pub(super) paths: WgpuRenderPipeline, - pub(super) underlines: WgpuRenderPipeline, - pub(super) monochrome_sprites: WgpuRenderPipeline, - pub(super) subpixel_sprites: Option, - pub(super) polychrome_sprites: WgpuRenderPipeline, - pub(super) smoothed_polychrome_sprites: Option, - #[cfg_attr( - not(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - )), - allow(dead_code) - )] - pub(super) surfaces: WgpuRenderPipeline, - pub(super) blur_downsample: WgpuRenderPipeline, - pub(super) blur: WgpuRenderPipeline, - pub(super) blur_composite: WgpuRenderPipeline, - pub(super) smoothed_blur_composite: Option, - // Retain handles already used by the eager pipelines, so first use only - // compiles the requested smoothing variant. Rebuilding this collection for - // a new device/target also replaces these descriptors and empty slots. - smoothing_textured_layout: wgpu::PipelineLayout, - smoothing_blur_layout: wgpu::PipelineLayout, - smoothing_scene_target: wgpu::ColorTargetState, - smoothing_composite_target: wgpu::ColorTargetState, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(super) struct SmoothingRequirements { - pub(super) quads: bool, - pub(super) shadows: bool, - pub(super) polychrome_sprites: bool, - pub(super) blur_composite: bool, -} - -impl SmoothingRequirements { - fn is_empty(self) -> bool { - self == Self::default() - } -} - -pub(super) struct WgpuRenderPipeline { - raw: wgpu::RenderPipeline, - specification: shader::Pipeline, -} - -impl WgpuRenderPipeline { - pub(super) fn data_layout(&self) -> shader::DataLayout { - self.specification.data_layout - } - - pub(super) fn fixed_vertex_count(&self) -> u32 { - self.specification.vertex_count.fixed().unwrap_or_else(|| { - panic!( - "pipeline {} uses a workload-defined vertex count", - self.specification.label - ) - }) - } -} - -impl std::ops::Deref for WgpuRenderPipeline { - type Target = wgpu::RenderPipeline; - - fn deref(&self) -> &Self::Target { - &self.raw - } -} - -pub(super) struct WgpuBindGroupLayouts { - pub(super) globals: wgpu::BindGroupLayout, - pub(super) instances: wgpu::BindGroupLayout, - monochrome_sprites: wgpu::BindGroupLayout, - subpixel_sprites: wgpu::BindGroupLayout, - textured_instances: wgpu::BindGroupLayout, - pub(super) surfaces: wgpu::BindGroupLayout, - pub(super) blur: wgpu::BindGroupLayout, -} - -impl WgpuBindGroupLayouts { - pub(super) fn new(device: &wgpu::Device, tier: RendererTier) -> Self { - let ( - instance_table, - monochrome_table, - subpixel_table, - textured_table, - surface_table, - blur_table, - instance_dynamic, - ) = match tier { - RendererTier::Modern => ( - INSTANCE_BINDINGS, - MONOCHROME_INSTANCE_BINDINGS, - SUBPIXEL_INSTANCE_BINDINGS, - TEXTURED_INSTANCE_BINDINGS, - SURFACE_BINDINGS, - BLUR_BINDINGS, - None, - ), - RendererTier::WebGl2 => ( - DOWNLEVEL_INSTANCE_BINDINGS, - DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, - DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, - DOWNLEVEL_TEXTURED_INSTANCE_BINDINGS, - DOWNLEVEL_SURFACE_BINDINGS, - DOWNLEVEL_BLUR_BINDINGS, - Some(DOWNLEVEL_RANGE_BINDING), - ), - }; - let globals = generated_bind_group_layout(device, "globals_layout", GLOBAL_BINDINGS, None); - let instances = generated_bind_group_layout( - device, - "instances_layout", - instance_table, - instance_dynamic, - ); - let monochrome_sprites = generated_bind_group_layout( - device, - "monochrome_sprites_layout", - monochrome_table, - instance_dynamic, - ); - let subpixel_sprites = generated_bind_group_layout( - device, - "subpixel_sprites_layout", - subpixel_table, - instance_dynamic, - ); - let textured_instances = generated_bind_group_layout( - device, - "textured_instances_layout", - textured_table, - instance_dynamic, - ); - let surfaces = generated_bind_group_layout( - device, - "surfaces_layout", - surface_table, - Some(shader::DATA_BUFFER_BINDING), - ); - let blur = generated_bind_group_layout( - device, - "blur_layout", - blur_table, - Some(shader::DATA_BUFFER_BINDING), - ); - Self { - globals, - instances, - monochrome_sprites, - subpixel_sprites, - textured_instances, - surfaces, - blur, - } - } - - pub(super) fn create_globals( - &self, - device: &wgpu::Device, - label: &str, - globals: wgpu::BufferBinding, - font_rasterization: wgpu::BufferBinding, - ) -> wgpu::BindGroup { - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(label), - layout: &self.globals, - entries: &[ - wgpu::BindGroupEntry { - binding: shader::GLOBAL_UNIFORMS_BINDING, - resource: wgpu::BindingResource::Buffer(globals), - }, - wgpu::BindGroupEntry { - binding: shader::FONT_RASTERIZATION_BINDING, - resource: wgpu::BindingResource::Buffer(font_rasterization), - }, - ], - }) - } - - /// Creates the group-1 data bind group for the tier's binding source. - pub(super) fn create_instances( - &self, - device: &wgpu::Device, - source: InstanceBindingSource<'_>, - ) -> wgpu::BindGroup { - let entries = instance_binding_entries(source); - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("instances"), - layout: &self.instances, - entries: &entries, - }) - } - - pub(super) fn create_textured_instances( - &self, - device: &wgpu::Device, - data_layout: shader::DataLayout, - source: InstanceBindingSource<'_>, - texture: &wgpu::TextureView, - sampler: &wgpu::Sampler, - ) -> wgpu::BindGroup { - let mut entries = instance_binding_entries(source); - entries.push(wgpu::BindGroupEntry { - binding: shader::PRIMARY_TEXTURE_BINDING, - resource: wgpu::BindingResource::TextureView(texture), - }); - entries.push(wgpu::BindGroupEntry { - binding: shader::PRIMARY_SAMPLER_BINDING, - resource: wgpu::BindingResource::Sampler(sampler), - }); - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("textured_instances"), - layout: match data_layout { - shader::DataLayout::MonochromeSprites => &self.monochrome_sprites, - shader::DataLayout::SubpixelSprites => &self.subpixel_sprites, - shader::DataLayout::TexturedInstances => &self.textured_instances, - _ => panic!("{data_layout:?} does not use a textured instance layout"), - }, - entries: &entries, - }) - } - - #[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - ))] - pub(super) fn create_surface( - &self, - device: &wgpu::Device, - uniforms: wgpu::BufferBinding, - color: &wgpu::TextureView, - chroma: &wgpu::TextureView, - sampler: &wgpu::Sampler, - ) -> wgpu::BindGroup { - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("surface"), - layout: &self.surfaces, - entries: &[ - wgpu::BindGroupEntry { - binding: shader::DATA_BUFFER_BINDING, - resource: wgpu::BindingResource::Buffer(uniforms), - }, - wgpu::BindGroupEntry { - binding: shader::PRIMARY_TEXTURE_BINDING, - resource: wgpu::BindingResource::TextureView(color), - }, - wgpu::BindGroupEntry { - binding: shader::SECONDARY_TEXTURE_BINDING, - resource: wgpu::BindingResource::TextureView(chroma), - }, - wgpu::BindGroupEntry { - binding: shader::SURFACE_SAMPLER_BINDING, - resource: wgpu::BindingResource::Sampler(sampler), - }, - ], - }) - } - - pub(super) fn create_blur( - &self, - device: &wgpu::Device, - uniforms: wgpu::BufferBinding, - texture: &wgpu::TextureView, - sampler: &wgpu::Sampler, - ) -> wgpu::BindGroup { - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("blur"), - layout: &self.blur, - entries: &[ - wgpu::BindGroupEntry { - binding: shader::DATA_BUFFER_BINDING, - resource: wgpu::BindingResource::Buffer(uniforms), - }, - wgpu::BindGroupEntry { - binding: shader::PRIMARY_TEXTURE_BINDING, - resource: wgpu::BindingResource::TextureView(texture), - }, - wgpu::BindGroupEntry { - binding: shader::PRIMARY_SAMPLER_BINDING, - resource: wgpu::BindingResource::Sampler(sampler), - }, - ], - }) - } -} - -impl WgpuPipelines { - /// Prepare before encoding, while the renderer can still mutably borrow its - /// resources. Ordinary frames do no GPU work here. Native failures discard - /// only this attempt's pipelines; the caller applies the frame retry policy. - pub(super) fn prepare_smoothing( - &mut self, - device: &wgpu::Device, - required: SmoothingRequirements, - ) -> Result<(), wgpu::Error> { - let missing = SmoothingRequirements { - quads: required.quads && self.smoothed_quads.is_none(), - shadows: required.shadows && self.smoothed_shadows.is_none(), - polychrome_sprites: required.polychrome_sprites - && self.smoothed_polychrome_sprites.is_none(), - blur_composite: required.blur_composite && self.smoothed_blur_composite.is_none(), - }; - if missing.is_empty() { - return Ok(()); - } - - // WebGPU reports validation asynchronously through the existing device - // callback. Native backends can reject failed creation before encoding. - #[cfg(not(target_family = "wasm"))] - let scopes = [ - device.push_error_scope(wgpu::ErrorFilter::Validation), - device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), - device.push_error_scope(wgpu::ErrorFilter::Internal), - ]; - if missing.quads { - self.smoothed_quads = Some(create_render_pipeline( - device, - shader::SMOOTHED_QUADS, - &self.instance_layout, - &self.smoothing_scene_target, - 1, - &self.base_shader, - )); - } - if missing.shadows { - self.smoothed_shadows = Some(create_render_pipeline( - device, - shader::SMOOTHED_SHADOWS, - &self.instance_layout, - &self.smoothing_scene_target, - 1, - &self.base_shader, - )); - } - if missing.polychrome_sprites { - self.smoothed_polychrome_sprites = Some(create_render_pipeline( - device, - shader::SMOOTHED_POLYCHROME_SPRITES, - &self.smoothing_textured_layout, - &self.smoothing_scene_target, - 1, - &self.base_shader, - )); - } - if missing.blur_composite { - self.smoothed_blur_composite = Some(create_render_pipeline( - device, - shader::SMOOTHED_BLUR_COMPOSITE, - &self.smoothing_blur_layout, - &self.smoothing_composite_target, - 1, - &self.base_shader, - )); - } - #[cfg(not(target_family = "wasm"))] - { - let mut creation_error = None; - // Preserve fatal errors over validation errors, and always pop all - // scopes even when several pipeline creations failed. - for scope in scopes.into_iter().rev() { - if let Some(error) = gpui::block_on(scope.pop()) { - creation_error.get_or_insert(error); - } - } - if let Some(error) = creation_error { - if missing.quads { - self.smoothed_quads = None; - } - if missing.shadows { - self.smoothed_shadows = None; - } - if missing.polychrome_sprites { - self.smoothed_polychrome_sprites = None; - } - if missing.blur_composite { - self.smoothed_blur_composite = None; - } - return Err(error); - } - } - Ok(()) - } - - pub(super) fn ensure_damage_clear(&mut self, device: &wgpu::Device) { - if self.damage_clear_quads.is_none() { - self.damage_clear_quads = Some(create_render_pipeline( - device, - shader::QUADS, - &self.instance_layout, - &color_target(self.damage_clear_format, None), - 1, - &self.base_shader, - )); - } - } - - pub(super) fn new( - device: &wgpu::Device, - bind_group_layouts: &WgpuBindGroupLayouts, - surface_format: wgpu::TextureFormat, - alpha_mode: wgpu::CompositeAlphaMode, - path_sample_count: u32, - dual_source_blending: bool, - tier: RendererTier, - ) -> Self { - // Warm runtime rebuilds keep the original serial error-scope behavior. - Self::new_with_workers( - device, - bind_group_layouts, - surface_format, - alpha_mode, - path_sample_count, - dual_source_blending, - tier, - 1, - ) - .expect("serial pipeline creation reports errors through the caller's device scopes") - } - - pub(super) fn new_for_backend( - device: &wgpu::Device, - bind_group_layouts: &WgpuBindGroupLayouts, - surface_format: wgpu::TextureFormat, - alpha_mode: wgpu::CompositeAlphaMode, - path_sample_count: u32, - dual_source_blending: bool, - tier: RendererTier, - _backend: wgpu::Backend, - ) -> Result { - let workers = 1; - #[cfg(target_os = "windows")] - let workers = if _backend == wgpu::Backend::Dx12 { - std::thread::available_parallelism() - .map_or(1, std::num::NonZeroUsize::get) - .min(3) - } else { - workers - }; - Self::new_with_workers( - device, - bind_group_layouts, - surface_format, - alpha_mode, - path_sample_count, - dual_source_blending, - tier, - workers, - ) - } - - fn new_with_workers( - device: &wgpu::Device, - bind_group_layouts: &WgpuBindGroupLayouts, - surface_format: wgpu::TextureFormat, - alpha_mode: wgpu::CompositeAlphaMode, - path_sample_count: u32, - dual_source_blending: bool, - tier: RendererTier, - _workers: usize, - ) -> Result { - let dual_source_blending = tier == RendererTier::Modern - && supported_dual_source_blending(device, dual_source_blending); - let base_source = match tier { - RendererTier::Modern => BASE_WGSL, - // GLES lacks vertex-stage storage; the dialect reads the data texture. - RendererTier::WebGl2 => BASE_DOWNLEVEL_WGSL, - }; - let shader_module = create_shader_module(device, "gpui_shaders", base_source); - let subpixel_shader_module = dual_source_blending.then(|| { - device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("gpui_subpixel_shaders"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( - SUBPIXEL_DUAL_SOURCE_WGSL, - )), - }) - }); - - let instance_layout = create_pipeline_layout( - device, - "instance_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.instances, - ); - let monochrome_layout = create_pipeline_layout( - device, - "monochrome_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.monochrome_sprites, - ); - let subpixel_layout = create_pipeline_layout( - device, - "subpixel_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.subpixel_sprites, - ); - let textured_layout = create_pipeline_layout( - device, - "textured_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.textured_instances, - ); - let surface_layout = create_pipeline_layout( - device, - "surface_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.surfaces, - ); - let blur_layout = create_pipeline_layout( - device, - "blur_pipeline_layout", - bind_group_layouts, - &bind_group_layouts.blur, - ); - - let scene_target = color_target(surface_format, Some(scene_blend_state(alpha_mode))); - let path_rasterization_target = color_target( - surface_format, - Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - ); - let path_target = color_target(surface_format, Some(path_blend_state())); - let overwrite_target = color_target(surface_format, None); - let composite_target = - color_target(surface_format, Some(premultiplied_composite_blend_state())); - - let layout_for = |data_layout| match data_layout { - shader::DataLayout::Instances => &instance_layout, - shader::DataLayout::TexturedInstances => &textured_layout, - shader::DataLayout::MonochromeSprites => &monochrome_layout, - shader::DataLayout::SubpixelSprites => &subpixel_layout, - shader::DataLayout::NativeOnly => { - panic!("native-only shader pipeline cannot be created by wgpu") - } - shader::DataLayout::Surface => &surface_layout, - shader::DataLayout::Blur => &blur_layout, - }; - let describe = - |specification: shader::Pipeline, target, samples, module| PipelineCreation { - specification, - layout: layout_for(specification.data_layout), - target, - sample_count: samples, - module, - }; - let subpixel_target = wgpu::ColorTargetState { - write_mask: wgpu::ColorWrites::COLOR, - ..color_target(surface_format, Some(subpixel_blend_state())) - }; - let mut requests = vec![ - describe(shader::QUADS, &scene_target, 1, &shader_module), - describe(shader::SHADOWS, &scene_target, 1, &shader_module), - describe( - shader::PATH_RASTERIZATION, - &path_rasterization_target, - path_sample_count, - &shader_module, - ), - describe(shader::PATHS, &path_target, 1, &shader_module), - describe(shader::UNDERLINES, &scene_target, 1, &shader_module), - describe(shader::MONOCHROME_SPRITES, &scene_target, 1, &shader_module), - ]; - if let Some(module) = subpixel_shader_module.as_ref() { - requests.push(describe( - shader::SUBPIXEL_SPRITES, - &subpixel_target, - 1, - module, - )); - } - requests.extend([ - describe(shader::POLYCHROME_SPRITES, &scene_target, 1, &shader_module), - describe(shader::SURFACES, &composite_target, 1, &shader_module), - // Keep the two shared fullscreen vertex stages in one worker batch. - describe( - shader::BLUR_DOWNSAMPLE, - &overwrite_target, - 1, - &shader_module, - ), - describe(shader::BLUR, &overwrite_target, 1, &shader_module), - describe(shader::BLUR_COMPOSITE, &composite_target, 1, &shader_module), - ]); - #[cfg(target_os = "windows")] - let pipelines = if _workers > 1 { - create_pipelines_parallel(device, &requests, _workers)? - } else { - requests - .iter() - .map(|request| request.create(device)) - .collect() - }; - #[cfg(not(target_os = "windows"))] - let pipelines: Vec<_> = requests - .iter() - .map(|request| request.create(device)) - .collect(); - let mut pipelines = pipelines.into_iter(); - let mut next = || { - pipelines - .next() - .expect("every requested pipeline was created") - }; - let quads = next(); - let shadows = next(); - let path_rasterization = next(); - let paths = next(); - let underlines = next(); - let monochrome_sprites = next(); - let subpixel_sprites = subpixel_shader_module.as_ref().map(|_| next()); - - Ok(Self { - quads, - damage_clear_quads: None, - smoothed_quads: None, - shadows, - smoothed_shadows: None, - path_rasterization, - paths, - underlines, - monochrome_sprites, - subpixel_sprites, - polychrome_sprites: next(), - smoothed_polychrome_sprites: None, - surfaces: next(), - blur_downsample: next(), - blur: next(), - blur_composite: next(), - smoothed_blur_composite: None, - smoothing_textured_layout: textured_layout, - smoothing_blur_layout: blur_layout, - smoothing_scene_target: scene_target, - smoothing_composite_target: composite_target, - base_shader: shader_module, - instance_layout: instance_layout, - damage_clear_format: surface_format, - }) - } -} - -struct PipelineCreation<'a> { - specification: shader::Pipeline, - layout: &'a wgpu::PipelineLayout, - target: &'a wgpu::ColorTargetState, - sample_count: u32, - module: &'a wgpu::ShaderModule, -} - -impl PipelineCreation<'_> { - fn create(&self, device: &wgpu::Device) -> WgpuRenderPipeline { - create_render_pipeline( - device, - self.specification, - self.layout, - self.target, - self.sample_count, - self.module, - ) - } -} - -#[cfg(target_os = "windows")] -fn create_pipelines_parallel( - device: &wgpu::Device, - requests: &[PipelineCreation<'_>], - workers: usize, -) -> Result, wgpu::Error> { - use std::{ - panic::{AssertUnwindSafe, catch_unwind, resume_unwind}, - sync::atomic::{AtomicUsize, Ordering}, - }; - - // Adjacent pipelines sharing a vertex stage stay sequential: wgpu's DX12 - // shader cache releases its lock during compilation and is not single-flight. - let mut batches: Vec> = Vec::new(); - for (index, request) in requests.iter().enumerate() { - if index > 0 - && request.specification.vertex_entry == requests[index - 1].specification.vertex_entry - && request.module == requests[index - 1].module - && request.layout == requests[index - 1].layout - { - batches.last_mut().unwrap().end = index + 1; - } else { - batches.push(index..index + 1); - } - } - let next_batch = AtomicUsize::new(0); - let run_worker = || { - // Error scopes are thread-local. Parent initialization scopes cannot - // observe a worker failure, so drain all three scopes on the worker. - let scopes = [ - device.push_error_scope(wgpu::ErrorFilter::Validation), - device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), - device.push_error_scope(wgpu::ErrorFilter::Internal), - ]; - let mut pipelines = Vec::new(); - while let Some(batch) = batches.get(next_batch.fetch_add(1, Ordering::Relaxed)) { - for index in batch.clone() { - pipelines.push((index, requests[index].create(device))); - } - } - let mut error = None; - for scope in scopes.into_iter().rev() { - if let Some(captured) = gpui::block_on(scope.pop()) { - retain_pipeline_error(&mut error, captured); - } - } - match error { - Some(error) => Err(error), - None => Ok(pipelines), - } - }; - let results = std::thread::scope(|scope| { - // Include the calling thread in the bound; never start one thread per PSO. - let mut handles = Vec::new(); - for index in 1..workers.clamp(1, 3).min(batches.len().max(1)) { - match std::thread::Builder::new() - .name(format!("gpui-dx12-pipelines-{index}")) - .spawn_scoped(scope, &run_worker) - { - Ok(handle) => handles.push(handle), - // Already-started workers and the caller can finish the queue. - Err(_) => break, - } - } - let caller = catch_unwind(AssertUnwindSafe(&run_worker)); - let mut results = vec![caller]; - // Join every worker before returning errors or resuming any panic. - results.extend(handles.into_iter().map(|handle| handle.join())); - results - }); - let mut pipelines = Vec::with_capacity(requests.len()); - let mut error = None; - let mut panic = None; - for result in results { - match result { - Ok(Ok(created)) => pipelines.extend(created), - Ok(Err(captured)) => retain_pipeline_error(&mut error, captured), - Err(payload) => { - panic.get_or_insert(payload); - } - } - } - if let Some(payload) = panic { - resume_unwind(payload); - } - if let Some(error) = error { - return Err(error); - } - pipelines.sort_unstable_by_key(|(index, _)| *index); - Ok(pipelines - .into_iter() - .map(|(_, pipeline)| pipeline) - .collect()) -} - -#[cfg(target_os = "windows")] -fn retain_pipeline_error(slot: &mut Option, error: wgpu::Error) { - // Never hide a fatal worker error behind a validation failure from another. - if slot.is_none() - || matches!(slot, Some(wgpu::Error::Validation { .. })) - && !matches!(error, wgpu::Error::Validation { .. }) - { - *slot = Some(error); - } -} - -fn generated_bind_group_layout( - device: &wgpu::Device, - label: &str, - bindings: &[GeneratedBinding], - dynamic_uniform_binding: Option, -) -> wgpu::BindGroupLayout { - let entries = bindings - .iter() - .map(|binding| wgpu::BindGroupLayoutEntry { - binding: binding.binding, - visibility: wgpu::ShaderStages::from_bits_retain(binding.visibility), - ty: match binding.kind { - GeneratedBindingKind::Uniform(min_size) => wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: dynamic_uniform_binding == Some(binding.binding), - min_binding_size: NonZeroU64::new(min_size), - }, - GeneratedBindingKind::StorageRead(min_size) => wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: NonZeroU64::new(min_size), - }, - GeneratedBindingKind::Texture2dFloat => wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - GeneratedBindingKind::FilteringSampler => { - wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering) - } - GeneratedBindingKind::DataTexture => wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Uint, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - GeneratedBindingKind::RangeUniform => wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: true, - // The generated `DATA_RANGE` batch base, padded to satisfy - // browser/WebGL downlevel uniform-binding alignment. - min_binding_size: NonZeroU64::new(16), - }, - }, - count: None, - }) - .collect::>(); - - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some(label), - entries: &entries, - }) -} - -fn create_shader_module( - device: &wgpu::Device, - label: &str, - source: &'static str, -) -> wgpu::ShaderModule { - device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some(label), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(source)), - }) -} - -fn create_pipeline_layout( - device: &wgpu::Device, - label: &str, - layouts: &WgpuBindGroupLayouts, - data_layout: &wgpu::BindGroupLayout, -) -> wgpu::PipelineLayout { - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some(label), - bind_group_layouts: &[Some(&layouts.globals), Some(data_layout)], - immediate_size: 0, - }) -} - -fn create_render_pipeline( - device: &wgpu::Device, - specification: shader::Pipeline, - layout: &wgpu::PipelineLayout, - color_target: &wgpu::ColorTargetState, - sample_count: u32, - module: &wgpu::ShaderModule, -) -> WgpuRenderPipeline { - let raw = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(specification.label), - layout: Some(layout), - vertex: wgpu::VertexState { - module, - entry_point: Some(specification.vertex_entry), - buffers: &[], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - fragment: Some(wgpu::FragmentState { - module, - entry_point: Some(specification.fragment_entry), - targets: &[Some(color_target.clone())], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }), - primitive: wgpu::PrimitiveState { - topology: match specification.topology { - shader::PrimitiveTopology::TriangleList => wgpu::PrimitiveTopology::TriangleList, - shader::PrimitiveTopology::TriangleStrip => wgpu::PrimitiveTopology::TriangleStrip, - }, - ..Default::default() - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: sample_count, - ..Default::default() - }, - multiview_mask: None, - cache: None, - }); - WgpuRenderPipeline { raw, specification } -} - -fn color_target( - format: wgpu::TextureFormat, - blend: Option, -) -> wgpu::ColorTargetState { - wgpu::ColorTargetState { - format, - blend, - write_mask: wgpu::ColorWrites::ALL, - } -} - -fn supported_dual_source_blending(device: &wgpu::Device, requested: bool) -> bool { - let supported = device - .features() - .contains(wgpu::Features::DUAL_SOURCE_BLENDING); - if requested && !supported { - log::error!( - "dual-source blending was requested but is absent from device features {:?}; falling \ - back to monochrome text rendering", - device.features(), - ); - } - requested && supported -} - -fn scene_blend_state(alpha_mode: wgpu::CompositeAlphaMode) -> wgpu::BlendState { - let source_color_factor = if alpha_mode == wgpu::CompositeAlphaMode::PreMultiplied { - wgpu::BlendFactor::One - } else { - wgpu::BlendFactor::SrcAlpha - }; - source_over_blend_state(source_color_factor) -} - -fn premultiplied_composite_blend_state() -> wgpu::BlendState { - source_over_blend_state(wgpu::BlendFactor::One) -} - -fn source_over_blend_state(source_color_factor: wgpu::BlendFactor) -> wgpu::BlendState { - wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: source_color_factor, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - } -} - -fn path_blend_state() -> wgpu::BlendState { - source_over_blend_state(wgpu::BlendFactor::One) -} - -fn subpixel_blend_state() -> wgpu::BlendState { - wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::Src1, - dst_factor: wgpu::BlendFactor::OneMinusSrc1, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - } -} - -#[cfg(all(test, any(target_os = "macos", target_os = "windows")))] -pub(super) fn desktop_scene_blend_state(alpha_mode: wgpu::CompositeAlphaMode) -> wgpu::BlendState { - scene_blend_state(alpha_mode) -} - -#[cfg(all(test, not(target_family = "wasm")))] -mod tests { - use super::*; - use crate::WgpuContext; - - #[cfg(target_os = "windows")] - #[test] - fn parallel_pipeline_failure_is_captured_and_allows_retry() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - anyhow::ensure!(context.adapter.get_info().backend == wgpu::Backend::Dx12); - let device = &context.device; - let layouts = WgpuBindGroupLayouts::new(device, context.renderer_tier()); - let layout = create_pipeline_layout(device, "parallel_test", &layouts, &layouts.instances); - let module = create_shader_module(device, "parallel_test", BASE_WGSL); - let valid_target = color_target(context.color_texture_format(), None); - let invalid_target = color_target(wgpu::TextureFormat::Depth24Plus, None); - let requests = [ - PipelineCreation { - specification: shader::SHADOWS, - layout: &layout, - target: &valid_target, - sample_count: 1, - module: &module, - }, - PipelineCreation { - specification: shader::QUADS, - layout: &layout, - target: &invalid_target, - sample_count: 1, - module: &module, - }, - PipelineCreation { - specification: shader::UNDERLINES, - layout: &layout, - target: &valid_target, - sample_count: 1, - module: &module, - }, - ]; - let parent_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); - let error = match create_pipelines_parallel(device, &requests, 3) { - Ok(_) => panic!("invalid worker pipeline was accepted"), - Err(error) => error, - }; - assert!(matches!(error, wgpu::Error::Validation { .. })); - assert!(gpui::block_on(parent_scope.pop()).is_none()); - assert!( - context.uncaptured_error_slot().lock().unwrap().is_none(), - "worker errors must be returned, not leak into an unrelated frame" - ); - let mut retry = requests; - retry[1].target = &valid_target; - let pipelines = create_pipelines_parallel(device, &retry, 3)?; - assert_eq!( - pipelines - .iter() - .map(|pipeline| pipeline.specification.label) - .collect::>(), - ["shadows", "quads", "underlines"], - "completion order must not change pipeline assignment" - ); - assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); - Ok(()) - } - - #[cfg(all(feature = "test-support", target_os = "windows"))] - #[test] - fn parallel_pipeline_creation_matches_serial_pixels() -> anyhow::Result<()> { - use super::super::WgpuRenderer; - use gpui::{ - Bounds, ContentMask, Corners, DevicePixels, Quad, ScaledPixels, Scene, Shadow, point, - size, solid_background, - }; - - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - anyhow::ensure!(context.adapter.get_info().backend == wgpu::Backend::Dx12); - let target = size(DevicePixels(64), DevicePixels(64)); - let mut parallel = WgpuRenderer::new_headless(&context, target)?; - let mut serial = WgpuRenderer::new_headless(&context, target)?; - for (renderer, workers) in [(&mut parallel, 3), (&mut serial, 1)] { - renderer.retained.disable_for_tests(); - let config = renderer.target.configuration().clone(); - let samples = renderer.rendering_params.path_sample_count; - let dual_source = renderer.dual_source_blending; - let resources = renderer.resources_mut(); - resources.pipelines = WgpuPipelines::new_with_workers( - &resources.device, - &resources.bind_group_layouts, - config.format, - config.alpha_mode, - samples, - dual_source, - resources.renderer_tier, - workers, - )?; - assert_eq!( - resources.pipelines.subpixel_sprites.is_some(), - dual_source, - "parallel creation must retain supported subpixel rendering" - ); - assert!(resources.pipelines.smoothed_quads.is_none()); - assert!(resources.pipelines.smoothed_shadows.is_none()); - } - let bounds = Bounds::new( - point(ScaledPixels(16.0), ScaledPixels(16.0)), - size(ScaledPixels(32.0), ScaledPixels(32.0)), - ); - let content_mask = ContentMask { - bounds: Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(64.0), ScaledPixels(64.0)), - ), - ..Default::default() - }; - let corners = Corners::all(ScaledPixels(8.0)); - let mut scene = Scene::default(); - scene.insert_primitive(Shadow { - order: 0, - bounds, - element_bounds: bounds, - content_mask, - corner_radii: corners, - element_corner_radii: corners, - corner_smoothing: 0.0, - color: solid_background(gpui::white()), - blur_radius: ScaledPixels(4.0), - inset: false.into(), - }); - scene.insert_primitive(Quad { - bounds, - content_mask, - corner_radii: corners, - background: solid_background(gpui::white()), - ..Default::default() - }); - scene.finish(); - let reference = serial.render_to_image(&scene)?; - assert_eq!(reference.get_pixel(32, 32).0, [255; 4]); - assert_eq!(parallel.render_to_image(&scene)?, reference); - assert_eq!(parallel.render_to_image(&scene)?, reference); - assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); - Ok(()) - } - - #[test] - fn smoothing_creation_failure_rolls_back_only_new_pipelines() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let tier = context.renderer_tier(); - let layouts = WgpuBindGroupLayouts::new(&context.device, tier); - let mut pipelines = WgpuPipelines::new( - &context.device, - &layouts, - context.color_texture_format(), - wgpu::CompositeAlphaMode::Opaque, - 1, - false, - tier, - ); - pipelines.prepare_smoothing( - &context.device, - SmoothingRequirements { - quads: true, - ..Default::default() - }, - )?; - let quads = pipelines.smoothed_quads.as_ref().unwrap().raw.clone(); - let target = pipelines.smoothing_scene_target.clone(); - pipelines.smoothing_scene_target.format = wgpu::TextureFormat::Depth24Plus; - let requirements = SmoothingRequirements { - shadows: true, - ..Default::default() - }; - let error = pipelines - .prepare_smoothing(&context.device, requirements) - .unwrap_err(); - assert!(matches!(error, wgpu::Error::Validation { .. })); - assert!(pipelines.smoothed_shadows.is_none()); - assert_eq!(pipelines.smoothed_quads.as_ref().unwrap().raw, quads); - assert!( - context.uncaptured_error_slot().lock().unwrap().is_none(), - "checked failure must not leak into an unrelated frame" - ); - pipelines.smoothing_scene_target = target; - pipelines.prepare_smoothing(&context.device, requirements)?; - assert!( - pipelines.smoothed_shadows.is_some(), - "a failed first use can retry" - ); - Ok(()) - } - - #[cfg(all(feature = "test-support", target_os = "windows"))] - #[test] - fn lazy_smoothing_failures_follow_dx12_frame_retry_policy() -> anyhow::Result<()> { - use super::super::WgpuRenderer; - use gpui::{Bounds, ContentMask, DevicePixels, Quad, ScaledPixels, Scene, point, size}; - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let mut renderer = - WgpuRenderer::new_headless(&context, size(DevicePixels(32), DevicePixels(32)))?; - renderer.native_backend = Some(crate::NativeBackend::Dx12); - renderer - .resources_mut() - .pipelines - .smoothing_scene_target - .format = wgpu::TextureFormat::Depth24Plus; - let bounds = Bounds::new( - point(ScaledPixels(0.0), ScaledPixels(0.0)), - size(ScaledPixels(32.0), ScaledPixels(32.0)), - ); - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds, - content_mask: ContentMask { - bounds, - ..Default::default() - }, - background: gpui::white().into(), - corner_smoothing: 0.65, - ..Default::default() - }); - scene.finish(); - assert!(renderer.render_to_image(&scene).is_err()); - assert!(renderer.resources().pipelines.smoothed_quads.is_none()); - assert!(renderer.faults.pending_error.lock().unwrap().is_some()); - assert!(renderer.needs_redraw()); - for _ in 0..20 { - assert!(renderer.render_to_image(&scene).is_err()); - if renderer.terminal_error().is_some() { - break; - } - } - assert!( - renderer - .terminal_error() - .is_some_and(|message| message.contains("smoothing pipelines")), - "repeated creation failure must reach the existing terminal policy" - ); - assert!(renderer.resources().pipelines.smoothed_quads.is_none()); - Ok(()) - } - - #[test] - fn generated_layouts_create_every_pipeline_and_bind_group() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - let context = WgpuContext::new_headless(None)?; - let device = &context.device; - let tier = context.renderer_tier(); - let layouts = WgpuBindGroupLayouts::new(device, tier); - let mut pipelines = WgpuPipelines::new( - device, - &layouts, - context.color_texture_format(), - wgpu::CompositeAlphaMode::Opaque, - 1, - context.supports_dual_source_blending(), - tier, - ); - pipelines.prepare_smoothing( - device, - SmoothingRequirements { - quads: true, - shadows: true, - polychrome_sprites: true, - blur_composite: true, - }, - )?; - - let buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("shader_interface_test_buffer"), - size: 256, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::STORAGE, - mapped_at_creation: false, - }); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("shader_interface_test_texture"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); - let binding = || wgpu::BufferBinding { - buffer: &buffer, - offset: 0, - size: NonZeroU64::new(256), - }; - - let _globals = layouts.create_globals(device, "test_globals", binding(), binding()); - let _instances = layouts.create_instances(device, InstanceBindingSource::Buffer(binding())); - let _textured = layouts.create_textured_instances( - device, - shader::DataLayout::MonochromeSprites, - InstanceBindingSource::Buffer(binding()), - &view, - &sampler, - ); - #[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - ))] - let _surface = layouts.create_surface(device, binding(), &view, &view, &sampler); - let _blur = layouts.create_blur(device, binding(), &view, &sampler); - Ok(()) - } - - /// The downlevel artifacts must also build and bind against a real device. - #[test] - fn downlevel_tier_builds_pipelines_and_data_texture_arena() -> anyhow::Result<()> { - let _gpu_test_guard = crate::test_gpu::guard(); - use super::super::buffers::{InstanceBufferArena, InstanceTransport}; - use gpui::Quad; - - let context = WgpuContext::new_headless(None)?; - let device = &context.device; - let tier = crate::RendererTier::WebGl2; - let layouts = WgpuBindGroupLayouts::new(device, tier); - let mut pipelines = WgpuPipelines::new( - device, - &layouts, - context.color_texture_format(), - wgpu::CompositeAlphaMode::Opaque, - 1, - false, - tier, - ); - assert!(pipelines.quads.fixed_vertex_count() > 0); - pipelines.prepare_smoothing( - device, - SmoothingRequirements { - quads: true, - shadows: true, - polychrome_sprites: true, - blur_composite: true, - }, - )?; - - let mut arena = InstanceBufferArena::new(device, &layouts, tier); - assert_eq!(arena.transport(), InstanceTransport::DataTexture); - arena - .ensure_capacity(device, &layouts, 4096, 8) - .then_some(()) - .expect("small downlevel capacities must always be available"); - let mut upload = arena - .begin_upload(&context.queue, 4096, 8) - .expect("mapped downlevel staging"); - - let custom_dash = Quad { - border_dashed_length: 4.0, - border_dashed_gap: 0.5, - ..Quad::default() - }; - let slice = upload - .write(&[custom_dash, Quad::default()]) - .expect("small batch must fit"); - // Downlevel draws start at instance zero; the base travels in the range offset. - assert_eq!(slice.range(), 0..2); - - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("downlevel_test_texture"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); - let _textured = layouts.create_textured_instances( - device, - shader::DataLayout::MonochromeSprites, - arena.binding_source(), - &view, - &sampler, - ); - - let mut encoder = - device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); - upload.finish(&mut encoder); - let _commands = encoder.finish(); - Ok(()) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs b/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs deleted file mode 100644 index 9386961d8..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/platform.rs +++ /dev/null @@ -1,715 +0,0 @@ -#[cfg(not(target_family = "wasm"))] -use std::rc::Rc; -use std::sync::Arc; - -use gpui::Scene; - -#[cfg(not(target_family = "wasm"))] -use crate::{CompositorGpuHint, NativeBackend, SoftwareAdapterPolicy, WgpuDeviceRequirements}; -use crate::{WgpuAtlas, WgpuContext}; - -#[cfg(not(target_family = "wasm"))] -use super::GpuContext; -use super::{WgpuRenderer, WgpuSurfaceConfig}; - -/// Recovery is waiting for the driver to stabilize; no recreation was attempted. -#[derive(Debug)] -pub struct RecoveryPending; -impl std::fmt::Display for RecoveryPending { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("waiting for the GPU driver to stabilize before recovery") - } -} -impl std::error::Error for RecoveryPending {} - -#[cfg(not(target_family = "wasm"))] -use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; - -#[cfg(all(test, target_os = "linux"))] -#[path = "surface_tests.rs"] -mod tests; - -impl WgpuRenderer { - /// Creates a renderer whose surface and GPU context follow the native window lifetime. - #[cfg(not(target_family = "wasm"))] - pub fn new( - gpu_context: GpuContext, - window: &W, - config: WgpuSurfaceConfig, - compositor_gpu: Option, - extra_requirements: Option, - ) -> anyhow::Result - where - W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, - { - let window_handle = window - .window_handle() - .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; - Self::new_for_target( - gpu_context, - &|| Some(Box::new(window.clone())), - NativeSurfaceTarget::Window(window_handle.as_raw()), - config, - compositor_gpu, - extra_requirements, - None, - ) - } - - /// Creates a Windows renderer using hardware DX12 only, including during recovery. - #[cfg(target_os = "windows")] - pub fn new_dx12( - gpu_context: GpuContext, - window: &W, - config: WgpuSurfaceConfig, - compositor_gpu: Option, - extra_requirements: Option, - ) -> anyhow::Result - where - W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, - { - let handle = window - .window_handle() - .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; - Self::new_for_target( - gpu_context, - &|| Some(Box::new(window.clone())), - NativeSurfaceTarget::Window(handle.as_raw()), - config, - compositor_gpu, - extra_requirements, - Some(NativeBackend::Dx12), - ) - } - - /// Creates a renderer presenting to a `CAMetalLayer`, such as the backing - /// layer of a layer-backed `NSView`. The surface retains the layer. - #[cfg(target_os = "macos")] - pub fn new_for_metal_layer( - gpu_context: GpuContext, - layer: &metal::MetalLayerRef, - config: WgpuSurfaceConfig, - extra_requirements: Option, - ) -> anyhow::Result { - Self::new_for_target( - gpu_context, - &|| None, - NativeSurfaceTarget::metal_layer(layer), - config, - None, - extra_requirements, - None, - ) - } - - #[cfg(not(target_family = "wasm"))] - fn new_for_target( - gpu_context: GpuContext, - display: DisplayHandleSource<'_>, - surface_target: NativeSurfaceTarget, - config: WgpuSurfaceConfig, - compositor_gpu: Option, - extra_requirements: Option, - backend: Option, - ) -> anyhow::Result { - let mut context_slot = gpu_context.borrow_mut(); - let (context, surface) = match context_slot.as_mut() { - Some(context) => { - let surface = create_surface(&context.instance, surface_target)?; - context.check_compatible_with_surface(&surface)?; - (context, surface) - } - None => { - let (context, surface) = initialize_context_and_surface( - display, - surface_target, - compositor_gpu, - if backend.is_some() { - SoftwareAdapterPolicy::Reject - } else { - SoftwareAdapterPolicy::Allow - }, - extra_requirements.as_ref(), - backend, - )?; - (context_slot.insert(context), surface) - } - }; - let mut renderer = initialize_checked(context, backend, || { - Self::new_internal( - Some(Rc::clone(&gpu_context)), - context, - Some(surface), - config, - compositor_gpu, - extra_requirements, - Arc::new(WgpuAtlas::from_context(context)), - ) - })?; - renderer.native_backend = backend; - Ok(renderer) - } - - #[cfg(target_family = "wasm")] - #[allow(clippy::arc_with_non_send_sync)] - pub fn new_from_surface( - context: &WgpuContext, - surface: wgpu::Surface<'static>, - config: WgpuSurfaceConfig, - ) -> anyhow::Result { - Self::new_internal( - None, - context, - Some(surface), - config, - None, - None, - Arc::new(WgpuAtlas::from_context(context)), - ) - } - - /// Acquires, renders, and presents one native or canvas surface frame. - pub fn draw(&mut self, scene: &Scene) -> bool { - if !self.target.is_configured() { - return false; - } - let frame = match self - .resources() - .surface - .as_ref() - .expect("draw requires a configured surface") - .get_current_texture() - { - wgpu::CurrentSurfaceTexture::Success(frame) => frame, - wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { - drop(frame); - self.reconfigure_surface(); - return false; - } - wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { - self.reconfigure_surface(); - return false; - } - wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { - self.target.request_redraw(); - return false; - } - wgpu::CurrentSurfaceTexture::Validation => { - self.target.request_redraw(); - // This frame never reaches render_to_view; account for its error here so - // persistent acquisition failures cannot bypass the terminal-error limit. - if self.reports_terminal_errors() { - super::frame::handle_gpu_error(self, "surface texture validation error".into()); - } else { - *self.faults.pending_error.lock().unwrap() = - Some("surface texture validation error".into()); - } - return false; - } - }; - let view = frame - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - let rendered = self.render_to_texture(scene, &frame.texture, &view); - if rendered { - frame.present(); - } else { - // Vulkan's native swapchain cannot release an acquired image just - // by dropping it. Repeated failed frames would exhaust its images - // and make subsequent acquisitions time out until a resize. - drop(view); - drop(frame); - self.reconfigure_surface(); - } - rendered - } - - fn reconfigure_surface(&mut self) { - self.target.request_redraw(); - let config = self.target.configuration().clone(); - let resources = self.resources_mut(); - resources - .surface - .as_ref() - .expect("draw requires a configured surface") - .configure(&resources.device, &config); - } - - /// Keeps device resources alive while detaching a destroyed native surface. - pub fn unconfigure_surface(&mut self) { - self.release_retained_frame(); - self.target.set_configured(false); - if let Some(resources) = self.resources.as_mut() { - resources.invalidate_intermediate_textures(); - } - } - - #[cfg(not(target_family = "wasm"))] - pub fn replace_surface( - &mut self, - window: &W, - config: WgpuSurfaceConfig, - instance: &wgpu::Instance, - ) -> anyhow::Result<()> { - let window_handle = window - .window_handle() - .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; - let surface = create_surface( - instance, - NativeSurfaceTarget::Window(window_handle.as_raw()), - )?; - // A replacement surface can expose different present modes (for example when a - // window moves between displays or Wayland/X11 surfaces). Query the new surface, - // rather than reusing capabilities from the old target. - let capabilities = { - let context_slot = self.context.as_ref().ok_or_else(|| { - anyhow::anyhow!("native surface replacement requires a GPU context") - })?; - let context_slot = context_slot.borrow(); - let context = context_slot.as_ref().ok_or_else(|| { - anyhow::anyhow!("native surface replacement requires an initialized GPU context") - })?; - let capabilities = surface.get_capabilities(&context.adapter); - if capabilities.formats.is_empty() { - let info = context.adapter.get_info(); - anyhow::bail!( - "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the display surface for this window.", - info.name, - info.backend, - info.device, - ); - } - capabilities - }; - if self.target.apply(config, &capabilities)? { - self.rebuild_pipelines(); - } - let target_config = self.target.configuration().clone(); - - let resources = self - .resources - .as_mut() - .expect("GPU resources not available"); - surface.configure(&resources.device, &target_config); - resources.surface = Some(surface); - resources.invalidate_intermediate_textures(); - self.target.set_configured(true); - Ok(()) - } - - pub fn destroy(&mut self) { - self.retained.invalidate(); - self.resources.take(); - } - - pub fn device_lost(&self) -> bool { - self.faults - .device_lost - .load(std::sync::atomic::Ordering::Relaxed) - } - - pub fn needs_redraw(&mut self) -> bool { - self.target.take_redraw_request() - } - - #[cfg(not(target_family = "wasm"))] - pub fn recover(&mut self, window: &W) -> anyhow::Result<()> - where - W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, - { - let window_handle = window - .window_handle() - .map_err(|error| anyhow::anyhow!("failed to get window handle: {error}"))?; - self.recover_target( - &|| Some(Box::new(window.clone())), - NativeSurfaceTarget::Window(window_handle.as_raw()), - ) - } - - /// Recovers a renderer made by [`Self::new_for_metal_layer`]. - #[cfg(target_os = "macos")] - pub fn recover_metal_layer(&mut self, layer: &metal::MetalLayerRef) -> anyhow::Result<()> { - self.recover_target(&|| None, NativeSurfaceTarget::metal_layer(layer)) - } - - #[cfg(not(target_family = "wasm"))] - fn recover_target( - &mut self, - display: DisplayHandleSource<'_>, - surface_target: NativeSurfaceTarget, - ) -> anyhow::Result<()> { - let gpu_context = self.context.as_ref().expect("recover requires gpu_context"); - let needs_new_context = gpu_context - .borrow() - .as_ref() - .is_none_or(WgpuContext::device_lost); - if needs_new_context { - let now = std::time::Instant::now(); - match self.faults.recovery_not_before { - None => { - self.faults.recovery_not_before = - Some(now + std::time::Duration::from_millis(350)); - return Err(RecoveryPending.into()); - } - Some(not_before) if now < not_before => { - return Err(RecoveryPending.into()); - } - Some(_) => self.faults.recovery_not_before = None, - } - } else { - self.faults.recovery_not_before = None; - } - - let surface = if needs_new_context { - log::warn!("GPU device lost, recreating context..."); - self.resources = None; - *gpu_context.borrow_mut() = None; - let (new_context, surface) = match initialize_context_and_surface( - display, - surface_target, - self.compositor_gpu, - SoftwareAdapterPolicy::Reject, - self.extra_requirements.as_ref(), - self.native_backend, - ) { - Ok(result) => result, - Err(error) => { - self.faults.recovery_not_before = - Some(std::time::Instant::now() + std::time::Duration::from_millis(350)); - return Err(error); - } - }; - *gpu_context.borrow_mut() = Some(new_context); - surface - } else { - let context_slot = gpu_context.borrow(); - let instance = &context_slot - .as_ref() - .expect("a recovered context must exist") - .instance; - create_surface(instance, surface_target)? - }; - - let config = self.target.recovery_config(); - let gpu_context = Rc::clone(gpu_context); - let context_slot = gpu_context.borrow(); - let context = context_slot.as_ref().expect("context should exist"); - self.resources = None; - - let font_rasterization = self.rendering_params.font_rasterization; - let subpixel_order = self.subpixel_order; - let mut recovered = initialize_checked(context, self.native_backend, || { - self.atlas.handle_device_lost(context); - Self::new_internal( - Some(Rc::clone(&gpu_context)), - context, - Some(surface), - config, - self.compositor_gpu, - self.extra_requirements.clone(), - Arc::clone(&self.atlas), - ) - })?; - recovered.native_backend = self.native_backend; - recovered.set_font_rasterization_settings(font_rasterization); - recovered.set_subpixel_order(subpixel_order); - *self = recovered; - log::info!("GPU recovery complete"); - Ok(()) - } - - #[cfg(target_os = "macos")] - pub fn set_presents_with_transaction(&self, enabled: bool) { - let Some(surface) = self - .resources - .as_ref() - .and_then(|resources| resources.surface.as_ref()) - else { - return; - }; - if let Some(surface) = unsafe { surface.as_hal::() } { - surface - .render_layer() - .lock() - .setPresentsWithTransaction(enabled); - } - } -} - -/// Where a native renderer presents its frames. -#[cfg(not(target_family = "wasm"))] -#[derive(Clone, Copy)] -enum NativeSurfaceTarget { - Window(raw_window_handle::RawWindowHandle), - /// A live `CAMetalLayer`, which the surface retains. - #[cfg(target_os = "macos")] - MetalLayer(*mut std::ffi::c_void), -} - -#[cfg(target_os = "macos")] -impl NativeSurfaceTarget { - fn metal_layer(layer: &metal::MetalLayerRef) -> Self { - use metal::foreign_types::ForeignTypeRef as _; - Self::MetalLayer(layer.as_ptr().cast()) - } -} - -/// The display handle a new wgpu instance is created with, if any; called once -/// per backend attempted. -#[cfg(not(target_family = "wasm"))] -type DisplayHandleSource<'a> = &'a dyn Fn() -> Option>; - -#[cfg(not(target_family = "wasm"))] -// Startup and recovery both reject invalid resources before declaring the renderer usable. -fn initialize_checked( - context: &WgpuContext, - backend: Option, - initialize: impl FnOnce() -> anyhow::Result, -) -> anyhow::Result { - if backend.is_none() { - return initialize(); - } - let scopes = [ - context - .device - .push_error_scope(wgpu::ErrorFilter::Validation), - context - .device - .push_error_scope(wgpu::ErrorFilter::OutOfMemory), - context.device.push_error_scope(wgpu::ErrorFilter::Internal), - ]; - let result = initialize(); - let mut initialization_error = None; - for scope in scopes.into_iter().rev() { - if let Some(error) = gpui::block_on(scope.pop()) { - initialization_error.get_or_insert_with(|| error.to_string()); - } - } - if let Some(error) = - initialization_error.or_else(|| context.fatal_error_slot().lock().unwrap().clone()) - { - anyhow::bail!("DX12 renderer initialization failed: {error}"); - } - result -} - -#[cfg(all(test, feature = "test-support", target_os = "windows"))] -mod initialization_tests { - use super::*; - use std::{cell::Cell, rc::Rc}; - - #[test] - fn invalid_pipeline_is_rejected_and_partial_resources_drop_before_failure_returns() { - struct PartialResources { - _pipeline: wgpu::RenderPipeline, - dropped: Rc>, - } - impl Drop for PartialResources { - fn drop(&mut self) { - self.dropped.set(true); - } - } - let context = WgpuContext::new_headless(None).unwrap(); - let dropped = Rc::new(Cell::new(false)); - let result = initialize_checked(&context, Some(NativeBackend::Dx12), || { - let shader = context - .device - .create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("recovery regression shader"), - source: wgpu::ShaderSource::Wgsl( - "@vertex fn main() -> @builtin(position) vec4 { return vec4(0.0); }" - .into(), - ), - }); - let pipeline = context - .device - .create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("injected invalid recovery pipeline"), - layout: None, - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("missing_entry_point"), - compilation_options: Default::default(), - buffers: &[], - }, - fragment: None, - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }); - Ok(PartialResources { - _pipeline: pipeline, - dropped: dropped.clone(), - }) - }); - let Err(error) = result else { - panic!("invalid DX12 pipeline was accepted") - }; - assert!( - error - .to_string() - .contains("DX12 renderer initialization failed") - ); - assert!(dropped.get()); - assert!(context.uncaptured_error_slot().lock().unwrap().is_none()); - // All scopes are drained, so a later healthy recreation can succeed. - assert_eq!( - initialize_checked(&context, Some(NativeBackend::Dx12), || Ok(12)).unwrap(), - 12 - ); - } - - #[test] - fn fatal_callbacks_are_checked_only_for_explicit_dx12_initialization() { - let context = WgpuContext::new_headless(None).unwrap(); - let fatal = context.fatal_error_slot(); - *fatal.lock().unwrap() = Some("injected out of memory".into()); - assert_eq!(initialize_checked(&context, None, || Ok(11)).unwrap(), 11); - let error = initialize_checked(&context, Some(NativeBackend::Dx12), || Ok(12)).unwrap_err(); - assert!(error.to_string().contains("out of memory")); - assert!(fatal.try_lock().is_ok()); - } -} - -#[cfg(not(target_family = "wasm"))] -fn initialize_context_and_surface( - display: DisplayHandleSource<'_>, - surface_target: NativeSurfaceTarget, - compositor_gpu: Option, - adapter_policy: SoftwareAdapterPolicy, - extra_requirements: Option<&WgpuDeviceRequirements>, - backend: Option, -) -> anyhow::Result<(WgpuContext, wgpu::Surface<'static>)> { - let attempt = |backend: NativeBackend| { - let instance = backend.instance(display()); - let surface = create_surface(&instance.raw, surface_target)?; - let context = WgpuContext::new_with_adapter_policy( - instance, - &surface, - compositor_gpu, - adapter_policy, - extra_requirements, - )?; - Ok((context, surface)) - }; - match backend { - Some(backend) => attempt(backend), - None => NativeBackend::try_in_preference_order("a GPU context for the window", attempt), - } -} - -#[cfg(not(target_family = "wasm"))] -fn create_surface( - instance: &wgpu::Instance, - surface_target: NativeSurfaceTarget, -) -> anyhow::Result> { - let target = match surface_target { - NativeSurfaceTarget::Window(raw_window_handle) => wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: None, - raw_window_handle, - }, - #[cfg(target_os = "macos")] - NativeSurfaceTarget::MetalLayer(layer) => { - wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer) - } - }; - // SAFETY: window handles come from live windows. Metal layers come from a - // `MetalLayerRef` borrowed for this call, and WGPU retains the layer. - unsafe { - instance - .create_surface_unsafe(target) - .map_err(|error| anyhow::anyhow!("failed to create surface: {error}")) - } -} - -#[cfg(all(test, target_os = "macos", feature = "test-support"))] -mod tests { - use std::sync::atomic::Ordering; - use std::time::Duration; - - use gpui::{Bounds, DevicePixels, Point, Quad, ScaledPixels, Size}; - - use super::*; - - fn renderer( - context: &GpuContext, - layer: &metal::MetalLayerRef, - ) -> anyhow::Result { - WgpuRenderer::new_for_metal_layer( - Rc::clone(context), - layer, - WgpuSurfaceConfig { - size: Size { - width: DevicePixels(2), - height: DevicePixels(2), - }, - transparent: false, - preferred_present_mode: None, - }, - None, - ) - } - - fn red_scene() -> Scene { - let bounds = Bounds { - origin: Point { - x: ScaledPixels(0.0), - y: ScaledPixels(0.0), - }, - size: Size { - width: ScaledPixels(2.0), - height: ScaledPixels(2.0), - }, - }; - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds, - content_mask: gpui::ContentMask { - bounds, - ..Default::default() - }, - background: gpui::solid_background(gpui::red()), - ..Default::default() - }); - scene.finish(); - scene - } - - #[test] - fn metal_layer_renderers_recover_onto_one_new_device() -> anyhow::Result<()> { - let context = GpuContext::default(); - let (first_layer, second_layer) = (metal::MetalLayer::new(), metal::MetalLayer::new()); - let mut first = renderer(&context, &first_layer)?; - let mut second = renderer(&context, &second_layer)?; - let (lost_device, _) = first.gpu_context(); - assert!(Arc::ptr_eq(&lost_device, &second.gpu_context().0)); - - context - .borrow() - .as_ref() - .expect("the renderers' context") - .device_lost_flag() - .store(true, Ordering::Relaxed); - assert!(first.device_lost() && second.device_lost()); - - // The driver is given time to settle before the context is recreated. - assert!(first.recover_metal_layer(&first_layer).is_err()); - assert!(first.device_lost()); - std::thread::sleep(Duration::from_millis(400)); - first.recover_metal_layer(&first_layer)?; - // The other window joins the context the first one recreated. - second.recover_metal_layer(&second_layer)?; - - let (device, _) = first.gpu_context(); - assert!(!Arc::ptr_eq(&device, &lost_device)); - assert!(Arc::ptr_eq(&device, &second.gpu_context().0)); - assert!(!first.device_lost() && !second.device_lost()); - for renderer in [&mut first, &mut second] { - let image = renderer.render_to_image(&red_scene())?; - assert_eq!(image.get_pixel(1, 1).0, [255, 0, 0, 255]); - } - Ok(()) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs b/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs deleted file mode 100644 index fc04a7f12..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/resources.rs +++ /dev/null @@ -1,434 +0,0 @@ -use std::{ - cell::RefCell, - num::NonZeroU64, - sync::{Arc, Mutex}, -}; - -use collections::FxHashMap; -use gpui::ScenePlanRequirements; - -use crate::WgpuContext; -use gpui_render::blur::downsampled_dimension; -use gpui_render::path_types::path_target_extent; -use gpui_render::shaders::{ - blur::BlurUniforms, - common::{FontRasterizationUniforms, GlobalUniforms}, - surface::SurfaceUniforms, -}; - -use super::{ - WgpuRenderer, - buffers::{DynamicUniformBuffer, InstanceBufferArena}, - filters::FrameUniformRequirements, - pipelines::{WgpuBindGroupLayouts, WgpuPipelines}, - settings::RenderingParameters, - surfaces::SurfaceCache, -}; - -const INITIAL_FILTER_UNIFORM_CAPACITY: u64 = 16; -const INITIAL_SURFACE_UNIFORM_CAPACITY: u64 = 8; - -/// Device-owned state that is replaced atomically during GPU recovery. -pub(super) struct WgpuResources { - pub(super) device: Arc, - pub(super) queue: Arc, - pub(super) renderer_tier: crate::RendererTier, - pub(super) surface: Option>, - pub(super) pipelines: WgpuPipelines, - pub(super) bind_group_layouts: WgpuBindGroupLayouts, - pub(super) atlas_sampler: wgpu::Sampler, - pub(super) surface_sampler: wgpu::Sampler, - pub(super) surface_uniforms: DynamicUniformBuffer, - #[cfg_attr( - not(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - )), - allow(dead_code) - )] - pub(super) surface_cache: RefCell, - pub(super) filter_uniforms: DynamicUniformBuffer, - blur_bind_groups: RefCell, - pub(super) globals_buffer: wgpu::Buffer, - pub(super) globals_bind_group: wgpu::BindGroup, - pub(super) path_globals_bind_group: wgpu::BindGroup, - pub(super) instances: InstanceBufferArena, - pub(super) path_intermediate_texture: Option, - pub(super) path_intermediate_view: Option, - pub(super) path_msaa_texture: Option, - pub(super) path_msaa_view: Option, - pub(super) scene_color_texture: Option, - pub(super) scene_color_view: Option, - pub(super) backdrop_snapshot_texture: Option, - pub(super) backdrop_snapshot_view: Option, - pub(super) blur_ping_texture: Option, - pub(super) blur_ping_view: Option, - pub(super) blur_pong_texture: Option, - pub(super) blur_pong_view: Option, - pub(super) filter_group_textures: Vec, - pub(super) filter_group_views: Vec, -} - -#[derive(Default)] -struct BlurBindGroups { - uniform_generation: u64, - groups: FxHashMap, -} - -pub(super) struct ResourceMetadata { - pub(super) globals: GlobalBufferLayout, - pub(super) last_error: Arc>>, -} - -pub(super) struct GlobalBufferLayout { - pub(super) path_offset: u64, - pub(super) font_offset: u64, - pub(super) maximum_uniform_buffer_size: u64, -} - -impl WgpuResources { - pub(super) fn new( - context: &WgpuContext, - surface: Option>, - surface_config: &wgpu::SurfaceConfiguration, - rendering: &RenderingParameters, - dual_source_blending: bool, - ) -> anyhow::Result<(Self, ResourceMetadata)> { - let device = Arc::clone(&context.device); - let queue = Arc::clone(&context.queue); - let renderer_tier = context.renderer_tier(); - let bind_group_layouts = WgpuBindGroupLayouts::new(&device, renderer_tier); - let pipelines = WgpuPipelines::new_for_backend( - &device, - &bind_group_layouts, - surface_config.format, - surface_config.alpha_mode, - rendering.path_sample_count, - dual_source_blending, - renderer_tier, - context.adapter.get_info().backend, - )?; - let linear_sampler = |label| { - device.create_sampler(&wgpu::SamplerDescriptor { - label: Some(label), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }) - }; - let atlas_sampler = linear_sampler("atlas_sampler"); - let surface_sampler = linear_sampler("surface_sampler"); - - let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; - let surface_uniforms = DynamicUniformBuffer::new( - &device, - "surface_uniforms", - INITIAL_SURFACE_UNIFORM_CAPACITY, - uniform_alignment, - ); - let filter_uniforms = DynamicUniformBuffer::new( - &device, - "filter_uniforms", - INITIAL_FILTER_UNIFORM_CAPACITY, - uniform_alignment, - ); - let globals_size = std::mem::size_of::() as u64; - let gamma_size = std::mem::size_of::() as u64; - let path_globals_offset = globals_size.next_multiple_of(uniform_alignment); - let gamma_offset = (path_globals_offset + globals_size).next_multiple_of(uniform_alignment); - let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("globals_buffer"), - size: gamma_offset + gamma_size, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let create_globals = |label, offset| { - bind_group_layouts.create_globals( - &device, - label, - wgpu::BufferBinding { - buffer: &globals_buffer, - offset, - size: NonZeroU64::new(globals_size), - }, - wgpu::BufferBinding { - buffer: &globals_buffer, - offset: gamma_offset, - size: NonZeroU64::new(gamma_size), - }, - ) - }; - let globals_bind_group = create_globals("globals_bind_group", 0); - let path_globals_bind_group = - create_globals("path_globals_bind_group", path_globals_offset); - let last_error = context.uncaptured_error_slot(); - let surface_cache = SurfaceCache::new(&device)?; - - let metadata = ResourceMetadata { - globals: GlobalBufferLayout { - path_offset: path_globals_offset, - font_offset: gamma_offset, - maximum_uniform_buffer_size: device.limits().max_buffer_size.min(u32::MAX as u64), - }, - last_error, - }; - let resources = Self { - instances: InstanceBufferArena::new(&device, &bind_group_layouts, renderer_tier), - renderer_tier, - device, - queue, - surface, - pipelines, - bind_group_layouts, - atlas_sampler, - surface_sampler, - surface_uniforms, - surface_cache: RefCell::new(surface_cache), - filter_uniforms, - blur_bind_groups: RefCell::default(), - globals_buffer, - globals_bind_group, - path_globals_bind_group, - path_intermediate_texture: None, - path_intermediate_view: None, - path_msaa_texture: None, - path_msaa_view: None, - scene_color_texture: None, - scene_color_view: None, - backdrop_snapshot_texture: None, - backdrop_snapshot_view: None, - blur_ping_texture: None, - blur_ping_view: None, - blur_pong_texture: None, - blur_pong_view: None, - filter_group_textures: Vec::new(), - filter_group_views: Vec::new(), - }; - Ok((resources, metadata)) - } - - pub(super) fn invalidate_intermediate_textures(&mut self) { - self.instances.invalidate_texture_bindings(); - self.blur_bind_groups.get_mut().groups.clear(); - self.path_intermediate_texture = None; - self.path_intermediate_view = None; - self.path_msaa_texture = None; - self.path_msaa_view = None; - self.scene_color_texture = None; - self.scene_color_view = None; - self.backdrop_snapshot_texture = None; - self.backdrop_snapshot_view = None; - self.blur_ping_texture = None; - self.blur_ping_view = None; - self.blur_pong_texture = None; - self.blur_pong_view = None; - self.filter_group_textures.clear(); - self.filter_group_views.clear(); - } - - pub(super) fn finish_frame_uploads(&self) { - self.filter_uniforms.finish_upload(); - self.surface_uniforms.finish_upload(); - } - - #[cfg(test)] - pub(super) fn blur_bind_group_count(&self) -> usize { - self.blur_bind_groups.borrow().groups.len() - } - - pub(super) fn blur_bind_group(&self, source: &wgpu::TextureView) -> wgpu::BindGroup { - let mut cache = self.blur_bind_groups.borrow_mut(); - let uniform_generation = self.filter_uniforms.generation(); - if cache.uniform_generation != uniform_generation { - cache.groups.clear(); - cache.uniform_generation = uniform_generation; - } - cache - .groups - .entry(source.clone()) - .or_insert_with(|| { - self.bind_group_layouts.create_blur( - &self.device, - wgpu::BufferBinding { - buffer: &self.filter_uniforms.buffer, - offset: 0, - size: NonZeroU64::new(std::mem::size_of::() as u64), - }, - source, - &self.surface_sampler, - ) - }) - .clone() - } -} - -impl WgpuRenderer { - pub(super) fn ensure_path_textures(&mut self, scene: &gpui::Scene) { - // Path targets cover only the paths' bounds. - let (mut width, mut height) = path_target_extent( - scene.paths.iter().map(|path| path.clipped_bounds()), - self.target.width(), - self.target.height(), - ); - if let Some(texture) = self.resources().path_intermediate_texture.as_ref() { - // Grow in coarse blocks to avoid reallocating for each small graph - // change. Window resize invalidates these targets as before. - if texture.width() >= width && texture.height() >= height { - return; - } - width = width.max(texture.width()); - height = height.max(texture.height()); - } - if width == 0 || height == 0 { - return; - } - let format = self.target.format(); - let sample_count = self.rendering_params.path_sample_count; - let resources = self.resources_mut(); - resources.instances.invalidate_texture_bindings(); - let (texture, view) = sampled_render_texture(&resources.device, format, width, height); - resources.path_intermediate_texture = Some(texture); - resources.path_intermediate_view = Some(view); - if let Some((texture, view)) = - msaa_texture(&resources.device, format, width, height, sample_count) - { - resources.path_msaa_texture = Some(texture); - resources.path_msaa_view = Some(view); - } - } - - pub(super) fn ensure_filter_textures(&mut self, requirements: &ScenePlanRequirements) { - let format = self.target.format(); - let width = self.target.width(); - let height = self.target.height(); - let blur_width = downsampled_dimension(width); - let blur_height = downsampled_dimension(height); - let resources = self.resources_mut(); - - if resources.scene_color_texture.is_none() { - let (texture, view) = sampled_render_texture(&resources.device, format, width, height); - resources.scene_color_texture = Some(texture); - resources.scene_color_view = Some(view); - } - - if requirements.uses_blur_target && resources.blur_ping_texture.is_none() { - let (texture, view) = - sampled_render_texture(&resources.device, format, blur_width, blur_height); - resources.blur_ping_texture = Some(texture); - resources.blur_ping_view = Some(view); - let (texture, view) = - sampled_render_texture(&resources.device, format, blur_width, blur_height); - resources.blur_pong_texture = Some(texture); - resources.blur_pong_view = Some(view); - } - - if requirements.backdrop_filter_count > 0 - && requirements.isolated_target_count > 0 - && resources.backdrop_snapshot_texture.is_none() - { - let (texture, view) = sampled_render_texture(&resources.device, format, width, height); - resources.backdrop_snapshot_texture = Some(texture); - resources.backdrop_snapshot_view = Some(view); - } - - while resources.filter_group_views.len() < requirements.isolated_target_count { - let (texture, view) = sampled_render_texture(&resources.device, format, width, height); - resources.filter_group_textures.push(texture); - resources.filter_group_views.push(view); - } - } - - pub(super) fn ensure_uniform_capacity( - &mut self, - requirements: FrameUniformRequirements, - ) -> bool { - let maximum_buffer_size = self.globals.maximum_uniform_buffer_size; - let resources = self.resources_mut(); - let filters = resources.filter_uniforms.ensure_capacity( - &resources.device, - requirements.filter_count, - maximum_buffer_size, - ); - let surfaces = resources.surface_uniforms.ensure_capacity( - &resources.device, - requirements.surface_count, - maximum_buffer_size, - ); - let capacity_available = filters && surfaces; - if !capacity_available { - log::error!( - "scene uniform data exceeds the GPU buffer limit: {} filter uniforms and {} surface uniforms", - requirements.filter_count, - requirements.surface_count, - ); - return false; - } - let filters = resources - .filter_uniforms - .begin_upload(&resources.queue, requirements.filter_count); - let surfaces = resources - .surface_uniforms - .begin_upload(&resources.queue, requirements.surface_count); - if !(filters && surfaces) { - resources.finish_frame_uploads(); - log::error!("failed to map frame uniform staging memory"); - return false; - } - true - } -} - -fn sampled_render_texture( - device: &wgpu::Device, - format: wgpu::TextureFormat, - width: u32, - height: u32, -) -> (wgpu::Texture, wgpu::TextureView) { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("sampled_render_texture"), - size: wgpu::Extent3d { - width: width.max(1), - height: height.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - (texture, view) -} - -fn msaa_texture( - device: &wgpu::Device, - format: wgpu::TextureFormat, - width: u32, - height: u32, - sample_count: u32, -) -> Option<(wgpu::Texture, wgpu::TextureView)> { - if sample_count <= 1 { - return None; - } - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("path_msaa"), - size: wgpu::Extent3d { - width: width.max(1), - height: height.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - Some((texture, view)) -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs b/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs deleted file mode 100644 index 053a422c1..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/retained.rs +++ /dev/null @@ -1,318 +0,0 @@ -use super::resources::WgpuResources; -use gpui::Scene; -use gpui::profiler::gpu; -use gpui_render::damage::{Damage, SceneSnapshot}; -use gpui_render::retained::{GpuMemoryReservation, RetainedScene, reserve_retained_bytes}; -use gpui_render::shaders::{blur::BlurUniforms, interface::bytes_of}; -use std::sync::Arc; - -pub(super) struct RetainedFrame { - enabled: bool, - target: Option, - scene: RetainedScene, - #[cfg(test)] - last_damage: &'static str, -} -struct Target { - texture: wgpu::Texture, - view: wgpu::TextureView, - width: u32, - height: u32, - format: wgpu::TextureFormat, - alpha_mode: wgpu::CompositeAlphaMode, - clear_color: wgpu::Color, - copy_source: bool, - /// Samples the target onto the presentation texture when it isn't copied. - /// Owned here: a shared cache would keep the texture alive after a release. - blit: wgpu::BindGroup, - // Held for its drop, which releases the device budget. - _reservation: GpuMemoryReservation, -} -pub(super) struct RetainedPlan { - pub view: Option, - pub copy_texture: Option, - pub blit: Option, - pub damage: Damage, - pub snapshot: Option, - pub scene_revision: u64, -} -impl RetainedFrame { - pub fn new() -> Self { - Self { - // Measured on Windows and Linux; the web and macOS builds draw in full. - enabled: cfg!(any( - target_os = "windows", - target_os = "linux", - target_os = "freebsd" - )), - target: None, - scene: RetainedScene::default(), - #[cfg(test)] - last_damage: "full", - } - } - pub fn enabled(&self) -> bool { - self.enabled - } - #[cfg(test)] - pub fn enable_for_tests(&mut self) { - self.enabled = true; - } - #[cfg(test)] - pub fn disable_for_tests(&mut self) { - self.enabled = false; - self.invalidate(); - } - #[cfg(test)] - pub fn last_damage(&self) -> &'static str { - self.last_damage - } - /// Forget the retained frame and free its target and budget at once: wgpu - /// keeps a texture alive while submitted work uses it, and an idle or closed - /// window would never poll a completion callback. - pub fn invalidate(&mut self) { - self.scene.forget(); - self.target = None; - } - pub fn prepare( - &mut self, - scene: &Scene, - atlas: u64, - width: u32, - height: u32, - copy_source: bool, - resources: &WgpuResources, - format: wgpu::TextureFormat, - alpha_mode: wgpu::CompositeAlphaMode, - clear_color: wgpu::Color, - ) -> RetainedPlan { - let context = gpu::gpu_frame_context(); - let device = &resources.device; - let full = || RetainedPlan { - view: None, - copy_texture: None, - blit: None, - damage: Damage::Full, - snapshot: None, - scene_revision: context.scene_revision, - }; - if !self.enabled { - return full(); - } - let bytes = retained_bytes(format, width, height); - if !context.interactive - || !alpha_supported(alpha_mode) - // The damage-clear Quad must reproduce the raw target clear exactly. - // Colored translucent clears would be premultiplied again by its shader; - // arbitrary opaque colors also lack an exact HSL round-trip proof. - || !retained_clear_supported(clear_color) - || bytes.is_none() - { - self.invalidate(); - return full(); - } - if self.target.as_ref().is_some_and(|target| { - target.width != width - || target.height != height - || target.format != format - || target.alpha_mode != alpha_mode - || target.clear_color != clear_color - || target.copy_source != copy_source - }) { - self.invalidate(); - } - // Reserve before hashing, so a window the budget can't hold doesn't - // capture its scene every frame only to draw in full. - let reservation = if self.target.is_none() { - let reservation = bytes.and_then(|bytes| { - reserve_retained_bytes("wgpu", Arc::as_ptr(device) as usize, bytes) - }); - if reservation.is_none() { - self.invalidate(); - return full(); - } - reservation - } else { - None - }; - let Some((mut damage, snapshot)) = - self.scene - .plan(scene, context.scene_revision, atlas, width, height) - else { - self.invalidate(); - return full(); - }; - if let Some(reservation) = reservation { - #[cfg(not(target_family = "wasm"))] - let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("gpui_retained_frame"), - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | if copy_source { - wgpu::TextureUsages::COPY_SRC - } else { - wgpu::TextureUsages::empty() - }, - view_formats: &[], - }); - // The target is optional: without memory for it, draw in full, as - // with an exhausted budget, instead of failing the device. The web - // never retains frames. - #[cfg(not(target_family = "wasm"))] - if gpui::block_on(scope.pop()).is_some() { - self.invalidate(); - return full(); - } - let view = texture.create_view(&Default::default()); - let uniforms = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("gpui_retained_blit"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let size = [width as f32, height as f32]; - resources - .queue - .write_buffer(&uniforms, 0, bytes_of(&BlurUniforms::copy(size))); - let blit = resources.bind_group_layouts.create_blur( - device, - uniforms.as_entire_buffer_binding(), - &view, - &resources.surface_sampler, - ); - self.target = Some(Target { - texture, - view, - width, - height, - format, - alpha_mode, - clear_color, - copy_source, - blit, - _reservation: reservation, - }); - // A new target holds nothing yet. - damage = Damage::Full; - } - let target = self.target.as_ref().unwrap(); - RetainedPlan { - view: Some(target.view.clone()), - copy_texture: copy_source.then(|| target.texture.clone()), - blit: Some(target.blit.clone()), - damage, - snapshot, - scene_revision: context.scene_revision, - } - } - pub fn complete(&mut self, plan: RetainedPlan, success: bool) { - #[cfg(test)] - { - self.last_damage = plan.damage.kind(); - } - if success { - self.scene.complete(plan.snapshot, plan.scene_revision); - } else { - self.invalidate(); - } - } -} - -fn retained_clear_supported(clear: wgpu::Color) -> bool { - // Both linear and sRGB targets preserve the black/white endpoints exactly. - // Black RGB stays black through either premultiplied or straight-alpha output. - let black = clear.r == 0.0 && clear.g == 0.0 && clear.b == 0.0; - let opaque_white = clear.r == 1.0 && clear.g == 1.0 && clear.b == 1.0 && clear.a == 1.0; - (black && clear.a.is_finite() && (0.0..=1.0).contains(&clear.a)) || opaque_white -} - -/// The alpha modes partial redraw is verified with, copied or blitted. -pub(super) fn alpha_supported(alpha: wgpu::CompositeAlphaMode) -> bool { - matches!( - alpha, - wgpu::CompositeAlphaMode::Opaque | wgpu::CompositeAlphaMode::PreMultiplied - ) -} - -fn retained_bytes(format: wgpu::TextureFormat, width: u32, height: u32) -> Option { - if width == 0 - || height == 0 - || !matches!( - format, - wgpu::TextureFormat::Bgra8Unorm - | wgpu::TextureFormat::Bgra8UnormSrgb - | wgpu::TextureFormat::Rgba8Unorm - | wgpu::TextureFormat::Rgba8UnormSrgb - ) - { - return None; - } - (width as u64).checked_mul(height as u64)?.checked_mul(4) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retained_clear_requires_exact_shader_endpoint_representation() { - assert!(retained_clear_supported(wgpu::Color::TRANSPARENT)); - assert!(retained_clear_supported(wgpu::Color::BLACK)); - assert!(retained_clear_supported(wgpu::Color::WHITE)); - assert!(retained_clear_supported(wgpu::Color { - a: 0.25, - ..wgpu::Color::BLACK - })); - for alpha in [0.0, 0.25, 1.0] { - assert!(!retained_clear_supported(wgpu::Color { - r: 0.04, - g: 0.03, - b: 0.02, - a: alpha, - })); - } - assert!(!retained_clear_supported(wgpu::Color { - a: f64::NAN, - ..wgpu::Color::BLACK - })); - assert!(!retained_clear_supported(wgpu::Color { - a: -0.25, - ..wgpu::Color::BLACK - })); - } - - #[test] - fn only_opaque_and_premultiplied_targets_retain() { - assert!(alpha_supported(wgpu::CompositeAlphaMode::Opaque)); - assert!(alpha_supported(wgpu::CompositeAlphaMode::PreMultiplied)); - assert!(!alpha_supported(wgpu::CompositeAlphaMode::PostMultiplied)); - assert!(!alpha_supported(wgpu::CompositeAlphaMode::Inherit)); - } - - #[test] - fn retained_budget_rejects_hdr_unknown_formats_zero_extent_and_overflow() { - assert_eq!( - retained_bytes(wgpu::TextureFormat::Bgra8Unorm, 4096, 2048), - Some(4096 * 2048 * 4) - ); - assert_eq!( - retained_bytes(wgpu::TextureFormat::Rgba16Float, 4096, 2048), - None - ); - assert_eq!(retained_bytes(wgpu::TextureFormat::Rgba8Unorm, 0, 8), None); - assert_eq!( - retained_bytes(wgpu::TextureFormat::Rgba8Unorm, u32::MAX, u32::MAX), - None - ); - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs b/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs deleted file mode 100644 index 4d23b1345..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/settings.rs +++ /dev/null @@ -1,97 +0,0 @@ -use gpui::{DevicePixels, Size, get_gamma_correction_ratios}; - -pub struct WgpuSurfaceConfig { - pub size: Size, - pub transparent: bool, - /// Preferred presentation mode, falling back to FIFO when unsupported. - pub preferred_present_mode: Option, -} - -/// Physical order of the color components in an LCD subpixel layout. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SubpixelOrder { - RedGreenBlue, - BlueGreenRed, -} - -/// Platform text-rasterization settings consumed by the glyph shaders. -#[derive(Clone, Copy, Debug)] -pub struct FontRasterizationSettings { - pub(super) gamma_ratios: [f32; 4], - pub(super) grayscale_enhanced_contrast: f32, - pub(super) subpixel_enhanced_contrast: f32, - pub(super) subpixel_order: SubpixelOrder, -} - -impl FontRasterizationSettings { - /// Creates settings equivalent to the retired DirectWrite renderer's parameters. - pub fn new( - gamma: f32, - grayscale_enhanced_contrast: f32, - subpixel_enhanced_contrast: f32, - subpixel_order: SubpixelOrder, - ) -> Self { - Self { - gamma_ratios: get_gamma_correction_ratios(gamma.clamp(1.0, 2.2)), - grayscale_enhanced_contrast: finite_contrast(grayscale_enhanced_contrast), - subpixel_enhanced_contrast: finite_contrast(subpixel_enhanced_contrast), - subpixel_order, - } - } - - /// Disables contrast and gamma correction, like the retired macOS Metal glyph shader. - pub fn uncorrected() -> Self { - Self::new(1.0, 0.0, 0.0, SubpixelOrder::RedGreenBlue) - } - - fn from_environment() -> Self { - let gamma = environment_f32("ZED_FONTS_GAMMA", 1.8).clamp(1.0, 2.2); - let grayscale_enhanced_contrast = - environment_f32("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST", 1.0).max(0.0); - let subpixel_enhanced_contrast = - environment_f32("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST", 0.5).max(0.0); - Self::new( - gamma, - grayscale_enhanced_contrast, - subpixel_enhanced_contrast, - SubpixelOrder::RedGreenBlue, - ) - } -} - -pub(super) struct RenderingParameters { - pub(super) path_sample_count: u32, - pub(super) font_rasterization: FontRasterizationSettings, -} - -impl RenderingParameters { - pub(super) fn new(adapter: &wgpu::Adapter, surface_format: wgpu::TextureFormat) -> Self { - let format_features = adapter.get_texture_format_features(surface_format); - let path_sample_count = [4, 2, 1] - .into_iter() - .find(|&sample_count| format_features.flags.sample_count_supported(sample_count)) - .unwrap_or(1); - Self { - path_sample_count, - font_rasterization: FontRasterizationSettings::from_environment(), - } - } -} - -fn environment_f32(name: &str, default: f32) -> f32 { - std::env::var(name) - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| value.is_finite()) - .unwrap_or(default) -} - -fn finite_contrast(contrast: f32) -> f32 { - // Infinity survives max(0), then produces inf/inf (or inf*0) in the coverage - // shader. Fall back to neutral correction instead of making every glyph NaN. - if contrast.is_finite() { - contrast.max(0.0) - } else { - 0.0 - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs deleted file mode 100644 index 725753709..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surface_tests.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Run inside a Wayland session with Vulkan available: -//! `cargo test -p gpui_ce_wgpu --lib failed_frames_release_surface_images -- --ignored` -use std::{ptr::NonNull, sync::Arc}; - -use gpui::{DevicePixels, Scene, Size}; -use raw_window_handle::{RawWindowHandle, WaylandWindowHandle}; -use wayland_client::{ - Connection, Dispatch, Proxy, QueueHandle, delegate_noop, - globals::{GlobalListContents, registry_queue_init}, - protocol::{wl_compositor, wl_registry, wl_surface}, -}; -use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; - -use super::{ - NativeBackend, NativeSurfaceTarget, SoftwareAdapterPolicy, WgpuAtlas, WgpuContext, - create_surface, -}; -use crate::{WgpuRenderer, WgpuSurfaceConfig}; - -#[derive(Default)] -struct WindowState { - configured: bool, -} - -delegate_noop!(WindowState: ignore wl_compositor::WlCompositor); -delegate_noop!(WindowState: ignore wl_surface::WlSurface); -delegate_noop!(WindowState: ignore xdg_toplevel::XdgToplevel); - -impl Dispatch for WindowState { - fn event( - _: &mut Self, - _: &wl_registry::WlRegistry, - _: wl_registry::Event, - _: &GlobalListContents, - _: &Connection, - _: &QueueHandle, - ) { - } -} - -impl Dispatch for WindowState { - fn event( - _: &mut Self, - shell: &xdg_wm_base::XdgWmBase, - event: xdg_wm_base::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - if let xdg_wm_base::Event::Ping { serial } = event { - shell.pong(serial); - } - } -} - -impl Dispatch for WindowState { - fn event( - state: &mut Self, - surface: &xdg_surface::XdgSurface, - event: xdg_surface::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - if let xdg_surface::Event::Configure { serial } = event { - surface.ack_configure(serial); - state.configured = true; - } - } -} - -#[test] -#[ignore = "requires a Wayland compositor and Vulkan; opens a temporary test window"] -fn failed_frames_release_surface_images() { - let connection = Connection::connect_to_env().expect("Wayland connection"); - let (globals, mut events) = registry_queue_init::(&connection).unwrap(); - let queue = events.handle(); - let compositor: wl_compositor::WlCompositor = globals.bind(&queue, 1..=4, ()).unwrap(); - let shell: xdg_wm_base::XdgWmBase = globals.bind(&queue, 1..=1, ()).unwrap(); - let surface = compositor.create_surface(&queue, ()); - let shell_surface = shell.get_xdg_surface(&surface, &queue, ()); - let toplevel = shell_surface.get_toplevel(&queue, ()); - toplevel.set_title("GPUI surface recovery test".into()); - surface.commit(); - let mut state = WindowState::default(); - events.roundtrip(&mut state).unwrap(); - assert!( - state.configured, - "compositor must configure the test window" - ); - connection.flush().unwrap(); - - // Use a real Vulkan swapchain: a headless texture cannot expose exhausted - // presentation images. Keep the Wayland objects alive until after rendering. - let instance = NativeBackend::Vulkan.instance(Some(Box::new(connection.backend()))); - let handle = WaylandWindowHandle::new(NonNull::new(surface.id().as_ptr().cast()).unwrap()); - let gpu_surface = create_surface( - &instance.raw, - NativeSurfaceTarget::Window(RawWindowHandle::Wayland(handle)), - ) - .unwrap(); - let context = WgpuContext::new_with_adapter_policy( - instance, - &gpu_surface, - None, - SoftwareAdapterPolicy::Allow, - None, - ) - .unwrap(); - let mut renderer = WgpuRenderer::new_internal( - None, - &context, - Some(gpu_surface), - WgpuSurfaceConfig { - size: Size { - width: DevicePixels(128), - height: DevicePixels(128), - }, - transparent: false, - preferred_present_mode: None, - }, - None, - None, - Arc::new(WgpuAtlas::from_context(&context)), - ) - .unwrap(); - let mut scene = Scene::default(); - scene.finish(); - assert!(renderer.draw(&scene), "initial frame must present"); - events.roundtrip(&mut state).unwrap(); - - for attempt in 0..8 { - // Exercise the existing failure path in begin_frame, after draw has - // acquired an image. Do not mock acquisition or call recovery ourselves. - renderer.faults.consecutive_failed_frames = 5; - *renderer.faults.pending_error.lock().unwrap() = Some("injected frame error".into()); - assert!(!renderer.draw(&scene), "injected frame {attempt} must fail"); - assert!( - renderer.faults.pending_error.lock().unwrap().is_none(), - "frame {attempt} never reached rendering: surface images were exhausted" - ); - events.roundtrip(&mut state).unwrap(); - } - assert!( - renderer.draw(&scene), - "healthy frame must present after repeated failures" - ); - drop(renderer); - toplevel.destroy(); - shell_surface.destroy(); - surface.destroy(); - connection.flush().unwrap(); -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs deleted file mode 100644 index 499ceb131..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces.rs +++ /dev/null @@ -1,163 +0,0 @@ -use gpui::PaintSurface; -#[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") -))] -use gpui_render::shaders::{ - common::SurfaceColorFormat, interface as shader_interface, surface::SurfaceUniforms, -}; - -use super::{WgpuRenderer, frame}; - -#[cfg(target_os = "macos")] -#[path = "surfaces/macos.rs"] -mod platform; -#[cfg(any( - target_os = "linux", - target_os = "freebsd", - all(target_family = "wasm", feature = "custom-gpu") -))] -#[path = "surfaces/texture.rs"] -mod platform; -#[cfg(all(target_os = "windows", feature = "wgpu-surfaces"))] -#[path = "surfaces/windows.rs"] -mod platform; -#[cfg(not(any( - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_family = "wasm", feature = "custom-gpu"), - all(target_os = "windows", feature = "wgpu-surfaces") -)))] -#[path = "surfaces/unsupported.rs"] -mod platform; - -pub(super) use platform::SurfaceCache; - -#[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") -))] -struct SurfaceBinding { - color_view: wgpu::TextureView, - chroma_view: wgpu::TextureView, - uniform_generation: u64, - bind_group: wgpu::BindGroup, -} - -#[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") -))] -impl SurfaceBinding { - fn new( - renderer: &WgpuRenderer, - color_view: wgpu::TextureView, - chroma_view: wgpu::TextureView, - ) -> Self { - Self { - bind_group: renderer.create_surface_bind_group(&color_view, &chroma_view), - uniform_generation: renderer.resources().surface_uniforms.generation(), - color_view, - chroma_view, - } - } - - fn refresh(&mut self, renderer: &WgpuRenderer) { - let generation = renderer.resources().surface_uniforms.generation(); - if self.uniform_generation != generation { - self.bind_group = - renderer.create_surface_bind_group(&self.color_view, &self.chroma_view); - self.uniform_generation = generation; - } - } -} - -impl WgpuRenderer { - pub(super) fn retain_surface_cache(&self, surfaces: &[PaintSurface]) { - platform::retain_surface_cache(self, surfaces); - } - - #[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - ))] - fn create_surface_bind_group( - &self, - color_view: &wgpu::TextureView, - chroma_view: &wgpu::TextureView, - ) -> wgpu::BindGroup { - let resources = self.resources(); - resources.bind_group_layouts.create_surface( - &resources.device, - wgpu::BufferBinding { - buffer: &resources.surface_uniforms.buffer, - offset: 0, - size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), - }, - color_view, - chroma_view, - &resources.surface_sampler, - ) - } - - #[cfg(any( - all(target_family = "wasm", feature = "custom-gpu"), - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - all(target_os = "windows", feature = "wgpu-surfaces") - ))] - fn draw_surface_binding( - &self, - surface: &PaintSurface, - color_format: SurfaceColorFormat, - binding: &mut SurfaceBinding, - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - binding.refresh(self); - let uniforms = SurfaceUniforms { - bounds: surface.bounds.into(), - content_mask: surface.content_mask.into(), - color_format, - opacity: 1.0, - padding0: 0, - padding1: 0, - padding2: 0, - padding3: 0, - padding4: 0, - padding5: 0, - }; - let resources = self.resources(); - let uniform_offset = resources.surface_uniforms.write(&uniforms); - - pass.set_pipeline(&resources.pipelines.surfaces); - pass.set_bind_group( - shader_interface::DATA_BIND_GROUP, - &binding.bind_group, - &[uniform_offset], - ); - pass.draw(0..resources.pipelines.surfaces.fixed_vertex_count(), 0..1); - Ok(()) - } - - pub(super) fn draw_surfaces( - &self, - surfaces: &[PaintSurface], - pass: &mut wgpu::RenderPass<'_>, - ) -> frame::DrawResult { - platform::draw_surfaces(self, surfaces, pass) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs deleted file mode 100644 index de3dbc01d..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/macos.rs +++ /dev/null @@ -1,405 +0,0 @@ -use super::*; -use collections::FxHashMap; - -pub(in crate::wgpu_renderer) struct SurfaceCache { - texture_cache: core_video::metal_texture_cache::CVMetalTextureCache, - surfaces: FxHashMap, - /// Bindings for application-rendered WGPU textures. - #[cfg(feature = "custom-gpu")] - textures: FxHashMap, -} - -impl SurfaceCache { - pub(in crate::wgpu_renderer) fn new(device: &wgpu::Device) -> anyhow::Result { - use metal::foreign_types::ForeignTypeRef as _; - - let hal_device = unsafe { device.as_hal::() } - .ok_or_else(|| anyhow::anyhow!("macOS WGPU device did not expose the Metal HAL"))?; - let raw_device = objc2::rc::Retained::as_ptr(hal_device.raw_device()) - .cast_mut() - .cast(); - let metal_device = unsafe { metal::DeviceRef::from_ptr(raw_device) }.to_owned(); - let texture_cache = - core_video::metal_texture_cache::CVMetalTextureCache::new(None, metal_device, None) - .map_err(|error| { - anyhow::anyhow!("failed to create CoreVideo Metal texture cache: {error}") - })?; - Ok(Self { - texture_cache, - surfaces: FxHashMap::default(), - #[cfg(feature = "custom-gpu")] - textures: FxHashMap::default(), - }) - } -} - -struct CachedSurface { - _luma_texture: wgpu::Texture, - _chroma_texture: wgpu::Texture, - binding: SurfaceBinding, -} - -pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { - let active_keys = surfaces - .iter() - .filter_map(|surface| { - let gpui::SurfaceSource::Surface(image_buffer) = &surface.source else { - return None; - }; - core_video_surface_key(image_buffer).ok() - }) - .collect::>(); - let mut cache = renderer.resources().surface_cache.borrow_mut(); - cache.surfaces.retain(|key, _| active_keys.contains(key)); - #[cfg(feature = "custom-gpu")] - { - let active_textures = surfaces - .iter() - .filter_map(|surface| match &surface.source { - gpui::SurfaceSource::Texture { texture, .. } => { - texture.downcast_ref::() - } - _ => None, - }) - .collect::>(); - cache - .textures - .retain(|texture, _| active_textures.contains(&texture)); - } -} - -pub(super) fn draw_surfaces( - renderer: &WgpuRenderer, - surfaces: &[PaintSurface], - pass: &mut wgpu::RenderPass<'_>, -) -> frame::DrawResult { - use core_video::pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange; - - let resources = renderer.resources(); - let mut cache = resources.surface_cache.borrow_mut(); - - for surface in surfaces { - match &surface.source { - gpui::SurfaceSource::Surface(image_buffer) => { - if image_buffer.get_pixel_format() != kCVPixelFormatType_420YpCbCr8BiPlanarFullRange - { - log::error!("unsupported CoreVideo surface pixel format"); - return Err(frame::DrawError::ExternalSurface); - } - let key = core_video_surface_key(image_buffer)?; - let mut imported = cache.surfaces.remove(&key).map(Ok).unwrap_or_else(|| { - create_core_video_surface(renderer, &cache.texture_cache, image_buffer) - })?; - renderer.draw_surface_binding( - surface, - SurfaceColorFormat::Yuv, - &mut imported.binding, - pass, - )?; - cache.surfaces.insert(key, imported); - } - #[cfg(feature = "custom-gpu")] - gpui::SurfaceSource::Texture { texture, .. } => { - let Some(texture) = texture.downcast_ref::() else { - log::error!("surface source is not a WGPU texture"); - return Err(frame::DrawError::ExternalSurface); - }; - let binding = cache.textures.entry(texture.clone()).or_insert_with(|| { - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - SurfaceBinding::new(renderer, view.clone(), view) - }); - renderer.draw_surface_binding(surface, SurfaceColorFormat::Rgba, binding, pass)?; - } - _ => { - log::error!("surface source cannot be imported by the macOS renderer"); - return Err(frame::DrawError::ExternalSurface); - } - } - } - Ok(()) -} - -fn core_video_surface_key( - image_buffer: &core_video::pixel_buffer::CVPixelBuffer, -) -> Result { - use core_foundation::base::TCFType as _; - - let io_surface = unsafe { - core_video::pixel_buffer_io_surface::CVPixelBufferGetIOSurface( - image_buffer.as_concrete_TypeRef(), - ) - }; - if io_surface.is_null() { - log::error!( - "CoreVideo surface is not IOSurface-backed; allocate it with \ - kCVPixelBufferIOSurfacePropertiesKey and \ - kCVPixelBufferMetalCompatibilityKey" - ); - return Err(frame::DrawError::ExternalSurface); - } - Ok(io_surface as usize) -} - -fn create_core_video_surface( - renderer: &WgpuRenderer, - texture_cache: &core_video::metal_texture_cache::CVMetalTextureCache, - image_buffer: &core_video::pixel_buffer::CVPixelBuffer, -) -> Result { - use core_foundation::base::TCFType as _; - use core_video::image_buffer::TCVImageBuffer as _; - use core_video::metal_texture::CVMetalTextureGetTexture; - - let resources = renderer.resources(); - let luma = texture_cache - .create_texture_from_image( - &image_buffer.as_image_buffer(), - None, - metal::MTLPixelFormat::R8Unorm, - image_buffer.get_width_of_plane(0), - image_buffer.get_height_of_plane(0), - 0, - ) - .map_err(|error| { - log::error!("failed to create CoreVideo luma texture: {error}"); - frame::DrawError::ExternalSurface - })?; - let chroma = texture_cache - .create_texture_from_image( - &image_buffer.as_image_buffer(), - None, - metal::MTLPixelFormat::RG8Unorm, - image_buffer.get_width_of_plane(1), - image_buffer.get_height_of_plane(1), - 1, - ) - .map_err(|error| { - log::error!("failed to create CoreVideo chroma texture: {error}"); - frame::DrawError::ExternalSurface - })?; - let luma_texture = unsafe { - import_core_video_texture( - &resources.device, - CVMetalTextureGetTexture(luma.as_concrete_TypeRef()).cast(), - wgpu::TextureFormat::R8Unorm, - plane_size(image_buffer, 0), - ) - } - .ok_or(frame::DrawError::ExternalSurface)?; - let chroma_texture = unsafe { - import_core_video_texture( - &resources.device, - CVMetalTextureGetTexture(chroma.as_concrete_TypeRef()).cast(), - wgpu::TextureFormat::Rg8Unorm, - plane_size(image_buffer, 1), - ) - } - .ok_or(frame::DrawError::ExternalSurface)?; - let luma_view = luma_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let chroma_view = chroma_texture.create_view(&wgpu::TextureViewDescriptor::default()); - Ok(CachedSurface { - _luma_texture: luma_texture, - _chroma_texture: chroma_texture, - binding: SurfaceBinding::new(renderer, luma_view, chroma_view), - }) -} - -fn plane_size( - image_buffer: &core_video::pixel_buffer::CVPixelBuffer, - plane: usize, -) -> wgpu::Extent3d { - wgpu::Extent3d { - width: image_buffer.get_width_of_plane(plane) as u32, - height: image_buffer.get_height_of_plane(plane) as u32, - depth_or_array_layers: 1, - } -} - -unsafe fn import_core_video_texture( - device: &wgpu::Device, - raw_texture: *mut objc2::runtime::AnyObject, - format: wgpu::TextureFormat, - size: wgpu::Extent3d, -) -> Option { - use objc2::{rc::Retained, runtime::ProtocolObject}; - - // SAFETY: CoreVideo returned a live MTLTexture; the retain count transfers into the - // HAL texture so it outlives the CVMetalTexture wrapper. - let object = unsafe { Retained::retain(raw_texture) }?; - let raw = - unsafe { Retained::cast_unchecked::>(object) }; - let hal_texture = unsafe { - wgpu::hal::metal::Device::texture_from_raw( - raw, - format, - objc2_metal::MTLTextureType::Type2D, - 1, - 1, - size.into(), - ) - }; - Some(unsafe { - device.create_texture_from_hal::( - hal_texture, - &wgpu::TextureDescriptor { - label: Some("core_video_surface_plane"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }, - ) - }) -} - -#[cfg(all(test, feature = "test-support", feature = "custom-gpu"))] -mod tests { - use std::sync::Arc; - - use gpui::{ - Bounds, ContentMask, DevicePixels, Point, ScaledPixels, Scene, Size, SurfaceSource, - }; - - use super::*; - use crate::WgpuContext; - - const RED: [u8; 4] = [255, 0, 0, 255]; - - /// A 4x2 renderer: a surface over its left half leaves the right half clear. - fn renderer() -> anyhow::Result { - let context = WgpuContext::new_headless(None)?; - WgpuRenderer::new_headless( - &context, - Size { - width: DevicePixels(4), - height: DevicePixels(2), - }, - ) - } - - fn solid_texture(renderer: &WgpuRenderer, color: [u8; 4]) -> Arc { - let (device, queue) = renderer.gpu_context(); - let size = wgpu::Extent3d { - width: 2, - height: 2, - depth_or_array_layers: 1, - }; - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("application_texture"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - texture.as_image_copy(), - &color.repeat(4), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(8), - rows_per_image: Some(2), - }, - size, - ); - Arc::new(texture) - } - - /// A scene drawing each source over the renderer's left half. - fn scene(sources: impl IntoIterator) -> Scene { - let bounds = |width| Bounds { - origin: Point { - x: ScaledPixels(0.0), - y: ScaledPixels(0.0), - }, - size: Size { - width: ScaledPixels(width), - height: ScaledPixels(2.0), - }, - }; - let mut scene = Scene::default(); - for (order, source) in sources.into_iter().enumerate() { - scene.insert_primitive(PaintSurface { - order: order as u32, - bounds: bounds(2.0), - content_mask: ContentMask { - bounds: bounds(4.0), - ..Default::default() - }, - source, - }); - } - scene.finish(); - scene - } - - fn texture_source(texture: &Arc) -> SurfaceSource { - SurfaceSource::Texture { - texture: texture.clone(), - size: Size { - width: DevicePixels(2), - height: DevicePixels(2), - }, - } - } - - fn cached_textures(renderer: &WgpuRenderer) -> Vec { - let cache = renderer.resources().surface_cache.borrow(); - cache.textures.keys().cloned().collect() - } - - #[test] - fn application_textures_composite_within_their_bounds() -> anyhow::Result<()> { - let mut renderer = renderer()?; - let clear = renderer.render_to_image(&scene([]))?.get_pixel(3, 1).0; - assert_ne!(clear, RED); - let texture = solid_texture(&renderer, RED); - - let image = renderer.render_to_image(&scene([texture_source(&texture)]))?; - - assert_eq!(image.get_pixel(0, 0).0, RED); - assert_eq!(image.get_pixel(1, 1).0, RED); - assert_eq!(image.get_pixel(2, 0).0, clear); - assert_eq!(image.get_pixel(3, 1).0, clear); - Ok(()) - } - - #[test] - fn texture_bindings_last_only_while_their_texture_is_drawn() -> anyhow::Result<()> { - let mut renderer = renderer()?; - let first = solid_texture(&renderer, RED); - let second = solid_texture(&renderer, RED); - - // One binding per texture, reused across frames. - renderer.render_to_image(&scene([texture_source(&first)]))?; - renderer.render_to_image(&scene([texture_source(&first)]))?; - assert_eq!(cached_textures(&renderer), [(*first).clone()]); - - // A texture the scene stops drawing is released, so an application - // that replaces its target (on resize) doesn't accumulate them. - renderer.render_to_image(&scene([texture_source(&second)]))?; - assert_eq!(cached_textures(&renderer), [(*second).clone()]); - renderer.render_to_image(&scene([]))?; - assert_eq!(cached_textures(&renderer), []); - Ok(()) - } - - #[test] - fn sources_that_are_not_wgpu_textures_fail_the_frame() -> anyhow::Result<()> { - let mut renderer = renderer()?; - let not_a_texture = SurfaceSource::Texture { - texture: Arc::new("not a texture"), - size: Size { - width: DevicePixels(2), - height: DevicePixels(2), - }, - }; - - assert!(renderer.render_to_image(&scene([not_a_texture])).is_err()); - Ok(()) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs deleted file mode 100644 index 62d210347..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/texture.rs +++ /dev/null @@ -1,70 +0,0 @@ -use super::*; -use collections::FxHashMap; - -pub(in crate::wgpu_renderer) struct SurfaceCache { - textures: FxHashMap, -} - -impl SurfaceCache { - pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> anyhow::Result { - Ok(Self { - textures: FxHashMap::default(), - }) - } -} - -pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { - let mut textures = smallvec::SmallVec::<[&wgpu::Texture; 4]>::new(); - for surface in surfaces { - let gpui::SurfaceSource::Texture { texture, .. } = &surface.source else { - continue; - }; - if let Some(texture) = texture.downcast_ref::() { - textures.push(texture); - } - } - renderer - .resources() - .surface_cache - .borrow_mut() - .textures - .retain(|texture, _| textures.contains(&texture)); -} - -pub(super) fn draw_surfaces( - renderer: &WgpuRenderer, - surfaces: &[PaintSurface], - pass: &mut wgpu::RenderPass<'_>, -) -> frame::DrawResult { - let mut textures = smallvec::SmallVec::<[(&PaintSurface, &wgpu::Texture); 4]>::new(); - for surface in surfaces { - let gpui::SurfaceSource::Texture { texture, .. } = &surface.source else { - log::error!("surface source cannot be imported by the WGPU texture renderer"); - return Err(frame::DrawError::ExternalSurface); - }; - let Some(texture) = texture.downcast_ref::() else { - log::error!("surface source is not a WGPU texture"); - return Err(frame::DrawError::ExternalSurface); - }; - textures.push((surface, texture)); - } - - let resources = renderer.resources(); - let mut cache = resources.surface_cache.borrow_mut(); - - for (surface, texture) in textures { - if !cache.textures.contains_key(texture) { - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - cache.textures.insert( - texture.clone(), - SurfaceBinding::new(renderer, view.clone(), view), - ); - } - let Some(cached) = cache.textures.get_mut(texture) else { - log::error!("surface texture cache insertion failed"); - return Err(frame::DrawError::ExternalSurface); - }; - renderer.draw_surface_binding(surface, SurfaceColorFormat::Rgba, cached, pass)?; - } - Ok(()) -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs deleted file mode 100644 index e391a0936..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/unsupported.rs +++ /dev/null @@ -1,24 +0,0 @@ -use super::*; - -pub(in crate::wgpu_renderer) struct SurfaceCache; - -impl SurfaceCache { - pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> anyhow::Result { - Ok(Self) - } -} - -pub(super) fn retain_surface_cache(_renderer: &WgpuRenderer, _surfaces: &[PaintSurface]) {} - -pub(super) fn draw_surfaces( - _renderer: &WgpuRenderer, - surfaces: &[PaintSurface], - _pass: &mut wgpu::RenderPass<'_>, -) -> frame::DrawResult { - if surfaces.is_empty() { - Ok(()) - } else { - log::error!("native surface primitives are unsupported on this render target"); - Err(frame::DrawError::ExternalSurface) - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs deleted file mode 100644 index fdf74119f..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows.rs +++ /dev/null @@ -1,300 +0,0 @@ -use super::*; -use anyhow::Result; -use collections::FxHashMap; -use windows_core::Interface as _; - -#[path = "windows/bindings.rs"] -mod bindings; -use bindings::Windows::Win32::{D3D11_TEXTURE2D_DESC, ID3D11Texture2D}; - -#[path = "windows/shared.rs"] -mod shared; -#[path = "windows/upload.rs"] -mod upload; - -use shared::SharedTexture; -use upload::UploadedTexture; - -pub(in crate::wgpu_renderer) struct SurfaceCache { - textures: FxHashMap, -} - -impl SurfaceCache { - pub(in crate::wgpu_renderer) fn new(_device: &wgpu::Device) -> Result { - Ok(Self { - textures: FxHashMap::default(), - }) - } -} - -pub(super) fn retain_surface_cache(renderer: &WgpuRenderer, surfaces: &[PaintSurface]) { - let active_keys = surfaces - .iter() - .filter_map(|surface| capture_frame(surface).map(CaptureFrameKey::from_frame)) - .collect::>(); - renderer - .resources() - .surface_cache - .borrow_mut() - .textures - .retain(|key, _| active_keys.contains(key)); -} - -pub(super) fn draw_surfaces( - renderer: &WgpuRenderer, - surfaces: &[PaintSurface], - pass: &mut wgpu::RenderPass<'_>, -) -> frame::DrawResult { - let resources = renderer.resources(); - let mut cache = resources.surface_cache.borrow_mut(); - for surface in surfaces { - let Some(frame) = capture_frame(surface) else { - log::error!("surface source cannot be imported by the Windows renderer"); - return Err(frame::DrawError::ExternalSurface); - }; - let raw_source = frame.texture().as_raw(); - // The frame owns this ID3D11Texture2D for the entire draw. Borrow its - // ABI-stable COM pointer using this renderer's generated bindings. - let source = unsafe { ID3D11Texture2D::from_raw_borrowed(&raw_source) } - .expect("capture texture is non-null"); - let key = CaptureFrameKey::from_frame(frame); - let size = source_size(source); - if cache - .textures - .get(&key) - .is_none_or(|cached| cached.size != size) - { - cache.textures.insert( - key, - CachedTexture::new(renderer, source).map_err(surface_error)?, - ); - } - - let Some(cached) = cache.textures.get_mut(&key) else { - log::error!("capture texture cache insertion failed"); - return Err(frame::DrawError::ExternalSurface); - }; - // A frame can be painted more than once (for clipping, filters, or repeated content). - // The cache key includes the capture timestamp, so each frame gets its own destination; - // `updated` prevents duplicate GPU copies/uploads for repeated uses of that frame. - if !cached.updated { - if let Err(error) = cached.update(&resources.queue, source) { - if !cached.is_shared() { - return Err(surface_error(error)); - } - log::warn!("shared capture update failed, switching to CPU upload: {error:#}"); - *cached = CachedTexture::new_uploaded(renderer, source) - .and_then(|mut cached| { - cached.update(&resources.queue, source)?; - Ok(cached) - }) - .map_err(surface_error)?; - } - cached.updated = true; - } - - renderer.draw_surface_binding( - surface, - SurfaceColorFormat::Rgba, - &mut cached.binding, - pass, - )?; - } - Ok(()) -} - -fn capture_frame(surface: &PaintSurface) -> Option<&gpui::WindowsScreenCaptureFrame> { - let gpui::SurfaceSource::WindowsCapture(frame) = &surface.source else { - return None; - }; - Some(frame) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -struct CaptureFrameKey { - texture: usize, - display_time: u64, -} - -impl CaptureFrameKey { - fn from_frame(frame: &gpui::WindowsScreenCaptureFrame) -> Self { - Self { - texture: frame.texture().as_raw() as usize, - display_time: frame.display_time(), - } - } -} - -fn surface_error(error: anyhow::Error) -> frame::DrawError { - log::error!("failed to import Windows capture frame: {error:#}"); - frame::DrawError::ExternalSurface -} - -struct CachedTexture { - size: wgpu::Extent3d, - updated: bool, - backend: CaptureBackend, - _texture: wgpu::Texture, - binding: SurfaceBinding, -} - -impl CachedTexture { - fn new(renderer: &WgpuRenderer, source: &ID3D11Texture2D) -> Result { - let device = &renderer.resources().device; - if let Some(hal_device) = unsafe { device.as_hal::() } { - match SharedTexture::new(device, &hal_device, source) { - Ok((backend, texture)) => { - return Ok(Self::from_backend(renderer, backend.into(), texture)); - } - Err(error) => { - log::warn!("D3D12 capture sharing is unavailable, using CPU upload: {error:#}"); - } - } - } - Self::new_uploaded(renderer, source) - } - - fn new_uploaded(renderer: &WgpuRenderer, source: &ID3D11Texture2D) -> Result { - let (backend, texture) = UploadedTexture::new(&renderer.resources().device, source)?; - Ok(Self::from_backend(renderer, backend.into(), texture)) - } - - fn from_backend( - renderer: &WgpuRenderer, - backend: CaptureBackend, - texture: wgpu::Texture, - ) -> Self { - let size = backend.size(); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - Self { - size, - updated: false, - backend, - binding: SurfaceBinding::new(renderer, view.clone(), view), - _texture: texture, - } - } - - fn update(&mut self, queue: &wgpu::Queue, source: &ID3D11Texture2D) -> Result<()> { - match &mut self.backend { - CaptureBackend::Shared(texture) => texture.update(source), - CaptureBackend::Uploaded(texture) => texture.update(queue, source), - } - } - - fn is_shared(&self) -> bool { - matches!(self.backend, CaptureBackend::Shared(_)) - } -} - -enum CaptureBackend { - Shared(SharedTexture), - Uploaded(UploadedTexture), -} - -impl CaptureBackend { - fn size(&self) -> wgpu::Extent3d { - match self { - Self::Shared(texture) => texture.size, - Self::Uploaded(texture) => texture.size, - } - } -} - -impl From for CaptureBackend { - fn from(value: SharedTexture) -> Self { - Self::Shared(value) - } -} - -impl From for CaptureBackend { - fn from(value: UploadedTexture) -> Self { - Self::Uploaded(value) - } -} - -fn source_descriptor(source: &ID3D11Texture2D) -> D3D11_TEXTURE2D_DESC { - let mut descriptor = D3D11_TEXTURE2D_DESC::default(); - unsafe { source.GetDesc(&mut descriptor) }; - descriptor -} - -fn source_size(source: &ID3D11Texture2D) -> wgpu::Extent3d { - capture_size(&source_descriptor(source)) -} - -fn validate_capture_descriptor(descriptor: &D3D11_TEXTURE2D_DESC) -> Result<()> { - use bindings::Windows::Win32::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB}; - - anyhow::ensure!( - descriptor.Width > 0 && descriptor.Height > 0, - "capture texture has empty dimensions" - ); - anyhow::ensure!( - descriptor.MipLevels == 1 && descriptor.ArraySize == 1 && descriptor.SampleDesc.Count == 1, - "capture texture layout is unsupported" - ); - anyhow::ensure!( - descriptor.Format == DXGI_FORMAT_B8G8R8A8_UNORM - || descriptor.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, - "capture texture format {} is not BGRA8", - descriptor.Format - ); - Ok(()) -} - -fn capture_size(descriptor: &D3D11_TEXTURE2D_DESC) -> wgpu::Extent3d { - wgpu::Extent3d { - width: descriptor.Width, - height: descriptor.Height, - depth_or_array_layers: 1, - } -} - -fn capture_texture_descriptor( - label: &'static str, - size: wgpu::Extent3d, -) -> wgpu::TextureDescriptor<'static> { - wgpu::TextureDescriptor { - label: Some(label), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - // Windows Graphics Capture produces DXGI_FORMAT_B8G8R8A8_UNORM. Importing it as - // sRGB applies a second decode during sampling and visibly darkens captured content. - format: wgpu::TextureFormat::Bgra8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - } -} - -#[cfg(test)] -mod tests { - use collections::FxHashMap; - - use super::CaptureFrameKey; - - #[test] - fn capture_frame_key_controls_cache_identity_and_pruning() { - let first = CaptureFrameKey { - texture: 42, - display_time: 1, - }; - let second = CaptureFrameKey { - texture: 42, - display_time: 2, - }; - assert_ne!(first, second); - - let mut cache = FxHashMap::default(); - assert_eq!(cache.insert(first, 1), None); - assert_eq!(cache.insert(second, 2), None); - assert_eq!(cache.insert(first, 3), Some(1)); - assert_eq!(cache.len(), 2); - - cache.retain(|key, _| *key == second); - assert_eq!(cache.len(), 1); - assert_eq!(cache.get(&second), Some(&2)); - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs deleted file mode 100644 index 0000e810d..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/bindings.rs +++ /dev/null @@ -1,1180 +0,0 @@ -// Generated by windows-bindgen 0.100.0. -// Regenerate with `cargo run -p gpui_ce_windows_bindings`. - -#![allow( - non_snake_case, - non_upper_case_globals, - non_camel_case_types, - dead_code, - clippy::all -)] - -pub mod Windows { - pub mod Win32 { - #[inline] - pub unsafe fn CloseHandle(hobject: HANDLE) -> windows_core::BOOL { - windows_core::link!("kernel32.dll" "system" fn CloseHandle(hobject : HANDLE) -> windows_core::BOOL); - unsafe { CloseHandle(hobject) } - } - pub type D3D11_BIND_FLAG = i32; - pub const D3D11_BIND_SHADER_RESOURCE: D3D11_BIND_FLAG = 8; - pub type D3D11_CPU_ACCESS_FLAG = i32; - pub const D3D11_CPU_ACCESS_READ: D3D11_CPU_ACCESS_FLAG = 131072; - pub type D3D11_FENCE_FLAG = u32; - pub const D3D11_FENCE_FLAG_SHARED: D3D11_FENCE_FLAG = 2; - pub type D3D11_MAP = i32; - #[repr(C)] - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct D3D11_MAPPED_SUBRESOURCE { - pub pData: *mut core::ffi::c_void, - pub RowPitch: u32, - pub DepthPitch: u32, - } - pub const D3D11_MAP_READ: D3D11_MAP = 1; - pub type D3D11_RESOURCE_MISC_FLAG = i32; - pub const D3D11_RESOURCE_MISC_SHARED_NTHANDLE: D3D11_RESOURCE_MISC_FLAG = 2048; - #[repr(C)] - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct D3D11_SUBRESOURCE_DATA { - pub pSysMem: *const core::ffi::c_void, - pub SysMemPitch: u32, - pub SysMemSlicePitch: u32, - } - #[repr(C)] - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct D3D11_TEXTURE2D_DESC { - pub Width: u32, - pub Height: u32, - pub MipLevels: u32, - pub ArraySize: u32, - pub Format: DXGI_FORMAT, - pub SampleDesc: DXGI_SAMPLE_DESC, - pub Usage: D3D11_USAGE, - pub BindFlags: u32, - pub CPUAccessFlags: u32, - pub MiscFlags: u32, - } - pub type D3D11_USAGE = i32; - pub const D3D11_USAGE_DEFAULT: D3D11_USAGE = 0; - pub const D3D11_USAGE_STAGING: D3D11_USAGE = 3; - pub type DXGI_FORMAT = i32; - pub const DXGI_FORMAT_B8G8R8A8_TYPELESS: DXGI_FORMAT = 90; - pub const DXGI_FORMAT_B8G8R8A8_UNORM: DXGI_FORMAT = 87; - pub const DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: DXGI_FORMAT = 91; - #[repr(C)] - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct DXGI_SAMPLE_DESC { - pub Count: u32, - pub Quality: u32, - } - pub const GENERIC_ALL: i32 = 268435456; - #[repr(transparent)] - #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] - pub struct HANDLE(pub *mut core::ffi::c_void); - windows_core::imp::define_interface!( - ID3D11Device, - ID3D11Device_Vtbl, - 0xdb6f6ddb_ac77_4e88_8253_819df9bbf140 - ); - windows_core::imp::interface_hierarchy!(ID3D11Device, windows_core::IUnknown); - impl ID3D11Device { - pub unsafe fn CreateTexture2D( - &self, - pdesc: *const D3D11_TEXTURE2D_DESC, - pinitialdata: Option<*const D3D11_SUBRESOURCE_DATA>, - pptexture2d: Option<*mut Option>, - ) -> windows_core::HRESULT { - unsafe { - (windows_core::Interface::vtable(self).CreateTexture2D)( - windows_core::Interface::as_raw(self), - pdesc, - pinitialdata.unwrap_or(core::mem::zeroed()) as _, - pptexture2d.unwrap_or(core::mem::zeroed()) as _, - ) - } - } - pub unsafe fn GetImmediateContext(&self) -> windows_core::Result { - unsafe { - let mut result__ = core::mem::zeroed(); - (windows_core::Interface::vtable(self).GetImmediateContext)( - windows_core::Interface::as_raw(self), - &mut result__, - ); - windows_core::imp::Type::from_abi(result__) - } - } - } - #[repr(C)] - pub struct ID3D11Device_Vtbl { - pub base__: windows_core::IUnknown_Vtbl, - CreateBuffer: usize, - CreateTexture1D: usize, - pub CreateTexture2D: unsafe extern "system" fn( - *mut core::ffi::c_void, - *const D3D11_TEXTURE2D_DESC, - *const D3D11_SUBRESOURCE_DATA, - *mut *mut core::ffi::c_void, - ) -> windows_core::HRESULT, - CreateTexture3D: usize, - CreateShaderResourceView: usize, - CreateUnorderedAccessView: usize, - CreateRenderTargetView: usize, - CreateDepthStencilView: usize, - CreateInputLayout: usize, - CreateVertexShader: usize, - CreateGeometryShader: usize, - CreateGeometryShaderWithStreamOutput: usize, - CreatePixelShader: usize, - CreateHullShader: usize, - CreateDomainShader: usize, - CreateComputeShader: usize, - CreateClassLinkage: usize, - CreateBlendState: usize, - CreateDepthStencilState: usize, - CreateRasterizerState: usize, - CreateSamplerState: usize, - CreateQuery: usize, - CreatePredicate: usize, - CreateCounter: usize, - CreateDeferredContext: usize, - OpenSharedResource: usize, - CheckFormatSupport: usize, - CheckMultisampleQualityLevels: usize, - CheckCounterInfo: usize, - CheckCounter: usize, - CheckFeatureSupport: usize, - GetPrivateData: usize, - SetPrivateData: usize, - SetPrivateDataInterface: usize, - GetFeatureLevel: usize, - GetCreationFlags: usize, - GetDeviceRemovedReason: usize, - pub GetImmediateContext: - unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void), - SetExceptionMode: usize, - GetExceptionMode: usize, - } - impl windows_core::RuntimeName for ID3D11Device {} - windows_core::imp::define_interface!( - ID3D11Device1, - ID3D11Device1_Vtbl, - 0xa04bfb29_08ef_43d6_a49c_a9bdbdcbe686 - ); - impl core::ops::Deref for ID3D11Device1 { - type Target = ID3D11Device; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Device1, - windows_core::IUnknown, - ID3D11Device - ); - #[repr(C)] - pub struct ID3D11Device1_Vtbl { - pub base__: ID3D11Device_Vtbl, - GetImmediateContext1: usize, - CreateDeferredContext1: usize, - CreateBlendState1: usize, - CreateRasterizerState1: usize, - CreateDeviceContextState: usize, - OpenSharedResource1: usize, - OpenSharedResourceByName: usize, - } - impl windows_core::RuntimeName for ID3D11Device1 {} - windows_core::imp::define_interface!( - ID3D11Device2, - ID3D11Device2_Vtbl, - 0x9d06dffa_d1e5_4d07_83a8_1bb123f2f841 - ); - impl core::ops::Deref for ID3D11Device2 { - type Target = ID3D11Device1; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Device2, - windows_core::IUnknown, - ID3D11Device, - ID3D11Device1 - ); - #[repr(C)] - pub struct ID3D11Device2_Vtbl { - pub base__: ID3D11Device1_Vtbl, - GetImmediateContext2: usize, - CreateDeferredContext2: usize, - GetResourceTiling: usize, - CheckMultisampleQualityLevels1: usize, - } - impl windows_core::RuntimeName for ID3D11Device2 {} - windows_core::imp::define_interface!( - ID3D11Device3, - ID3D11Device3_Vtbl, - 0xa05c8c37_d2c6_4732_b3a0_9ce0b0dc9ae6 - ); - impl core::ops::Deref for ID3D11Device3 { - type Target = ID3D11Device2; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Device3, - windows_core::IUnknown, - ID3D11Device, - ID3D11Device1, - ID3D11Device2 - ); - #[repr(C)] - pub struct ID3D11Device3_Vtbl { - pub base__: ID3D11Device2_Vtbl, - CreateTexture2D1: usize, - CreateTexture3D1: usize, - CreateRasterizerState2: usize, - CreateShaderResourceView1: usize, - CreateUnorderedAccessView1: usize, - CreateRenderTargetView1: usize, - CreateQuery1: usize, - GetImmediateContext3: usize, - CreateDeferredContext3: usize, - WriteToSubresource: usize, - ReadFromSubresource: usize, - } - impl windows_core::RuntimeName for ID3D11Device3 {} - windows_core::imp::define_interface!( - ID3D11Device4, - ID3D11Device4_Vtbl, - 0x8992ab71_02e6_4b8d_ba48_b056dcda42c4 - ); - impl core::ops::Deref for ID3D11Device4 { - type Target = ID3D11Device3; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Device4, - windows_core::IUnknown, - ID3D11Device, - ID3D11Device1, - ID3D11Device2, - ID3D11Device3 - ); - #[repr(C)] - pub struct ID3D11Device4_Vtbl { - pub base__: ID3D11Device3_Vtbl, - RegisterDeviceRemovedEvent: usize, - UnregisterDeviceRemoved: usize, - } - impl windows_core::RuntimeName for ID3D11Device4 {} - windows_core::imp::define_interface!( - ID3D11Device5, - ID3D11Device5_Vtbl, - 0x8ffde202_a0e7_45df_9e01_e837801b5ea0 - ); - impl core::ops::Deref for ID3D11Device5 { - type Target = ID3D11Device4; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Device5, - windows_core::IUnknown, - ID3D11Device, - ID3D11Device1, - ID3D11Device2, - ID3D11Device3, - ID3D11Device4 - ); - impl ID3D11Device5 { - pub unsafe fn CreateFence( - &self, - initialvalue: u64, - flags: D3D11_FENCE_FLAG, - result__: *mut Option, - ) -> windows_core::Result<()> - where - T: windows_core::Interface, - { - unsafe { - (windows_core::Interface::vtable(self).CreateFence)( - windows_core::Interface::as_raw(self), - initialvalue, - flags, - &T::IID, - result__ as *mut _ as *mut _, - ) - .ok() - } - } - } - #[repr(C)] - pub struct ID3D11Device5_Vtbl { - pub base__: ID3D11Device4_Vtbl, - OpenSharedFence: usize, - pub CreateFence: unsafe extern "system" fn( - *mut core::ffi::c_void, - u64, - D3D11_FENCE_FLAG, - *const windows_core::GUID, - *mut *mut core::ffi::c_void, - ) -> windows_core::HRESULT, - } - impl windows_core::RuntimeName for ID3D11Device5 {} - windows_core::imp::define_interface!( - ID3D11DeviceChild, - ID3D11DeviceChild_Vtbl, - 0x1841e5c8_16b0_489b_bcc8_44cfb0d5deae - ); - windows_core::imp::interface_hierarchy!(ID3D11DeviceChild, windows_core::IUnknown); - impl ID3D11DeviceChild { - pub unsafe fn GetDevice(&self) -> windows_core::Result { - unsafe { - let mut result__ = core::mem::zeroed(); - (windows_core::Interface::vtable(self).GetDevice)( - windows_core::Interface::as_raw(self), - &mut result__, - ); - windows_core::imp::Type::from_abi(result__) - } - } - } - #[repr(C)] - pub struct ID3D11DeviceChild_Vtbl { - pub base__: windows_core::IUnknown_Vtbl, - pub GetDevice: - unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void), - GetPrivateData: usize, - SetPrivateData: usize, - SetPrivateDataInterface: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceChild {} - windows_core::imp::define_interface!( - ID3D11DeviceContext, - ID3D11DeviceContext_Vtbl, - 0xc0bfa96c_e089_44fb_8eaf_26f8796190da - ); - impl core::ops::Deref for ID3D11DeviceContext { - type Target = ID3D11DeviceChild; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11DeviceContext, - windows_core::IUnknown, - ID3D11DeviceChild - ); - impl ID3D11DeviceContext { - pub unsafe fn Map( - &self, - presource: P0, - subresource: u32, - maptype: D3D11_MAP, - mapflags: u32, - pmappedresource: Option<*mut D3D11_MAPPED_SUBRESOURCE>, - ) -> windows_core::HRESULT - where - P0: windows_core::Param, - { - unsafe { - (windows_core::Interface::vtable(self).Map)( - windows_core::Interface::as_raw(self), - presource.param().abi(), - subresource, - maptype, - mapflags, - pmappedresource.unwrap_or(core::mem::zeroed()) as _, - ) - } - } - pub unsafe fn Unmap(&self, presource: P0, subresource: u32) - where - P0: windows_core::Param, - { - unsafe { - (windows_core::Interface::vtable(self).Unmap)( - windows_core::Interface::as_raw(self), - presource.param().abi(), - subresource, - ); - } - } - pub unsafe fn CopyResource(&self, pdstresource: P0, psrcresource: P1) - where - P0: windows_core::Param, - P1: windows_core::Param, - { - unsafe { - (windows_core::Interface::vtable(self).CopyResource)( - windows_core::Interface::as_raw(self), - pdstresource.param().abi(), - psrcresource.param().abi(), - ); - } - } - pub unsafe fn Flush(&self) { - unsafe { - (windows_core::Interface::vtable(self).Flush)(windows_core::Interface::as_raw( - self, - )); - } - } - } - #[repr(C)] - pub struct ID3D11DeviceContext_Vtbl { - pub base__: ID3D11DeviceChild_Vtbl, - VSSetConstantBuffers: usize, - PSSetShaderResources: usize, - PSSetShader: usize, - PSSetSamplers: usize, - VSSetShader: usize, - DrawIndexed: usize, - Draw: usize, - pub Map: unsafe extern "system" fn( - *mut core::ffi::c_void, - *mut core::ffi::c_void, - u32, - D3D11_MAP, - u32, - *mut D3D11_MAPPED_SUBRESOURCE, - ) -> windows_core::HRESULT, - pub Unmap: - unsafe extern "system" fn(*mut core::ffi::c_void, *mut core::ffi::c_void, u32), - PSSetConstantBuffers: usize, - IASetInputLayout: usize, - IASetVertexBuffers: usize, - IASetIndexBuffer: usize, - DrawIndexedInstanced: usize, - DrawInstanced: usize, - GSSetConstantBuffers: usize, - GSSetShader: usize, - IASetPrimitiveTopology: usize, - VSSetShaderResources: usize, - VSSetSamplers: usize, - Begin: usize, - End: usize, - GetData: usize, - SetPredication: usize, - GSSetShaderResources: usize, - GSSetSamplers: usize, - OMSetRenderTargets: usize, - OMSetRenderTargetsAndUnorderedAccessViews: usize, - OMSetBlendState: usize, - OMSetDepthStencilState: usize, - SOSetTargets: usize, - DrawAuto: usize, - DrawIndexedInstancedIndirect: usize, - DrawInstancedIndirect: usize, - Dispatch: usize, - DispatchIndirect: usize, - RSSetState: usize, - RSSetViewports: usize, - RSSetScissorRects: usize, - CopySubresourceRegion: usize, - pub CopyResource: unsafe extern "system" fn( - *mut core::ffi::c_void, - *mut core::ffi::c_void, - *mut core::ffi::c_void, - ), - UpdateSubresource: usize, - CopyStructureCount: usize, - ClearRenderTargetView: usize, - ClearUnorderedAccessViewUint: usize, - ClearUnorderedAccessViewFloat: usize, - ClearDepthStencilView: usize, - GenerateMips: usize, - SetResourceMinLOD: usize, - GetResourceMinLOD: usize, - ResolveSubresource: usize, - ExecuteCommandList: usize, - HSSetShaderResources: usize, - HSSetShader: usize, - HSSetSamplers: usize, - HSSetConstantBuffers: usize, - DSSetShaderResources: usize, - DSSetShader: usize, - DSSetSamplers: usize, - DSSetConstantBuffers: usize, - CSSetShaderResources: usize, - CSSetUnorderedAccessViews: usize, - CSSetShader: usize, - CSSetSamplers: usize, - CSSetConstantBuffers: usize, - VSGetConstantBuffers: usize, - PSGetShaderResources: usize, - PSGetShader: usize, - PSGetSamplers: usize, - VSGetShader: usize, - PSGetConstantBuffers: usize, - IAGetInputLayout: usize, - IAGetVertexBuffers: usize, - IAGetIndexBuffer: usize, - GSGetConstantBuffers: usize, - GSGetShader: usize, - IAGetPrimitiveTopology: usize, - VSGetShaderResources: usize, - VSGetSamplers: usize, - GetPredication: usize, - GSGetShaderResources: usize, - GSGetSamplers: usize, - OMGetRenderTargets: usize, - OMGetRenderTargetsAndUnorderedAccessViews: usize, - OMGetBlendState: usize, - OMGetDepthStencilState: usize, - SOGetTargets: usize, - RSGetState: usize, - RSGetViewports: usize, - RSGetScissorRects: usize, - HSGetShaderResources: usize, - HSGetShader: usize, - HSGetSamplers: usize, - HSGetConstantBuffers: usize, - DSGetShaderResources: usize, - DSGetShader: usize, - DSGetSamplers: usize, - DSGetConstantBuffers: usize, - CSGetShaderResources: usize, - CSGetUnorderedAccessViews: usize, - CSGetShader: usize, - CSGetSamplers: usize, - CSGetConstantBuffers: usize, - ClearState: usize, - pub Flush: unsafe extern "system" fn(*mut core::ffi::c_void), - GetType: usize, - GetContextFlags: usize, - FinishCommandList: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceContext {} - windows_core::imp::define_interface!( - ID3D11DeviceContext1, - ID3D11DeviceContext1_Vtbl, - 0xbb2c6faa_b5fb_4082_8e6b_388b8cfa90e1 - ); - impl core::ops::Deref for ID3D11DeviceContext1 { - type Target = ID3D11DeviceContext; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11DeviceContext1, - windows_core::IUnknown, - ID3D11DeviceChild, - ID3D11DeviceContext - ); - #[repr(C)] - pub struct ID3D11DeviceContext1_Vtbl { - pub base__: ID3D11DeviceContext_Vtbl, - CopySubresourceRegion1: usize, - UpdateSubresource1: usize, - DiscardResource: usize, - DiscardView: usize, - VSSetConstantBuffers1: usize, - HSSetConstantBuffers1: usize, - DSSetConstantBuffers1: usize, - GSSetConstantBuffers1: usize, - PSSetConstantBuffers1: usize, - CSSetConstantBuffers1: usize, - VSGetConstantBuffers1: usize, - HSGetConstantBuffers1: usize, - DSGetConstantBuffers1: usize, - GSGetConstantBuffers1: usize, - PSGetConstantBuffers1: usize, - CSGetConstantBuffers1: usize, - SwapDeviceContextState: usize, - ClearView: usize, - DiscardView1: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceContext1 {} - windows_core::imp::define_interface!( - ID3D11DeviceContext2, - ID3D11DeviceContext2_Vtbl, - 0x420d5b32_b90c_4da4_bef0_359f6a24a83a - ); - impl core::ops::Deref for ID3D11DeviceContext2 { - type Target = ID3D11DeviceContext1; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11DeviceContext2, - windows_core::IUnknown, - ID3D11DeviceChild, - ID3D11DeviceContext, - ID3D11DeviceContext1 - ); - #[repr(C)] - pub struct ID3D11DeviceContext2_Vtbl { - pub base__: ID3D11DeviceContext1_Vtbl, - UpdateTileMappings: usize, - CopyTileMappings: usize, - CopyTiles: usize, - UpdateTiles: usize, - ResizeTilePool: usize, - TiledResourceBarrier: usize, - IsAnnotationEnabled: usize, - SetMarkerInt: usize, - BeginEventInt: usize, - EndEvent: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceContext2 {} - windows_core::imp::define_interface!( - ID3D11DeviceContext3, - ID3D11DeviceContext3_Vtbl, - 0xb4e3c01d_e79e_4637_91b2_510e9f4c9b8f - ); - impl core::ops::Deref for ID3D11DeviceContext3 { - type Target = ID3D11DeviceContext2; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11DeviceContext3, - windows_core::IUnknown, - ID3D11DeviceChild, - ID3D11DeviceContext, - ID3D11DeviceContext1, - ID3D11DeviceContext2 - ); - #[repr(C)] - pub struct ID3D11DeviceContext3_Vtbl { - pub base__: ID3D11DeviceContext2_Vtbl, - Flush1: usize, - SetHardwareProtectionState: usize, - GetHardwareProtectionState: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceContext3 {} - windows_core::imp::define_interface!( - ID3D11DeviceContext4, - ID3D11DeviceContext4_Vtbl, - 0x917600da_f58c_4c33_98d8_3e15b390fa24 - ); - impl core::ops::Deref for ID3D11DeviceContext4 { - type Target = ID3D11DeviceContext3; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11DeviceContext4, - windows_core::IUnknown, - ID3D11DeviceChild, - ID3D11DeviceContext, - ID3D11DeviceContext1, - ID3D11DeviceContext2, - ID3D11DeviceContext3 - ); - impl ID3D11DeviceContext4 { - pub unsafe fn Signal(&self, pfence: P0, value: u64) -> windows_core::HRESULT - where - P0: windows_core::Param, - { - unsafe { - (windows_core::Interface::vtable(self).Signal)( - windows_core::Interface::as_raw(self), - pfence.param().abi(), - value, - ) - } - } - } - #[repr(C)] - pub struct ID3D11DeviceContext4_Vtbl { - pub base__: ID3D11DeviceContext3_Vtbl, - pub Signal: unsafe extern "system" fn( - *mut core::ffi::c_void, - *mut core::ffi::c_void, - u64, - ) -> windows_core::HRESULT, - Wait: usize, - } - impl windows_core::RuntimeName for ID3D11DeviceContext4 {} - windows_core::imp::define_interface!( - ID3D11Fence, - ID3D11Fence_Vtbl, - 0xaffde9d1_1df7_4bb7_8a34_0f46251dab80 - ); - impl core::ops::Deref for ID3D11Fence { - type Target = ID3D11DeviceChild; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Fence, - windows_core::IUnknown, - ID3D11DeviceChild - ); - impl ID3D11Fence { - pub unsafe fn CreateSharedHandle( - &self, - pattributes: Option<*const SECURITY_ATTRIBUTES>, - dwaccess: u32, - lpname: P2, - ) -> windows_core::Result - where - P2: windows_core::Param, - { - unsafe { - let mut result__ = core::mem::zeroed(); - (windows_core::Interface::vtable(self).CreateSharedHandle)( - windows_core::Interface::as_raw(self), - pattributes.unwrap_or(core::mem::zeroed()) as _, - dwaccess, - lpname.param().abi(), - &mut result__, - ) - .map(|| result__) - } - } - } - #[repr(C)] - pub struct ID3D11Fence_Vtbl { - pub base__: ID3D11DeviceChild_Vtbl, - pub CreateSharedHandle: unsafe extern "system" fn( - *mut core::ffi::c_void, - *const SECURITY_ATTRIBUTES, - u32, - windows_core::PCWSTR, - *mut HANDLE, - ) -> windows_core::HRESULT, - GetCompletedValue: usize, - SetEventOnCompletion: usize, - } - impl windows_core::RuntimeName for ID3D11Fence {} - windows_core::imp::define_interface!( - ID3D11Resource, - ID3D11Resource_Vtbl, - 0xdc8e63f3_d12b_4952_b47b_5e45026a862d - ); - impl core::ops::Deref for ID3D11Resource { - type Target = ID3D11DeviceChild; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Resource, - windows_core::IUnknown, - ID3D11DeviceChild - ); - #[repr(C)] - pub struct ID3D11Resource_Vtbl { - pub base__: ID3D11DeviceChild_Vtbl, - GetType: usize, - SetEvictionPriority: usize, - GetEvictionPriority: usize, - } - impl windows_core::RuntimeName for ID3D11Resource {} - windows_core::imp::define_interface!( - ID3D11Texture2D, - ID3D11Texture2D_Vtbl, - 0x6f15aaf2_d208_4e89_9ab4_489535d34f9c - ); - impl core::ops::Deref for ID3D11Texture2D { - type Target = ID3D11Resource; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D11Texture2D, - windows_core::IUnknown, - ID3D11DeviceChild, - ID3D11Resource - ); - impl ID3D11Texture2D { - pub unsafe fn GetDesc(&self, pdesc: *mut D3D11_TEXTURE2D_DESC) { - unsafe { - (windows_core::Interface::vtable(self).GetDesc)( - windows_core::Interface::as_raw(self), - pdesc as _, - ); - } - } - } - #[repr(C)] - pub struct ID3D11Texture2D_Vtbl { - pub base__: ID3D11Resource_Vtbl, - pub GetDesc: - unsafe extern "system" fn(*mut core::ffi::c_void, *mut D3D11_TEXTURE2D_DESC), - } - impl windows_core::RuntimeName for ID3D11Texture2D {} - windows_core::imp::define_interface!( - ID3D12CommandQueue, - ID3D12CommandQueue_Vtbl, - 0x0ec870a6_5d7e_4c22_8cfc_5baae07616ed - ); - impl core::ops::Deref for ID3D12CommandQueue { - type Target = ID3D12Pageable; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D12CommandQueue, - windows_core::IUnknown, - ID3D12Object, - ID3D12DeviceChild, - ID3D12Pageable - ); - impl ID3D12CommandQueue { - pub unsafe fn Wait(&self, pfence: P0, value: u64) -> windows_core::HRESULT - where - P0: windows_core::Param, - { - unsafe { - (windows_core::Interface::vtable(self).Wait)( - windows_core::Interface::as_raw(self), - pfence.param().abi(), - value, - ) - } - } - } - #[repr(C)] - pub struct ID3D12CommandQueue_Vtbl { - pub base__: ID3D12Pageable_Vtbl, - UpdateTileMappings: usize, - CopyTileMappings: usize, - ExecuteCommandLists: usize, - SetMarker: usize, - BeginEvent: usize, - EndEvent: usize, - Signal: usize, - pub Wait: unsafe extern "system" fn( - *mut core::ffi::c_void, - *mut core::ffi::c_void, - u64, - ) -> windows_core::HRESULT, - GetTimestampFrequency: usize, - GetClockCalibration: usize, - GetDesc: usize, - } - impl windows_core::RuntimeName for ID3D12CommandQueue {} - windows_core::imp::define_interface!( - ID3D12Device, - ID3D12Device_Vtbl, - 0x189819f1_1db6_4b57_be54_1821339b85f7 - ); - impl core::ops::Deref for ID3D12Device { - type Target = ID3D12Object; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!(ID3D12Device, windows_core::IUnknown, ID3D12Object); - impl ID3D12Device { - pub unsafe fn OpenSharedHandle( - &self, - nthandle: HANDLE, - result__: *mut Option, - ) -> windows_core::Result<()> - where - T: windows_core::Interface, - { - unsafe { - (windows_core::Interface::vtable(self).OpenSharedHandle)( - windows_core::Interface::as_raw(self), - nthandle, - &T::IID, - result__ as *mut _ as *mut _, - ) - .ok() - } - } - } - #[repr(C)] - pub struct ID3D12Device_Vtbl { - pub base__: ID3D12Object_Vtbl, - GetNodeCount: usize, - CreateCommandQueue: usize, - CreateCommandAllocator: usize, - CreateGraphicsPipelineState: usize, - CreateComputePipelineState: usize, - CreateCommandList: usize, - CheckFeatureSupport: usize, - CreateDescriptorHeap: usize, - GetDescriptorHandleIncrementSize: usize, - CreateRootSignature: usize, - CreateConstantBufferView: usize, - CreateShaderResourceView: usize, - CreateUnorderedAccessView: usize, - CreateRenderTargetView: usize, - CreateDepthStencilView: usize, - CreateSampler: usize, - CopyDescriptors: usize, - CopyDescriptorsSimple: usize, - GetResourceAllocationInfo: usize, - GetCustomHeapProperties: usize, - CreateCommittedResource: usize, - CreateHeap: usize, - CreatePlacedResource: usize, - CreateReservedResource: usize, - CreateSharedHandle: usize, - pub OpenSharedHandle: unsafe extern "system" fn( - *mut core::ffi::c_void, - HANDLE, - *const windows_core::GUID, - *mut *mut core::ffi::c_void, - ) -> windows_core::HRESULT, - OpenSharedHandleByName: usize, - MakeResident: usize, - Evict: usize, - CreateFence: usize, - GetDeviceRemovedReason: usize, - GetCopyableFootprints: usize, - CreateQueryHeap: usize, - SetStablePowerState: usize, - CreateCommandSignature: usize, - GetResourceTiling: usize, - GetAdapterLuid: usize, - } - impl windows_core::RuntimeName for ID3D12Device {} - windows_core::imp::define_interface!( - ID3D12DeviceChild, - ID3D12DeviceChild_Vtbl, - 0x905db94b_a00c_4140_9df5_2b64ca9ea357 - ); - impl core::ops::Deref for ID3D12DeviceChild { - type Target = ID3D12Object; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D12DeviceChild, - windows_core::IUnknown, - ID3D12Object - ); - #[repr(C)] - pub struct ID3D12DeviceChild_Vtbl { - pub base__: ID3D12Object_Vtbl, - GetDevice: usize, - } - impl windows_core::RuntimeName for ID3D12DeviceChild {} - windows_core::imp::define_interface!( - ID3D12Fence, - ID3D12Fence_Vtbl, - 0x0a753dcf_c4d8_4b91_adf6_be5a60d95a76 - ); - impl core::ops::Deref for ID3D12Fence { - type Target = ID3D12Pageable; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D12Fence, - windows_core::IUnknown, - ID3D12Object, - ID3D12DeviceChild, - ID3D12Pageable - ); - #[repr(C)] - pub struct ID3D12Fence_Vtbl { - pub base__: ID3D12Pageable_Vtbl, - GetCompletedValue: usize, - SetEventOnCompletion: usize, - Signal: usize, - } - impl windows_core::RuntimeName for ID3D12Fence {} - windows_core::imp::define_interface!( - ID3D12Object, - ID3D12Object_Vtbl, - 0xc4fec28f_7966_4e95_9f94_f431cb56c3b8 - ); - windows_core::imp::interface_hierarchy!(ID3D12Object, windows_core::IUnknown); - #[repr(C)] - pub struct ID3D12Object_Vtbl { - pub base__: windows_core::IUnknown_Vtbl, - GetPrivateData: usize, - SetPrivateData: usize, - SetPrivateDataInterface: usize, - SetName: usize, - } - impl windows_core::RuntimeName for ID3D12Object {} - windows_core::imp::define_interface!( - ID3D12Pageable, - ID3D12Pageable_Vtbl, - 0x63ee58fb_1268_4835_86da_f008ce62f0d6 - ); - impl core::ops::Deref for ID3D12Pageable { - type Target = ID3D12DeviceChild; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D12Pageable, - windows_core::IUnknown, - ID3D12Object, - ID3D12DeviceChild - ); - #[repr(C)] - pub struct ID3D12Pageable_Vtbl { - pub base__: ID3D12DeviceChild_Vtbl, - } - impl windows_core::RuntimeName for ID3D12Pageable {} - windows_core::imp::define_interface!( - ID3D12Resource, - ID3D12Resource_Vtbl, - 0x696442be_a72e_4059_bc79_5b5c98040fad - ); - impl core::ops::Deref for ID3D12Resource { - type Target = ID3D12Pageable; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - ID3D12Resource, - windows_core::IUnknown, - ID3D12Object, - ID3D12DeviceChild, - ID3D12Pageable - ); - #[repr(C)] - pub struct ID3D12Resource_Vtbl { - pub base__: ID3D12Pageable_Vtbl, - Map: usize, - Unmap: usize, - GetDesc: usize, - GetGPUVirtualAddress: usize, - WriteToSubresource: usize, - ReadFromSubresource: usize, - GetHeapProperties: usize, - } - impl windows_core::RuntimeName for ID3D12Resource {} - windows_core::imp::define_interface!( - IDXGIDeviceSubObject, - IDXGIDeviceSubObject_Vtbl, - 0x3d3e0379_f9de_4d58_bb6c_18d62992f1a6 - ); - impl core::ops::Deref for IDXGIDeviceSubObject { - type Target = IDXGIObject; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - IDXGIDeviceSubObject, - windows_core::IUnknown, - IDXGIObject - ); - #[repr(C)] - pub struct IDXGIDeviceSubObject_Vtbl { - pub base__: IDXGIObject_Vtbl, - GetDevice: usize, - } - impl windows_core::RuntimeName for IDXGIDeviceSubObject {} - windows_core::imp::define_interface!( - IDXGIObject, - IDXGIObject_Vtbl, - 0xaec22fb8_76f3_4639_9be0_28eb43a67a2e - ); - windows_core::imp::interface_hierarchy!(IDXGIObject, windows_core::IUnknown); - #[repr(C)] - pub struct IDXGIObject_Vtbl { - pub base__: windows_core::IUnknown_Vtbl, - SetPrivateData: usize, - SetPrivateDataInterface: usize, - GetPrivateData: usize, - GetParent: usize, - } - impl windows_core::RuntimeName for IDXGIObject {} - windows_core::imp::define_interface!( - IDXGIResource, - IDXGIResource_Vtbl, - 0x035f3ab4_482e_4e50_b41f_8a7f8bd8960b - ); - impl core::ops::Deref for IDXGIResource { - type Target = IDXGIDeviceSubObject; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - IDXGIResource, - windows_core::IUnknown, - IDXGIObject, - IDXGIDeviceSubObject - ); - #[repr(C)] - pub struct IDXGIResource_Vtbl { - pub base__: IDXGIDeviceSubObject_Vtbl, - GetSharedHandle: usize, - GetUsage: usize, - SetEvictionPriority: usize, - GetEvictionPriority: usize, - } - impl windows_core::RuntimeName for IDXGIResource {} - windows_core::imp::define_interface!( - IDXGIResource1, - IDXGIResource1_Vtbl, - 0x30961379_4609_4a41_998e_54fe567ee0c1 - ); - impl core::ops::Deref for IDXGIResource1 { - type Target = IDXGIResource; - fn deref(&self) -> &Self::Target { - unsafe { core::mem::transmute(self) } - } - } - windows_core::imp::interface_hierarchy!( - IDXGIResource1, - windows_core::IUnknown, - IDXGIObject, - IDXGIDeviceSubObject, - IDXGIResource - ); - impl IDXGIResource1 { - pub unsafe fn CreateSharedHandle( - &self, - pattributes: Option<*const SECURITY_ATTRIBUTES>, - dwaccess: u32, - lpname: P2, - ) -> windows_core::Result - where - P2: windows_core::Param, - { - unsafe { - let mut result__ = core::mem::zeroed(); - (windows_core::Interface::vtable(self).CreateSharedHandle)( - windows_core::Interface::as_raw(self), - pattributes.unwrap_or(core::mem::zeroed()) as _, - dwaccess, - lpname.param().abi(), - &mut result__, - ) - .map(|| result__) - } - } - } - #[repr(C)] - pub struct IDXGIResource1_Vtbl { - pub base__: IDXGIResource_Vtbl, - CreateSubresourceSurface: usize, - pub CreateSharedHandle: unsafe extern "system" fn( - *mut core::ffi::c_void, - *const SECURITY_ATTRIBUTES, - u32, - windows_core::PCWSTR, - *mut HANDLE, - ) -> windows_core::HRESULT, - } - impl windows_core::RuntimeName for IDXGIResource1 {} - #[repr(C)] - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct SECURITY_ATTRIBUTES { - pub nLength: u32, - pub lpSecurityDescriptor: *mut core::ffi::c_void, - pub bInheritHandle: windows_core::BOOL, - } - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs deleted file mode 100644 index 72b2c360e..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/shared.rs +++ /dev/null @@ -1,188 +0,0 @@ -use super::bindings::Windows::Win32::{ - CloseHandle, HANDLE, ID3D11DeviceContext4, ID3D11Fence, ID3D11Texture2D, ID3D12CommandQueue, - ID3D12Device, ID3D12Fence, ID3D12Resource, -}; -use super::{ - capture_size, capture_texture_descriptor, source_descriptor, validate_capture_descriptor, -}; -use anyhow::{Context as _, Result}; -use windows_core::Interface as _; - -pub(super) struct SharedTexture { - pub(super) size: wgpu::Extent3d, - destination: ID3D11Texture2D, - context: ID3D11DeviceContext4, - d3d11_fence: ID3D11Fence, - d3d12_fence: ID3D12Fence, - d3d12_queue: ID3D12CommandQueue, - fence_value: u64, -} - -impl SharedTexture { - pub(super) fn new( - device: &wgpu::Device, - hal_device: &wgpu::hal::dx12::Device, - source: &ID3D11Texture2D, - ) -> Result<(Self, wgpu::Texture)> { - use super::bindings::Windows::Win32::{ - D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, - D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_USAGE_DEFAULT, - DXGI_FORMAT_B8G8R8A8_TYPELESS, GENERIC_ALL, ID3D11Device5, IDXGIResource1, - }; - - let mut descriptor = source_descriptor(source); - validate_capture_descriptor(&descriptor)?; - descriptor.Format = DXGI_FORMAT_B8G8R8A8_TYPELESS; - descriptor.Usage = D3D11_USAGE_DEFAULT; - descriptor.BindFlags = D3D11_BIND_SHADER_RESOURCE as u32; - descriptor.CPUAccessFlags = 0; - descriptor.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE as u32; - - let source_device = - unsafe { source.GetDevice() }.context("getting capture D3D11 device")?; - let context: ID3D11DeviceContext4 = unsafe { source_device.GetImmediateContext() } - .context("getting capture D3D11 context")? - .cast() - .context("D3D11 fence signaling is unavailable")?; - let mut destination = None; - unsafe { - source_device.CreateTexture2D( - &descriptor, - None, - Some(std::ptr::addr_of_mut!(destination)), - ) - } - .ok() - .context("creating shared D3D11 capture texture")?; - let destination = destination.context("D3D11 returned no shared texture")?; - - let device5: ID3D11Device5 = source_device - .cast() - .context("D3D11 shared fences are unavailable")?; - let mut d3d11_fence: Option = None; - unsafe { - device5.CreateFence( - 0, - D3D11_FENCE_FLAG_SHARED, - std::ptr::addr_of_mut!(d3d11_fence), - ) - } - .context("creating shared D3D11 capture fence")?; - let d3d11_fence = d3d11_fence.context("D3D11 returned no shared fence")?; - - let resource: IDXGIResource1 = destination - .cast() - .context("querying shared DXGI resource")?; - let resource_handle = - unsafe { resource.CreateSharedHandle(None, GENERIC_ALL as u32, None) } - .map(OwnedHandle) - .context("creating capture resource handle")?; - let fence_handle = - unsafe { d3d11_fence.CreateSharedHandle(None, GENERIC_ALL as u32, None) } - .map(OwnedHandle) - .context("creating capture fence handle")?; - let (d3d12_resource, d3d12_fence) = - open_shared_resources(hal_device, &resource_handle, &fence_handle)?; - - let size = capture_size(&descriptor); - let hal_texture = unsafe { - wgpu::hal::dx12::Device::texture_from_raw( - // WGPU's bindings use the same ID3D12Resource COM ABI. Transfer - // ownership into the HAL so it releases this reference with the texture. - std::mem::transmute(d3d12_resource), - wgpu::TextureFormat::Bgra8Unorm, - wgpu::TextureDimension::D2, - size, - 1, - 1, - ) - }; - let texture = unsafe { - device.create_texture_from_hal::( - hal_texture, - &capture_texture_descriptor("windows_capture_shared", size), - ) - }; - Ok(( - Self { - size, - destination, - context, - d3d11_fence, - d3d12_fence, - // Transfer an owned ID3D12CommandQueue reference between bindings - // with identical COM layout and reference-counting semantics. - d3d12_queue: unsafe { std::mem::transmute(hal_device.raw_queue().clone()) }, - fence_value: 0, - }, - texture, - )) - } - - pub(super) fn update(&mut self, source: &ID3D11Texture2D) -> Result<()> { - // CachedTexture gives each (native texture pointer, capture timestamp) frame - // its own destination and initializes it once. That makes this destination - // immutable while D3D12 samples it; only the producer-to-consumer handoff is - // needed here. - let fence_value = self - .fence_value - .checked_add(1) - .context("capture fence value exhausted")?; - unsafe { self.context.CopyResource(&self.destination, source) }; - unsafe { self.context.Signal(&self.d3d11_fence, fence_value) } - .ok() - .context("signaling capture copy completion")?; - // D3D11 immediate contexts can defer command submission. Flush after the - // producer signal so the D3D12 queue wait below cannot wait on work that - // is still sitting in the D3D11 command buffer. - unsafe { self.context.Flush() }; - unsafe { self.d3d12_queue.Wait(&self.d3d12_fence, fence_value) } - .ok() - .context("waiting for D3D11 capture copy")?; - self.fence_value = fence_value; - Ok(()) - } -} - -fn open_shared_resources( - hal_device: &wgpu::hal::dx12::Device, - resource_handle: &OwnedHandle, - fence_handle: &OwnedHandle, -) -> Result<(ID3D12Resource, ID3D12Fence)> { - // Clone before transferring this ABI-stable ID3D12Device pointer so our - // generated binding owns a reference independent of the HAL device. - let raw_device: ID3D12Device = unsafe { std::mem::transmute(hal_device.raw_device().clone()) }; - let mut resource: Option = None; - let mut fence: Option = None; - unsafe { - raw_device.OpenSharedHandle( - HANDLE(resource_handle.raw()), - std::ptr::addr_of_mut!(resource), - ) - } - .context("opening capture texture on D3D12")?; - unsafe { - raw_device.OpenSharedHandle(HANDLE(fence_handle.raw()), std::ptr::addr_of_mut!(fence)) - } - .context("opening capture fence on D3D12")?; - Ok(( - resource.context("D3D12 returned no capture texture")?, - fence.context("D3D12 returned no capture fence")?, - )) -} - -struct OwnedHandle(HANDLE); - -impl OwnedHandle { - fn raw(&self) -> *mut std::ffi::c_void { - self.0.0 - } -} - -impl Drop for OwnedHandle { - fn drop(&mut self) { - if let Err(error) = unsafe { CloseHandle(self.0) }.ok() { - log::error!("failed to close shared capture handle: {error}"); - } - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs b/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs deleted file mode 100644 index 39fc02c68..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/surfaces/windows/upload.rs +++ /dev/null @@ -1,121 +0,0 @@ -use super::bindings::Windows::Win32::{ - D3D11_MAPPED_SUBRESOURCE, ID3D11DeviceContext, ID3D11Texture2D, -}; -use super::{ - capture_size, capture_texture_descriptor, source_descriptor, validate_capture_descriptor, -}; -use anyhow::{Context as _, Result}; - -pub(super) struct UploadedTexture { - pub(super) size: wgpu::Extent3d, - context: ID3D11DeviceContext, - staging: ID3D11Texture2D, - pixels: Vec, - texture: wgpu::Texture, -} - -impl UploadedTexture { - pub(super) fn new( - device: &wgpu::Device, - source: &ID3D11Texture2D, - ) -> Result<(Self, wgpu::Texture)> { - use super::bindings::Windows::Win32::{D3D11_CPU_ACCESS_READ, D3D11_USAGE_STAGING}; - - let mut descriptor = source_descriptor(source); - validate_capture_descriptor(&descriptor)?; - descriptor.Usage = D3D11_USAGE_STAGING; - descriptor.BindFlags = 0; - descriptor.CPUAccessFlags = D3D11_CPU_ACCESS_READ as u32; - descriptor.MiscFlags = 0; - let size = capture_size(&descriptor); - - let source_device = - unsafe { source.GetDevice() }.context("getting capture D3D11 device")?; - let context = unsafe { source_device.GetImmediateContext() } - .context("getting capture D3D11 context")?; - let mut staging = None; - unsafe { - source_device.CreateTexture2D(&descriptor, None, Some(std::ptr::addr_of_mut!(staging))) - } - .ok() - .context("creating D3D11 capture staging texture")?; - let staging = staging.context("D3D11 returned no staging texture")?; - let texture = device.create_texture(&wgpu::TextureDescriptor { - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - ..capture_texture_descriptor("windows_capture_uploaded", size) - }); - Ok(( - Self { - size, - context, - staging, - pixels: vec![0; size.width as usize * size.height as usize * 4], - texture: texture.clone(), - }, - texture, - )) - } - - pub(super) fn update(&mut self, queue: &wgpu::Queue, source: &ID3D11Texture2D) -> Result<()> { - unsafe { self.context.CopyResource(&self.staging, source) }; - let mapped = MappedTexture::new(&self.context, &self.staging)?; - let bytes_per_row = self.size.width as usize * 4; - for row in 0..self.size.height as usize { - let source = mapped.row(row, bytes_per_row); - self.pixels[row * bytes_per_row..(row + 1) * bytes_per_row].copy_from_slice(source); - } - drop(mapped); - - queue.write_texture( - self.texture.as_image_copy(), - &self.pixels, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(bytes_per_row as u32), - rows_per_image: Some(self.size.height), - }, - self.size, - ); - Ok(()) - } -} - -struct MappedTexture<'a> { - context: &'a ID3D11DeviceContext, - texture: &'a ID3D11Texture2D, - mapped: D3D11_MAPPED_SUBRESOURCE, -} - -impl<'a> MappedTexture<'a> { - fn new(context: &'a ID3D11DeviceContext, texture: &'a ID3D11Texture2D) -> Result { - use super::bindings::Windows::Win32::D3D11_MAP_READ; - - let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); - unsafe { context.Map(texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) } - .ok() - .context("mapping D3D11 capture texture")?; - Ok(Self { - context, - texture, - mapped, - }) - } - - fn row(&self, row: usize, bytes_per_row: usize) -> &[u8] { - unsafe { - std::slice::from_raw_parts( - self.mapped - .pData - .cast::() - .add(row * self.mapped.RowPitch as usize), - bytes_per_row, - ) - } - } -} - -impl Drop for MappedTexture<'_> { - fn drop(&mut self) { - unsafe { self.context.Unmap(self.texture, 0) }; - } -} diff --git a/vendor/gpui-wgpu/src/wgpu_renderer/target.rs b/vendor/gpui-wgpu/src/wgpu_renderer/target.rs deleted file mode 100644 index 2f2b9d140..000000000 --- a/vendor/gpui-wgpu/src/wgpu_renderer/target.rs +++ /dev/null @@ -1,519 +0,0 @@ -use gpui::{DevicePixels, Size}; - -use super::WgpuSurfaceConfig; - -/// Configuration and lifecycle state shared by window, canvas, and headless targets. -pub(super) struct RenderTarget { - config: wgpu::SurfaceConfiguration, - transparent_alpha_mode: wgpu::CompositeAlphaMode, - opaque_alpha_mode: wgpu::CompositeAlphaMode, - maximum_dimension: u32, - configured: bool, - needs_redraw: bool, - copy_backend_supported: bool, - copy_surface_supported: bool, - pub(super) clear_color: wgpu::Color, -} - -impl RenderTarget { - pub(super) fn new( - adapter: &wgpu::Adapter, - device: &wgpu::Device, - surface: Option<&wgpu::Surface<'_>>, - requested: WgpuSurfaceConfig, - backend: wgpu::Backend, - ) -> anyhow::Result { - let ( - format, - transparent_alpha_mode, - opaque_alpha_mode, - present_mode, - copy_surface_supported, - ) = if let Some(surface) = surface { - let capabilities = surface.get_capabilities(adapter); - let format = [ - wgpu::TextureFormat::Bgra8Unorm, - wgpu::TextureFormat::Rgba8Unorm, - ] - .into_iter() - .find(|format| capabilities.formats.contains(format)) - .or_else(|| { - capabilities - .formats - .iter() - .find(|format| !format.is_srgb()) - .copied() - }) - .or_else(|| capabilities.formats.first().copied()) - .ok_or_else(|| { - anyhow::anyhow!( - "surface reports no texture formats for adapter {:?}", - adapter.get_info().name - ) - })?; - let (transparent, opaque) = - select_alpha_modes(&capabilities.alpha_modes).ok_or_else(|| { - anyhow::anyhow!( - "surface reports no alpha modes for adapter {:?}", - adapter.get_info().name - ) - })?; - let present_mode = select_present_mode( - requested.preferred_present_mode, - &capabilities.present_modes, - wgpu::PresentMode::Fifo, - ); - ( - format, - transparent, - opaque, - present_mode, - capabilities.usages.contains(wgpu::TextureUsages::COPY_DST), - ) - } else { - // Native desktop swapchains are BGRA; matching that keeps snapshots comparable. - ( - wgpu::TextureFormat::Bgra8Unorm, - wgpu::CompositeAlphaMode::PreMultiplied, - wgpu::CompositeAlphaMode::Opaque, - wgpu::PresentMode::Fifo, - true, - ) - }; - let maximum_dimension = device.limits().max_texture_dimension_2d; - let (width, height) = clamped_size(requested.size, maximum_dimension); - let alpha_mode = if requested.transparent { - transparent_alpha_mode - } else { - opaque_alpha_mode - }; - let mut target = Self { - config: wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format, - width, - height, - present_mode, - desired_maximum_frame_latency: 2, - alpha_mode, - view_formats: Vec::new(), - }, - transparent_alpha_mode, - opaque_alpha_mode, - maximum_dimension, - configured: surface.is_some(), - needs_redraw: false, - copy_backend_supported: cfg!(target_os = "windows") && backend == wgpu::Backend::Dx12, - copy_surface_supported, - clear_color: clear_color(requested.transparent), - }; - target.refresh_copy_usage(); - Ok(target) - } - - pub(super) fn configuration(&self) -> &wgpu::SurfaceConfiguration { - &self.config - } - - pub(super) fn resize(&mut self, size: Size) -> bool { - let (width, height) = clamped_size(size, self.maximum_dimension); - if (width, height) == (self.config.width, self.config.height) { - return false; - } - self.config.width = width; - self.config.height = height; - true - } - - /// Returns whether blend pipelines must be rebuilt. - pub(super) fn set_transparent(&mut self, transparent: bool) -> bool { - self.clear_color = clear_color(transparent); - let alpha_mode = if transparent { - self.transparent_alpha_mode - } else { - self.opaque_alpha_mode - }; - if alpha_mode == self.config.alpha_mode { - return false; - } - self.config.alpha_mode = alpha_mode; - self.refresh_copy_usage(); - true - } - - /// Whether the retained frame reaches the swapchain by copy rather than blit. - pub(super) fn retained_copy_enabled(&self) -> bool { - self.copy_backend_supported - && self.copy_surface_supported - // The formats the copy is verified with. - && matches!( - self.config.format, - wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm - ) - && super::retained::alpha_supported(self.config.alpha_mode) - } - - fn refresh_copy_usage(&mut self) { - let enabled = self.retained_copy_enabled(); - self.config - .usage - .set(wgpu::TextureUsages::COPY_DST, enabled); - } - - #[cfg(not(target_family = "wasm"))] - pub(super) fn update_surface_copy_support( - &mut self, - usages: wgpu::TextureUsages, - formats: &[wgpu::TextureFormat], - ) { - self.copy_surface_supported = - usages.contains(wgpu::TextureUsages::COPY_DST) && formats.contains(&self.config.format); - self.refresh_copy_usage(); - } - - #[cfg(all(test, target_os = "windows"))] - pub(super) fn set_format_for_tests(&mut self, format: wgpu::TextureFormat) { - self.config.format = format; - self.refresh_copy_usage(); - } - - #[cfg(not(target_family = "wasm"))] - pub(super) fn apply( - &mut self, - requested: WgpuSurfaceConfig, - capabilities: &wgpu::SurfaceCapabilities, - ) -> anyhow::Result { - // Validate before changing the live target. An unsupported retained alpha - // mode would invalidate configure() and misreport presentation-copy support. - let (transparent, opaque) = select_alpha_modes(&capabilities.alpha_modes) - .ok_or_else(|| anyhow::anyhow!("replacement surface reports no alpha modes"))?; - anyhow::ensure!( - capabilities.formats.contains(&self.config.format), - "replacement surface does not support the existing target format {:?}", - self.config.format - ); - self.transparent_alpha_mode = transparent; - self.opaque_alpha_mode = opaque; - self.update_surface_copy_support(capabilities.usages, &capabilities.formats); - self.resize(requested.size); - self.config.present_mode = select_present_mode( - requested.preferred_present_mode, - &capabilities.present_modes, - self.config.present_mode, - ); - Ok(self.set_transparent(requested.transparent)) - } - - #[cfg(not(target_family = "wasm"))] - pub(super) fn recovery_config(&self) -> WgpuSurfaceConfig { - WgpuSurfaceConfig { - size: self.viewport_size(), - transparent: self.config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, - preferred_present_mode: Some(self.config.present_mode), - } - } - - pub(super) fn viewport_size(&self) -> Size { - Size { - width: DevicePixels(self.config.width as i32), - height: DevicePixels(self.config.height as i32), - } - } - - pub(super) fn format(&self) -> wgpu::TextureFormat { - self.config.format - } - - pub(super) fn width(&self) -> u32 { - self.config.width - } - - pub(super) fn height(&self) -> u32 { - self.config.height - } - - pub(super) fn alpha_mode(&self) -> wgpu::CompositeAlphaMode { - self.config.alpha_mode - } - - pub(super) fn maximum_dimension(&self) -> u32 { - self.maximum_dimension - } - - pub(super) fn clear_color(&self) -> wgpu::Color { - self.clear_color - } - - pub(super) fn is_configured(&self) -> bool { - self.configured - } - - pub(super) fn set_configured(&mut self, configured: bool) { - self.configured = configured; - } - - pub(super) fn request_redraw(&mut self) { - self.needs_redraw = true; - } - - pub(super) fn take_redraw_request(&mut self) -> bool { - std::mem::take(&mut self.needs_redraw) - } -} - -fn select_present_mode( - requested: Option, - supported: &[wgpu::PresentMode], - current: wgpu::PresentMode, -) -> wgpu::PresentMode { - // AutoVsync and AutoNoVsync are valid on every surface even though they - // are intentionally omitted from SurfaceCapabilities::present_modes. - let candidate = requested.unwrap_or(current); - if is_supported(candidate, supported) { - return candidate; - } - // FIFO is guaranteed by the surface API, even if a backend omits it from - // its capability list. - wgpu::PresentMode::Fifo -} - -fn is_supported(mode: wgpu::PresentMode, supported: &[wgpu::PresentMode]) -> bool { - matches!( - mode, - wgpu::PresentMode::AutoVsync | wgpu::PresentMode::AutoNoVsync | wgpu::PresentMode::Fifo - ) || supported.contains(&mode) -} - -fn clamped_size(size: Size, maximum: u32) -> (u32, u32) { - let requested_width = size.width.0.max(1) as u32; - let requested_height = size.height.0.max(1) as u32; - let width = requested_width.min(maximum); - let height = requested_height.min(maximum); - if (width, height) != (requested_width, requested_height) { - log::warn!( - "requested target size ({requested_width}, {requested_height}) exceeds maximum texture dimension {maximum}; clamping to ({width}, {height})" - ); - } - (width, height) -} - -/// The supported alpha modes for transparent and opaque windows, in that order. -fn select_alpha_modes( - supported: &[wgpu::CompositeAlphaMode], -) -> Option<(wgpu::CompositeAlphaMode, wgpu::CompositeAlphaMode)> { - // A non-opaque CAMetalLayer, which WGPU's Metal backend reports as - // PostMultiplied, composites what gpui draws there as it does with Metal. - #[cfg(target_os = "macos")] - const TRANSPARENT: &[wgpu::CompositeAlphaMode] = &[ - wgpu::CompositeAlphaMode::PreMultiplied, - wgpu::CompositeAlphaMode::Inherit, - wgpu::CompositeAlphaMode::PostMultiplied, - ]; - #[cfg(not(target_os = "macos"))] - const TRANSPARENT: &[wgpu::CompositeAlphaMode] = &[ - wgpu::CompositeAlphaMode::PreMultiplied, - wgpu::CompositeAlphaMode::Inherit, - ]; - const OPAQUE: &[wgpu::CompositeAlphaMode] = &[ - wgpu::CompositeAlphaMode::Opaque, - wgpu::CompositeAlphaMode::Inherit, - ]; - let pick = |preferences: &[wgpu::CompositeAlphaMode]| { - preferences - .iter() - .find(|mode| supported.contains(mode)) - .or_else(|| supported.first()) - .copied() - }; - Some((pick(TRANSPARENT)?, pick(OPAQUE)?)) -} - -fn clear_color(transparent: bool) -> wgpu::Color { - if transparent { - return wgpu::Color::TRANSPARENT; - } - #[cfg(target_os = "windows")] - return wgpu::Color::WHITE; - #[cfg(target_os = "macos")] - return wgpu::Color::BLACK; - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - wgpu::Color::TRANSPARENT -} - -#[cfg(test)] -mod tests { - use super::{select_alpha_modes, select_present_mode}; - - #[cfg(not(target_family = "wasm"))] - #[test] - fn replacement_reselects_supported_alpha_and_copy_usage_before_configuration() { - use super::{RenderTarget, WgpuSurfaceConfig}; - use gpui::{DevicePixels, Size}; - use wgpu::{CompositeAlphaMode as Alpha, TextureUsages as Usage}; - let size = Size { - width: DevicePixels(201), - height: DevicePixels(103), - }; - let request = |transparent| WgpuSurfaceConfig { - size, - transparent, - preferred_present_mode: None, - }; - let mut target = RenderTarget { - config: wgpu::SurfaceConfiguration { - usage: Usage::RENDER_ATTACHMENT | Usage::COPY_DST, - format: wgpu::TextureFormat::Bgra8Unorm, - width: 201, - height: 103, - present_mode: wgpu::PresentMode::Fifo, - desired_maximum_frame_latency: 2, - alpha_mode: Alpha::PreMultiplied, - view_formats: Vec::new(), - }, - transparent_alpha_mode: Alpha::PreMultiplied, - opaque_alpha_mode: Alpha::Opaque, - maximum_dimension: 4096, - configured: true, - needs_redraw: false, - copy_backend_supported: true, - copy_surface_supported: true, - clear_color: wgpu::Color::TRANSPARENT, - }; - let mut caps = wgpu::SurfaceCapabilities { - formats: vec![wgpu::TextureFormat::Bgra8Unorm], - present_modes: vec![wgpu::PresentMode::Fifo], - alpha_modes: vec![Alpha::Opaque, Alpha::Inherit], - usages: Usage::RENDER_ATTACHMENT | Usage::COPY_DST, - }; - assert!(target.apply(request(true), &caps).unwrap()); - assert_eq!(target.alpha_mode(), Alpha::Inherit); - assert!(!target.retained_copy_enabled()); - assert!(!target.configuration().usage.contains(Usage::COPY_DST)); - assert!(target.apply(request(false), &caps).unwrap()); - assert_eq!(target.alpha_mode(), Alpha::Opaque); - assert!(target.retained_copy_enabled()); - caps.alpha_modes = vec![Alpha::PreMultiplied, Alpha::Opaque]; - assert!(target.apply(request(true), &caps).unwrap()); - assert_eq!(target.alpha_mode(), Alpha::PreMultiplied); - assert!(target.configuration().usage.contains(Usage::COPY_DST)); - caps.usages = Usage::RENDER_ATTACHMENT; - assert!(!target.apply(request(true), &caps).unwrap()); - assert!(!target.retained_copy_enabled()); - assert!(!target.configuration().usage.contains(Usage::COPY_DST)); - let unchanged = target.configuration().clone(); - caps.alpha_modes.clear(); - assert!(target.apply(request(true), &caps).is_err()); - assert_eq!( - *target.configuration(), - unchanged, - "failed replacement must leave the live configuration intact" - ); - caps.alpha_modes = vec![Alpha::PreMultiplied]; - caps.formats = vec![wgpu::TextureFormat::Rgba8Unorm]; - assert!(target.apply(request(true), &caps).is_err()); - assert_eq!(*target.configuration(), unchanged); - } - - #[cfg(target_os = "macos")] - #[test] - fn metal_transparent_windows_are_not_opaque() { - // What WGPU's Metal backend reports for a CAMetalLayer. - let metal = [ - wgpu::CompositeAlphaMode::Opaque, - wgpu::CompositeAlphaMode::PostMultiplied, - ]; - assert_eq!( - select_alpha_modes(&metal), - Some(( - wgpu::CompositeAlphaMode::PostMultiplied, - wgpu::CompositeAlphaMode::Opaque, - )), - ); - } - - #[test] - fn alpha_modes_follow_preference_then_the_first_supported() { - use wgpu::CompositeAlphaMode::{Inherit, Opaque, PostMultiplied, PreMultiplied}; - - assert_eq!( - select_alpha_modes(&[PostMultiplied, Opaque, PreMultiplied]), - Some((PreMultiplied, Opaque)), - ); - assert_eq!(select_alpha_modes(&[Inherit]), Some((Inherit, Inherit))); - // Neither opaque preference is offered, so opaque windows take the - // first mode the surface supports. - assert_eq!( - select_alpha_modes(&[PostMultiplied]), - Some((PostMultiplied, PostMultiplied)), - ); - } - - #[test] - fn no_alpha_modes_select_nothing() { - assert_eq!(select_alpha_modes(&[]), None); - } - - #[test] - fn unsupported_present_mode_falls_back_to_fifo() { - assert_eq!( - select_present_mode( - Some(wgpu::PresentMode::Immediate), - &[wgpu::PresentMode::Fifo], - wgpu::PresentMode::Fifo, - ), - wgpu::PresentMode::Fifo, - ); - } - - #[test] - fn missing_capabilities_keep_current_mode() { - assert_eq!( - select_present_mode(None, &[], wgpu::PresentMode::Fifo,), - wgpu::PresentMode::Fifo, - ); - } - - #[test] - fn none_preserves_supported_current_mode() { - assert_eq!( - select_present_mode( - None, - &[wgpu::PresentMode::Fifo, wgpu::PresentMode::Mailbox], - wgpu::PresentMode::Mailbox, - ), - wgpu::PresentMode::Mailbox, - ); - } - - #[test] - fn none_falls_back_when_replacement_surface_drops_current_mode() { - assert_eq!( - select_present_mode(None, &[wgpu::PresentMode::Fifo], wgpu::PresentMode::Mailbox,), - wgpu::PresentMode::Fifo, - ); - } - - #[test] - fn none_preserves_unlisted_auto_current_mode() { - assert_eq!( - select_present_mode( - None, - &[wgpu::PresentMode::Fifo], - wgpu::PresentMode::AutoVsync, - ), - wgpu::PresentMode::AutoVsync, - ); - } - - #[test] - fn auto_modes_are_valid_without_capability_listing() { - assert_eq!( - select_present_mode( - Some(wgpu::PresentMode::AutoNoVsync), - &[wgpu::PresentMode::Fifo], - wgpu::PresentMode::Fifo, - ), - wgpu::PresentMode::AutoNoVsync, - ); - } -} diff --git a/vendor/gpui-wgpu/tests/headless_primitives.rs b/vendor/gpui-wgpu/tests/headless_primitives.rs deleted file mode 100644 index d4909b179..000000000 --- a/vendor/gpui-wgpu/tests/headless_primitives.rs +++ /dev/null @@ -1,642 +0,0 @@ -//! Renders one of every primitive kind through the headless WGPU renderer and checks -//! that each one actually lands on the target. This is the cross-platform smoke test for -//! the shared render plan, instance transport, and generated shaders. -#![cfg(feature = "test-support")] - -use gpui::{ - AtlasKey, AtlasTile, BackdropFilter, BorderStyle, Bounds, ColorSpace, ContentMask, Corners, - DevicePixels, Edges, Hsla, MonochromeSprite, PlatformHeadlessRenderer, Point, PolychromeSprite, - Quad, RenderImageParams, RenderSvgParams, ScaledFilter, ScaledPixels, Scene, ShaderBool, - Shadow, Size, Underline, checkerboard, linear_color_stop, linear_gradient, solid_background, -}; -use gpui_ce_wgpu::WgpuHeadlessRenderer; -use smallvec::smallvec; -use std::borrow::Cow; - -const TARGET: Size = Size { - width: DevicePixels(310), - height: DevicePixels(100), -}; - -#[test] -fn filled_path_resolves_without_invalidating_the_frame() { - let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds: bounds(0.0, 0.0, 200.0, 100.0), - content_mask: full_mask(), - background: solid_background(gpui::black()), - ..Default::default() - }); - let mut builder = gpui::PathBuilder::fill(); - builder.move_to(gpui::point(gpui::px(20.0), gpui::px(20.0))); - builder.line_to(gpui::point(gpui::px(80.0), gpui::px(20.0))); - builder.line_to(gpui::point(gpui::px(50.0), gpui::px(80.0))); - builder.close(); - let mut path = builder.build().expect("triangle").scale(1.0); - path.content_mask = full_mask(); - path.color = solid_background(gpui::white()); - scene.insert_primitive(path); - scene.finish(); - - // The path uses an MSAA attachment when supported. Storing that transient - // attachment invalidates the command buffer, including the background quad. - let image = renderer - .render_scene_to_image(&scene, TARGET) - .expect("valid path frame"); - assert_eq!(image.get_pixel(50, 40).0, [255, 255, 255, 255]); - assert_eq!(image.get_pixel(150, 40).0, [0, 0, 0, 255]); -} - -fn bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds { - Bounds { - origin: Point { - x: ScaledPixels(x), - y: ScaledPixels(y), - }, - size: Size { - width: ScaledPixels(w), - height: ScaledPixels(h), - }, - } -} - -fn full_mask() -> ContentMask { - ContentMask { - bounds: bounds(0.0, 0.0, 310.0, 100.0), - ..Default::default() - } -} - -fn mask(width: f32, height: f32) -> ContentMask { - ContentMask { - bounds: bounds(0.0, 0.0, width, height), - ..Default::default() - } -} - -fn tile(renderer: &WgpuHeadlessRenderer, key: AtlasKey, bytes: Vec) -> AtlasTile { - renderer - .sprite_atlas() - .get_or_insert_with(&key, &mut || { - Ok(Some(( - Size { - width: DevicePixels(8), - height: DevicePixels(8), - }, - Cow::Owned(bytes.clone()), - ))) - }) - .expect("atlas insert must succeed") - .expect("atlas insert must produce a tile") -} - -#[test] -fn every_primitive_kind_renders() { - let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); - let mono = tile( - &renderer, - AtlasKey::Svg(RenderSvgParams { - path: "test-mono".into(), - size: Size { - width: DevicePixels(8), - height: DevicePixels(8), - }, - }), - vec![255; 64], - ); - let poly = tile( - &renderer, - AtlasKey::Image(RenderImageParams { - image_id: gpui::ImageId(1), - frame_index: 0, - }), - // BGRA red, opaque. - (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), - ); - - let mut scene = Scene::default(); - let green: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x00ff00)); - let red: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xff0000)); - let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); - let blue: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x0000ff)); - - // 1. Solid quad. - let quad_bounds = bounds(10.0, 10.0, 30.0, 30.0); - scene.insert_primitive(Quad { - order: 0, - bounds: quad_bounds, - content_mask: full_mask(), - background: solid_background(green), - ..Default::default() - }); - // 2. Bordered quad (white border, transparent fill). - let bordered = bounds(50.0, 10.0, 30.0, 30.0); - scene.insert_primitive(Quad { - order: 0, - bounds: bordered, - content_mask: full_mask(), - border_color: white.into(), - border_widths: gpui::Edges::all(ScaledPixels(4.0)), - ..Default::default() - }); - // 3. Shadow (blue, no blur so it is a solid block). - let shadow_bounds = bounds(90.0, 10.0, 30.0, 30.0); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(0.0), - bounds: shadow_bounds, - corner_radii: Default::default(), - content_mask: full_mask(), - color: blue.into(), - element_bounds: shadow_bounds, - element_corner_radii: Default::default(), - inset: ShaderBool::Disabled, - corner_smoothing: 0.0, - }); - // 4. Underline (white, solid). - let underline_bounds = bounds(130.0, 20.0, 30.0, 4.0); - scene.insert_primitive(Underline { - order: 0, - padding: 0, - bounds: underline_bounds, - content_mask: full_mask(), - color: white.into(), - thickness: ScaledPixels(4.0), - wavy: ShaderBool::Disabled, - }); - // 5. Monochrome sprite (white coverage tile tinted green). - let mono_bounds = bounds(10.0, 60.0, 30.0, 30.0); - scene.insert_primitive(MonochromeSprite { - order: 0, - padding: 0, - bounds: mono_bounds, - content_mask: full_mask(), - color: green.into(), - tile: mono, - transformation: Default::default(), - }); - // 6. Polychrome sprite (red image). - let poly_bounds = bounds(50.0, 60.0, 30.0, 30.0); - scene.insert_primitive(PolychromeSprite { - order: 0, - grayscale: ShaderBool::Disabled, - opacity: 1.0, - corner_smoothing: 0.0, - bounds: poly_bounds, - content_mask: full_mask(), - corner_radii: Default::default(), - tile: poly, - }); - // 7. A second quad batch. Overlapping the image pushes this quad above it in draw - // order, so it starts past the first quads in the frame's quad buffer: a backend - // that loses the batch base draws the wrong quads here. - let late_quad = bounds(60.0, 70.0, 30.0, 30.0); - scene.insert_primitive(Quad { - order: 0, - bounds: late_quad, - content_mask: full_mask(), - background: solid_background(blue), - ..Default::default() - }); - // 8–9. Gradient drop and inset shadows. The inset gradient stays in the element's paint - // coordinate space while its offset hole is smaller. - let gradient = linear_gradient( - 90.0, - linear_color_stop(red, 0.0), - linear_color_stop(blue, 1.0), - ) - .color_space(ColorSpace::Srgb); - let gradient_drop_bounds = bounds(210.0, 10.0, 40.0, 40.0); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(0.0), - bounds: gradient_drop_bounds, - corner_radii: Default::default(), - content_mask: full_mask(), - color: gradient, - element_bounds: gradient_drop_bounds, - element_corner_radii: Default::default(), - inset: ShaderBool::Disabled, - corner_smoothing: 0.0, - }); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(0.0), - bounds: bounds(266.0, 16.0, 28.0, 28.0), - corner_radii: Default::default(), - content_mask: full_mask(), - color: gradient, - element_bounds: bounds(260.0, 10.0, 40.0, 40.0), - element_corner_radii: Default::default(), - inset: ShaderBool::Enabled, - corner_smoothing: 0.0, - }); - scene.finish(); - let quad_batches: Vec<_> = scene - .render_commands() - .iter() - .filter_map(|command| match command { - gpui::RenderCommand::Batch(gpui::PrimitiveBatch::Quads { range, .. }) => { - Some(range.clone()) - } - _ => None, - }) - .collect(); - assert_eq!( - quad_batches, - vec![0..2, 2..3], - "the scene must split quads across batches" - ); - - let image = renderer - .render_scene_to_image(&scene, TARGET) - .expect("render must succeed"); - let px = |x: u32, y: u32| { - let p = image.get_pixel(x, y).0; - (p[0], p[1], p[2], p[3]) - }; - let mut failures = Vec::new(); - let mut check = |name: &str, x: u32, y: u32, expected: (u8, u8, u8)| { - let (r, g, b, _) = px(x, y); - let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 8; - if !(close(r, expected.0) && close(g, expected.1) && close(b, expected.2)) { - failures.push(format!( - "{name} at ({x},{y}): got ({r},{g},{b}) expected {expected:?}" - )); - } - }; - check("solid quad", 25, 25, (0, 255, 0)); - check("bordered quad border", 52, 25, (255, 255, 255)); - check("bordered quad interior", 65, 25, (0, 0, 0)); - check("shadow", 105, 25, (0, 0, 255)); - check("underline", 145, 22, (255, 255, 255)); - check("monochrome sprite", 25, 75, (0, 255, 0)); - check("polychrome sprite", 55, 65, (255, 0, 0)); - check("second quad batch", 85, 85, (0, 0, 255)); - check("background", 190, 90, (0, 0, 0)); - check("gradient inset shadow hole", 280, 30, (0, 0, 0)); - let mut check_dominant = |name: &str, x: u32, y: u32, red_dominant: bool| { - let (r, g, b, _) = px(x, y); - let matches = if red_dominant { - r > 180 && g < 80 && b < 80 - } else { - b > 180 && r < 80 && g < 80 - }; - if !matches { - failures.push(format!( - "{name} at ({x},{y}): got ({r},{g},{b}), expected {} dominance", - if red_dominant { "red" } else { "blue" } - )); - } - }; - check_dominant("gradient drop shadow left edge", 212, 30, true); - check_dominant("gradient drop shadow right edge", 247, 30, false); - check_dominant("gradient inset shadow left edge", 262, 30, true); - check_dominant("gradient inset shadow right edge", 297, 30, false); - if !failures.is_empty() { - let path = std::env::temp_dir().join("gpui_headless_primitives.png"); - image.save(&path).ok(); - panic!( - "{}\n(image saved to {})", - failures.join("\n"), - path.display() - ); - } -} - -#[test] -fn smoothed_primitives_share_one_contour() { - let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); - let image_tile = tile( - &renderer, - AtlasKey::Image(RenderImageParams { - image_id: gpui::ImageId(2), - frame_index: 0, - }), - (0..64).flat_map(|_| [0u8, 0, 255, 255]).collect(), - ); - let target = Size { - width: DevicePixels(360), - height: DevicePixels(170), - }; - let content_mask = mask(360.0, 170.0); - let green: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x19d36b)); - let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); - let blue: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x287cff)); - let black: Hsla = gpui::rgb_to_hsla(gpui::rgb(0x000000)); - let radii = Corners::all(ScaledPixels(18.0)); - - let mut scene = Scene::default(); - - let fill_bounds = bounds(10.0, 10.0, 50.0, 50.0); - scene.insert_primitive(Quad { - bounds: fill_bounds, - content_mask, - background: solid_background(green), - corner_radii: radii, - corner_smoothing: 1.0, - ..Default::default() - }); - - let border_bounds = bounds(72.0, 10.0, 50.0, 50.0); - scene.insert_primitive(Quad { - bounds: border_bounds, - content_mask, - border_color: white.into(), - border_widths: Edges { - top: ScaledPixels(2.0), - right: ScaledPixels(5.0), - bottom: ScaledPixels(8.0), - left: ScaledPixels(3.0), - }, - corner_radii: radii, - corner_smoothing: 0.6, - ..Default::default() - }); - - let dashed_bounds = bounds(134.0, 10.0, 58.0, 50.0); - scene.insert_primitive(Quad { - bounds: dashed_bounds, - content_mask, - border_style: BorderStyle::Dashed, - border_color: white.into(), - border_widths: Edges::all(ScaledPixels(3.0)), - corner_radii: Corners { - top_left: ScaledPixels(20.0), - top_right: ScaledPixels(8.0), - bottom_right: ScaledPixels(16.0), - bottom_left: ScaledPixels(3.0), - }, - corner_smoothing: 1.0, - ..Default::default() - }); - - let image_bounds = bounds(204.0, 10.0, 50.0, 50.0); - scene.insert_primitive(PolychromeSprite { - order: 0, - grayscale: ShaderBool::Disabled, - opacity: 1.0, - corner_smoothing: 1.0, - bounds: image_bounds, - content_mask, - corner_radii: radii, - tile: image_tile, - }); - - let drop_element = bounds(20.0, 96.0, 50.0, 44.0); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(5.0), - bounds: bounds(25.0, 101.0, 50.0, 44.0), - corner_radii: Corners::all(ScaledPixels(15.0)), - content_mask, - color: blue.into(), - element_bounds: drop_element, - element_corner_radii: Corners::all(ScaledPixels(15.0)), - inset: ShaderBool::Disabled, - corner_smoothing: 0.6, - }); - scene.insert_primitive(Quad { - bounds: drop_element, - content_mask, - background: solid_background(green), - corner_radii: Corners::all(ScaledPixels(15.0)), - corner_smoothing: 0.6, - ..Default::default() - }); - - let inset_bounds = bounds(100.0, 94.0, 50.0, 48.0); - scene.insert_primitive(Quad { - bounds: inset_bounds, - content_mask, - background: solid_background(green), - corner_radii: Corners::all(ScaledPixels(16.0)), - corner_smoothing: 1.0, - ..Default::default() - }); - scene.insert_primitive(Shadow { - order: 0, - blur_radius: ScaledPixels(4.0), - bounds: bounds(104.0, 98.0, 42.0, 40.0), - corner_radii: Corners::all(ScaledPixels(12.0)), - content_mask, - color: black.into(), - element_bounds: inset_bounds, - element_corner_radii: Corners::all(ScaledPixels(16.0)), - inset: ShaderBool::Enabled, - corner_smoothing: 1.0, - }); - - let filter_bounds = bounds(190.0, 92.0, 70.0, 52.0); - let backdrop = Quad { - bounds: filter_bounds, - content_mask, - background: checkerboard(white, 2.0), - ..Default::default() - }; - scene.insert_primitive(backdrop); - scene.insert_primitive(BackdropFilter { - order: 0, - bounds: filter_bounds, - content_mask, - corner_radii: Corners::all(ScaledPixels(18.0)), - corner_smoothing: 1.0, - filters: smallvec![ScaledFilter::Blur(ScaledPixels(5.0))], - opacity: 1.0, - }); - - scene.finish(); - let image = renderer - .render_scene_to_image(&scene, target) - .expect("render must succeed"); - let mut unfiltered_scene = Scene::default(); - unfiltered_scene.insert_primitive(backdrop); - unfiltered_scene.finish(); - let unfiltered = renderer - .render_scene_to_image(&unfiltered_scene, target) - .expect("reference render must succeed"); - let pixel = |x: u32, y: u32| image.get_pixel(x, y).0; - let reference_pixel = |x: u32, y: u32| unfiltered.get_pixel(x, y).0; - let is_black = |x, y| pixel(x, y)[0..3].iter().all(|channel| *channel <= 12); - let is_white = |x, y| pixel(x, y)[0..3].iter().all(|channel| *channel >= 235); - - for (name, left, center, channel, minimum) in - [("fill", 10, 35, 1, 180), ("image", 204, 229, 0, 220)] - { - assert!( - is_black(left, 10), - "smoothed {name} must exclude its corner" - ); - assert!( - pixel(center, 10)[channel] > minimum, - "{name} shoulder must reach the top edge", - ); - assert!(pixel(center, 35)[channel] > minimum, "{name} center"); - } - - for (side, outer, inner) in [ - ("top", (96, 10), (96, 14)), - ("right", (120, 35), (115, 35)), - ("bottom", (96, 56), (96, 50)), - ] { - assert!(is_white(outer.0, outer.1), "{side} border"); - assert!( - is_black(inner.0, inner.1), - "{side} border interior boundary" - ); - } - - let dashed_pixels = (8..62) - .flat_map(|y| (132..194).map(move |x| (x, y))) - .filter(|&(x, y)| is_white(x, y)) - .count(); - assert!( - (150..420).contains(&dashed_pixels), - "dashed contour coverage: {dashed_pixels}" - ); - assert!(is_black(163, 35), "dashed border interior"); - - assert!( - pixel(73, 132)[2] > 35, - "drop shadow must extend beyond the element" - ); - assert!( - pixel(125, 96)[1] < pixel(125, 118)[1], - "inset shadow must darken the edge" - ); - assert!( - pixel(125, 118)[1] > 120, - "inset shadow must preserve the center" - ); - - let blurred_midtones = (192..258) - .flat_map(|x| (94..142).map(move |y| pixel(x, y)[0])) - .filter(|channel| (30..225).contains(channel)) - .count(); - assert!( - blurred_midtones > 800, - "backdrop blur did not composite: {blurred_midtones}" - ); - assert!( - pixel(190, 92) - .iter() - .zip(reference_pixel(190, 92)) - .all(|(&actual, expected)| actual.abs_diff(expected) <= 1), - "the smoothed mask must exclude the backdrop corner" - ); - assert!( - pixel(225, 92)[0].abs_diff(reference_pixel(225, 92)[0]) > 20, - "the smoothed mask must include the top shoulder" - ); - - if std::env::var_os("GPUI_SAVE_HEADLESS_TESTS").is_some() { - image - .save(std::env::temp_dir().join("gpui_smoothed_primitives.png")) - .expect("save diagnostic image"); - } -} - -#[test] -fn smoothed_fill_and_border_trace_the_same_contour() { - // Fills may take the superellipse shortcut while bordered quads always trace the Figma - // contour. Both must land on one edge, or a fill pulls away from its own border. - let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); - let target = Size { - width: DevicePixels(320), - height: DevicePixels(320), - }; - let content_mask = mask(320.0, 320.0); - let white: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xffffff)); - let element = bounds(10.0, 10.0, 300.0, 300.0); - let center = 160.0; - - let mut edge_distances = |border_widths: Edges| -> Vec { - let mut scene = Scene::default(); - scene.insert_primitive(Quad { - bounds: element, - content_mask, - background: solid_background(white), - border_color: white.into(), - border_widths, - corner_radii: Corners::all(ScaledPixels(100.0)), - corner_smoothing: 0.45, - ..Default::default() - }); - scene.finish(); - let image = renderer - .render_scene_to_image(&scene, target) - .expect("render must succeed"); - - // Coverage summed along a ray from the center locates the edge on that ray. - (0..=90) - .map(|degrees| { - let angle = (180.0 + degrees as f32).to_radians(); - let start = 140.0; - start - + (0..40 * 16) - .map(|step| { - let distance = start + step as f32 / 16.0; - let x = center + angle.cos() * distance; - let y = center + angle.sin() * distance; - image.get_pixel(x as u32, y as u32).0[3] as f32 / 255.0 / 16.0 - }) - .sum::() - }) - .collect() - }; - - let fill = edge_distances(Edges::default()); - let bordered = edge_distances(Edges::all(ScaledPixels(1.0))); - for (degrees, (fill, bordered)) in fill.iter().zip(&bordered).enumerate() { - assert!( - (fill - bordered).abs() <= 0.35, - "{degrees} degrees past the left edge, the fill edge is {:.2}px inside the border", - bordered - fill - ); - } -} - -#[test] -fn content_mask_fades_quad_edges() { - let mut renderer = WgpuHeadlessRenderer::new().expect("headless renderer"); - let mut scene = Scene::default(); - let red: Hsla = gpui::rgb_to_hsla(gpui::rgb(0xff0000)); - scene.insert_primitive(Quad { - order: 0, - bounds: bounds(0.0, 0.0, 310.0, 100.0), - content_mask: ContentMask { - bounds: bounds(0.0, 0.0, 100.0, 100.0), - fade_out: Edges { - top: ScaledPixels(10.0), - right: ScaledPixels(0.0), - bottom: ScaledPixels(0.0), - left: ScaledPixels(20.0), - }, - }, - background: solid_background(red), - ..Default::default() - }); - scene.finish(); - let image = renderer - .render_scene_to_image(&scene, TARGET) - .expect("render must succeed"); - let pixel = |x: u32, y: u32| image.get_pixel(x, y).0; - let close = |x: u32, y: u32, red: u8| { - let actual = pixel(x, y)[0]; - assert!( - actual.abs_diff(red) <= 3, - "at ({x},{y}) got {actual}, expected {red}" - ); - }; - - // Fully inside both fades. - close(60, 50, 255); - // The ramp is evaluated at pixel centers (x + 0.5), so 10.5px into the - // 20px left fade is 52.5% coverage. - close(10, 50, 134); - // 2.5px into the 10px top fade is 25% coverage. - close(60, 2, 64); - // The unfaded right edge still clips hard at the mask boundary. - assert_eq!(pixel(101, 50)[0], 0); -} From c55d94a43d87a1373a49a4c0402cc8c4ce120a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 14:39:47 +0300 Subject: [PATCH 40/67] Adopt completed Git probes while native windows initialize Transfer deferred repository bootstrap exactly once between the probe worker and the attaching view. Queue runtime adoption before repository opens, keep the worker's ownership weak, reject stale results, and suppress duplicate recovery reloads when state publications coalesce. The clean Windows DX12 trace deferred bootstrap at 89.2 ms and finished Git discovery at 96.0 ms, but UI adoption waited until 434.1 ms. Five pinned-baseline launches reached first submission at median 538.0 ms and usable repository state at 831.9 ms; this change removes the handoff serialization, with end-to-end remeasurement following the renderer investigation. Add six regressions for early completion, attachment wins, stale results, dropped windows, independent windows and runtime recovery. Strengthen failed-open, entry-gate and empty-window coverage. Scope the existing synthetic-repository diagnostic assertion to its acknowledged entry. Validation: 168 targeted tests passed, rustfmt and git diff --check passed. --- .../src/view/gitcomet_view.rs | 10 +- .../src/view/mod_helpers/mod.rs | 1 + .../gitcomet-ui-gpui/src/view/state_apply.rs | 10 +- .../src/view/window_bootstrap.rs | 470 +++++++++++++++++- 4 files changed, 478 insertions(+), 13 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs index 4ce817e06..9b11dba31 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs @@ -494,11 +494,12 @@ impl GitCometView { pub(crate) fn new_with_bootstrap( store: AppStore, events: smol::channel::Receiver, - bootstrap: super::window_bootstrap::PreparedWindowBootstrap, + mut bootstrap: super::window_bootstrap::PreparedWindowBootstrap, window: &mut Window, cx: &mut gpui::Context, ) -> Self { let _stage = gitcomet_core::startup_trace::span("view_init"); + let mut startup_repo_runtime_adoption = bootstrap.attach_git_probe_handoff(); let super::window_bootstrap::PreparedWindowBootstrap { config, ui_session, @@ -514,6 +515,7 @@ impl GitCometView { deferred_repo_bootstrap, pending_repo_open_reservations, pending_repo_open_active, + .. } = bootstrap; let GitCometViewConfig { initial_path: _, @@ -593,6 +595,11 @@ impl GitCometView { let check_for_updates_on_startup = ui_preferences.security.check_for_updates_on_startup; let commit_push_after_enabled = ui_preferences.repository.commit_push_after_enabled; let initial_state = store.snapshot(); + // The worker queued Git adoption before the restore. If it is already + // reflected here, no later snapshot needs to suppress that transition. + if startup_repo_runtime_adoption.as_ref() == Some(&initial_state.git_runtime) { + startup_repo_runtime_adoption = None; + } let ui_model = cx.new(|cx| { AppUiModel::new_with_preferences( Arc::clone(&initial_state), @@ -1207,6 +1214,7 @@ impl GitCometView { focused_mergetool_bootstrap, submodule_diff_bootstrap: None, deferred_repo_bootstrap, + startup_repo_runtime_adoption, pending_repo_open_reservations, pending_repo_open_active, startup_repo_bootstrap_pending, diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs index 247c68fb5..dc3ce9337 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers/mod.rs @@ -995,6 +995,7 @@ pub struct GitCometView { pub(super) focused_mergetool_bootstrap: Option, pub(super) submodule_diff_bootstrap: Option, pub(super) deferred_repo_bootstrap: Option, + pub(super) startup_repo_runtime_adoption: Option, pub(super) pending_repo_open_reservations: FxHashMap, pub(super) pending_repo_open_active: Option, pub(super) startup_repo_bootstrap_pending: bool, diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index ac05ba860..941dbe6c2 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -707,7 +707,15 @@ impl GitCometView { } self.sync_terminal_sessions_with_state(cx); self.sync_reflog_panels_with_state(); - if !prev_git_runtime_available && self.state.git_runtime.is_available() { + let bootstrap_adopted_runtime = git_runtime_changed + && self + .startup_repo_runtime_adoption + .take() + .is_some_and(|runtime| runtime == self.state.git_runtime); + if !prev_git_runtime_available + && self.state.git_runtime.is_available() + && !bootstrap_adopted_runtime + { self.resume_after_git_runtime_recovery(); } if git_runtime_changed { diff --git a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs index 456f6facd..9b1bf27be 100644 --- a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs +++ b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs @@ -1,5 +1,58 @@ //! Prepare repository work before native window/renderer initialization. use super::*; +use gitcomet_core::process::GitRuntimeState; +use std::sync::{Mutex, Weak}; + +enum GitBootstrapState { + Waiting(DeferredRepoBootstrap), + Dispatched(GitRuntimeState), + Attached, +} + +/// The worker can consume a restore only until the native window attaches. +/// Queueing both messages under this lock makes that ownership transfer exact, +/// even if the store has not reduced either message when the view is created. +struct GitBootstrapHandoff { + store: AppStore, + expected_runtime: GitRuntimeState, + state: Mutex, +} + +impl GitBootstrapHandoff { + fn complete(&self, runtime: GitRuntimeState, current_runtime: &GitRuntimeState) { + let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); + if !matches!(*state, GitBootstrapState::Waiting(_)) + || !runtime.is_available() + || runtime != *current_runtime + || runtime.preference != self.expected_runtime.preference + || self.store.snapshot().git_runtime != self.expected_runtime + { + return; + } + let GitBootstrapState::Waiting(bootstrap) = + std::mem::replace(&mut *state, GitBootstrapState::Dispatched(runtime.clone())) + else { + unreachable!(); + }; + self.store.dispatch(Msg::SetGitRuntimeState(runtime)); + match bootstrap { + DeferredRepoBootstrap::RestoreSession { + open_repos, + active_repo, + } => self.store.dispatch(Msg::RestoreSession { + open_repos, + active_repo, + }), + DeferredRepoBootstrap::OpenRepos(paths) => { + for path in paths { + self.store.dispatch(Msg::OpenRepo(path)); + } + } + } + drop(state); + gitcomet_core::startup_trace::mark("repository_bootstrap_dispatched_after_git_probe"); + } +} /// Owns the bootstrap decision until the view attaches. An empty store snapshot /// does not mean a second restore is needed: the first may still be queued. @@ -18,11 +71,55 @@ pub(crate) struct PreparedWindowBootstrap { pub(super) deferred_repo_bootstrap: Option, pub(super) pending_repo_open_reservations: FxHashMap, pub(super) pending_repo_open_active: Option, + git_probe_handoff: Option>, } impl PreparedWindowBootstrap { pub(crate) fn new(store: &AppStore, config: GitCometViewConfig, cx: &mut App) -> Self { - Self::from_session(store, config, session::load(), cx) + let mut bootstrap = Self::from_session(store, config, session::load(), cx); + if !cfg!(test) + && let Some(handoff) = bootstrap.begin_git_probe_handoff(store) + { + cx.background_executor() + .spawn(async move { + // Wait only for discovery already started by the browser. + // Missing Git and abandoned probes keep the usual UI recovery. + let runtime = + gitcomet_core::process::wait_for_git_runtime_probe(Duration::from_secs(10)); + if let Some(runtime) = runtime + && let Some(handoff) = handoff.upgrade() + { + handoff.complete(runtime, &gitcomet_core::process::current_git_runtime()); + } + }) + .detach(); + } + bootstrap + } + + fn begin_git_probe_handoff(&mut self, store: &AppStore) -> Option> { + let bootstrap = self.deferred_repo_bootstrap.take()?; + let handoff = Arc::new(GitBootstrapHandoff { + store: store.clone(), + expected_runtime: store.snapshot().git_runtime.clone(), + state: Mutex::new(GitBootstrapState::Waiting(bootstrap)), + }); + let worker = Arc::downgrade(&handoff); + self.git_probe_handoff = Some(handoff); + Some(worker) + } + + pub(super) fn attach_git_probe_handoff(&mut self) -> Option { + let handoff = self.git_probe_handoff.take()?; + let mut state = handoff.state.lock().unwrap_or_else(|err| err.into_inner()); + match std::mem::replace(&mut *state, GitBootstrapState::Attached) { + GitBootstrapState::Waiting(bootstrap) => { + self.deferred_repo_bootstrap = Some(bootstrap); + None + } + GitBootstrapState::Dispatched(runtime) => Some(runtime), + GitBootstrapState::Attached => None, + } } fn from_session( @@ -240,6 +337,7 @@ impl PreparedWindowBootstrap { deferred_repo_bootstrap, pending_repo_open_reservations, pending_repo_open_active, + git_probe_handoff: None, } } } @@ -349,6 +447,329 @@ mod tests { .expect("feedback acknowledgments are reduced"); } + #[gpui::test] + fn git_probe_handoff_loads_before_attachment_and_dispatches_only_once( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(false, false); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_path_buf(); + let mut bootstrap = prepare( + &store, + restored_config(vec![path.clone()]), + session::UiSession::default(), + cx, + ); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + // Run completion on a worker without advancing the UI executor. This is + // the interval in which the real main thread is initializing DX12. + std::thread::scope(|scope| { + scope.spawn(|| handoff.complete(runtime.clone(), &runtime)); + }); + wait_for_state(&store, |state| { + state.repos.first().is_some_and(|repo| { + repo.spec.workdir == path && matches!(repo.open, Loadable::Ready(())) + }) + }); + let repo_id = store.snapshot().active_repo; + handoff.complete(runtime.clone(), &runtime); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + cx.update(|_, app| { + let view = view.read(app); + assert_eq!(view.state.active_repo, repo_id); + assert!(view.deferred_repo_bootstrap.is_none()); + assert!(view.startup_repo_runtime_adoption.is_none()); + }); + handoff.complete(runtime.clone(), &runtime); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn git_probe_handoff_attachment_wins_and_late_completion_leaves_recovery_to_view( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(false, true); + let path = PathBuf::from("/repos/late-bootstrap-probe"); + let mut bootstrap = prepare( + &store, + restored_config(vec![path.clone()]), + session::UiSession::default(), + cx, + ); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + let runtime = AppState::test_default().git_runtime; + handoff.complete(runtime.clone(), &runtime); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert!(!store_for_assert.snapshot().git_runtime.is_available()); + assert_eq!(opens.load(Ordering::SeqCst), 0); + store_for_assert.dispatch(Msg::SetGitRuntimeState(runtime)); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + cx.update(|_, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(view.deferred_repo_bootstrap.is_none()); + }); + }); + wait_for_state(&store_for_assert, |state| { + state.repo_open_failure_revision != 0 + }); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn git_probe_handoff_rejects_missing_or_stale_results_and_keeps_restore( + cx: &mut gpui::TestAppContext, + ) { + let (store, _events, opens) = store(false, false); + let path = PathBuf::from("/repos/bootstrap-stale-git"); + let mut bootstrap = prepare( + &store, + restored_config(vec![path]), + session::UiSession::default(), + cx, + ); + let deferred = bootstrap.deferred_repo_bootstrap.clone(); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + let mut missing = runtime.clone(); + missing.availability = GitExecutableAvailability::Unavailable { + detail: "Git is unavailable".into(), + }; + handoff.complete(missing.clone(), &missing); + handoff.complete(runtime.clone(), &missing); + let mut another = runtime.clone(); + another.preference = GitExecutablePreference::Custom(PathBuf::from("/other/git")); + handoff.complete(another.clone(), &another); + // An already-reported store change must not be overwritten either. + store.dispatch(Msg::SetGitRuntimeState(another.clone())); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + handoff.complete(runtime.clone(), &runtime); + assert!(bootstrap.attach_git_probe_handoff().is_none()); + assert_eq!(bootstrap.deferred_repo_bootstrap, deferred); + assert_eq!(store.snapshot().git_runtime, another); + shutdown(&store); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + fn dropping_prepared_window_releases_git_probe_handoff(cx: &mut gpui::TestAppContext) { + let opens = Arc::new(AtomicUsize::new(0)); + let backend = Arc::new(RecordingBackend { + opens: opens.clone(), + fail: false, + }); + let backend_lifetime = Arc::downgrade(&backend); + let (store, _events) = AppStore::new_test(backend); + store.disable_repo_monitors_for_test(); + let mut state = (*store.snapshot()).clone(); + state.git_runtime.availability = GitExecutableAvailability::Checking; + store.replace_snapshot_for_test(Arc::new(state)); + let mut bootstrap = prepare( + &store, + restored_config(vec![PathBuf::from("/repos/abandoned-bootstrap")]), + session::UiSession::default(), + cx, + ); + let worker = bootstrap.begin_git_probe_handoff(&store).unwrap(); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .expect("store initialization and bootstrap settings are reduced"); + drop(store); + assert!(worker.upgrade().is_some()); + drop(bootstrap); + assert!(worker.upgrade().is_none()); + let deadline = Instant::now() + Duration::from_secs(3); + while backend_lifetime.upgrade().is_some() { + assert!( + Instant::now() < deadline, + "an abandoned handoff must release the store and its workers" + ); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + fn independent_windows_adopt_one_probe_for_session_and_focused_open( + cx: &mut gpui::TestAppContext, + ) { + let (session_store, _session_events, session_opens) = store(false, false); + let (focused_store, _focused_events, focused_opens) = store(false, false); + let session_directory = tempfile::tempdir().unwrap(); + let focused_directory = tempfile::tempdir().unwrap(); + let mut session_bootstrap = prepare( + &session_store, + restored_config(vec![session_directory.path().to_path_buf()]), + session::UiSession::default(), + cx, + ); + let mut focused_bootstrap = prepare( + &focused_store, + GitCometViewConfig { + initial_path: Some(focused_directory.path().to_path_buf()), + view_mode: GitCometViewMode::FocusedMergetool, + ..Default::default() + }, + session::UiSession { + open_repos: vec![session_directory.path().to_path_buf()], + ..Default::default() + }, + cx, + ); + let session_handoff = session_bootstrap + .begin_git_probe_handoff(&session_store) + .unwrap() + .upgrade() + .unwrap(); + let focused_handoff = focused_bootstrap + .begin_git_probe_handoff(&focused_store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + std::thread::scope(|scope| { + scope.spawn(|| session_handoff.complete(runtime.clone(), &runtime)); + scope.spawn(|| focused_handoff.complete(runtime.clone(), &runtime)); + }); + for store in [&session_store, &focused_store] { + wait_for_state(store, |state| { + state + .repos + .first() + .is_some_and(|repo| matches!(repo.open, Loadable::Ready(()))) + }); + } + assert_eq!( + session_bootstrap.attach_git_probe_handoff(), + Some(runtime.clone()) + ); + assert_eq!(focused_bootstrap.attach_git_probe_handoff(), Some(runtime)); + assert_eq!(session_store.snapshot().repos.len(), 1); + assert_eq!(focused_store.snapshot().repos.len(), 1); + assert_eq!( + focused_store.snapshot().repos[0].spec.workdir, + focused_directory.path(), + "a focused tool must not restore the browser's session tabs" + ); + shutdown(&session_store); + shutdown(&focused_store); + assert_eq!(session_opens.load(Ordering::SeqCst), 1); + assert_eq!(focused_opens.load(Ordering::SeqCst), 1); + } + + #[gpui::test] + fn coalesced_bootstrap_runtime_does_not_reload_but_later_git_recovery_does( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, _) = store(false, false); + let directory = tempfile::tempdir().unwrap(); + let mut bootstrap = prepare( + &store, + restored_config(vec![directory.path().to_path_buf()]), + session::UiSession::default(), + cx, + ); + let initial = store.snapshot(); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + handoff.complete(runtime.clone(), &runtime); + wait_for_state(&store, |state| { + state.repos.first().is_some_and(|repo| { + matches!(repo.open, Loadable::Ready(())) && !repo.loads_in_flight.any_in_flight() + }) + }); + let mut loaded = (*store.snapshot()).clone(); + let marker = gitcomet_state::model::ComparisonMark { + commit_id: CommitId("bootstrap-retained-mark".into()), + label: "retained mark".into(), + }; + loaded.repos[0].navigation.comparison_mark = Some(marker.clone()); + // Reproduce attachment before the queued adoption is published, followed + // by a coalesced snapshot containing the already-loaded repository. + store.replace_snapshot_for_test(initial); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + store_for_assert.replace_snapshot_for_test(Arc::new(loaded.clone())); + cx.update(|_, app| { + view.update(app, |view, cx| { + assert_eq!(view.startup_repo_runtime_adoption.as_ref(), Some(&runtime)); + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(view.startup_repo_runtime_adoption.is_none()); + }); + }); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert_eq!( + store_for_assert.snapshot().repos[0] + .navigation + .comparison_mark, + Some(marker), + "a duplicate ReloadRepo would clear the retained comparison" + ); + let mut missing = loaded.clone(); + missing.git_runtime.availability = GitExecutableAvailability::Unavailable { + detail: "Git temporarily missing".into(), + }; + cx.update(|_, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(Arc::new(missing), cx); + view.apply_state_snapshot(Arc::new(loaded), cx); + }); + }); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert!( + store_for_assert.snapshot().repos[0] + .navigation + .comparison_mark + .is_none(), + "later runtime recovery must still reload the repository" + ); + shutdown(&store_for_assert); + } + #[gpui::test] fn restored_repository_loads_before_window_and_attaches_without_reopening( cx: &mut gpui::TestAppContext, @@ -416,13 +837,21 @@ mod tests { fn failed_prepared_open_is_not_retried_when_empty_store_attaches( cx: &mut gpui::TestAppContext, ) { - let (store, events, opens) = store(true, true); - let bootstrap = prepare( + let (store, events, opens) = store(false, true); + let mut bootstrap = prepare( &store, restored_config(vec![PathBuf::from("/repos/bootstrap-failure")]), session::UiSession::default(), cx, ); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + handoff.complete(runtime.clone(), &runtime); + drop(handoff); wait_for_state(&store, |state| state.repo_open_failure_revision != 0); assert!(store.snapshot().repos.is_empty()); let store_for_assert = store.clone(); @@ -507,14 +936,21 @@ mod tests { let denied_path = denied.path().to_path_buf(); let allowed_directory = tempfile::tempdir().unwrap(); let allowed = allowed_directory.path().to_path_buf(); - let (store, _events, opens) = store(true, false); + let (store, _events, opens) = store(false, false); let mut config = restored_config(vec![denied_path.clone(), allowed.clone()]); config.initial_path = Some(denied_path); - let bootstrap = prepare(&store, config, session::UiSession::default(), cx); + let mut bootstrap = prepare(&store, config, session::UiSession::default(), cx); assert_eq!(bootstrap.entry_denials.len(), 2); assert!(bootstrap.config.initial_path.is_none()); assert_eq!(bootstrap.ui_session.open_repos, vec![allowed.clone()]); assert!(bootstrap.ui_session.active_repo.is_none()); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + handoff.complete(runtime.clone(), &runtime); wait_for_state(&store, |state| { state.repos.iter().any(|repo| { repo.spec.workdir == allowed && matches!(repo.open, Loadable::Ready(())) @@ -635,11 +1071,18 @@ mod tests { ); }); flush_feedback_acknowledgments(&store_for_assert); + let remaining_diagnostics = store_for_assert.snapshot().repos[0] + .feedback + .diagnostics + .clone(); + // This synthetic preloaded repository has no actor handle, so later + // view requests can add unrelated diagnostics. Acknowledging the entry + // shown above must release that entry without assuming the queue is idle. assert!( - store_for_assert.snapshot().repos[0] - .feedback - .diagnostics - .is_empty() + !remaining_diagnostics + .iter() + .any(|entry| entry.message == "preloaded diagnostic"), + "preloaded diagnostic was not acknowledged: {remaining_diagnostics:?}" ); shutdown(&store_for_assert); assert_eq!(opens.load(Ordering::SeqCst), 0); @@ -657,7 +1100,7 @@ mod tests { ..Default::default() }; let (store, _events, opens) = store(false, false); - let empty = prepare( + let mut empty = prepare( &store, GitCometViewConfig { workspace: WorkspaceBootstrap::Empty, @@ -669,8 +1112,9 @@ mod tests { assert!(empty.ui_session.open_repos.is_empty()); assert!(empty.deferred_repo_bootstrap.is_none()); assert!(!empty.startup_repo_bootstrap_pending); + assert!(empty.begin_git_probe_handoff(&store).is_none()); - let focused = prepare( + let mut focused = prepare( &store, GitCometViewConfig { initial_path: Some(focused_path.clone()), @@ -684,6 +1128,10 @@ mod tests { focused.deferred_repo_bootstrap, Some(DeferredRepoBootstrap::OpenRepos(vec![focused_path])) ); + let deferred = focused.deferred_repo_bootstrap.clone(); + assert!(focused.begin_git_probe_handoff(&store).is_some()); + assert!(focused.attach_git_probe_handoff().is_none()); + assert_eq!(focused.deferred_repo_bootstrap, deferred); shutdown(&store); assert_eq!(opens.load(Ordering::SeqCst), 0); } From 7ebb3e912d931944c4b26e21f1ed62a4459b4002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 14:52:56 +0300 Subject: [PATCH 41/67] Pin GPUI to deferred Windows DX11 support-device creation Advance all six GPUI dependency pins and 22 lockfile sources from 18e1b2f to 164485d7d856c0e57607501480f255d027b92709, now pushed on lazy_load_shaders. Update the UI kit revision contract without changing unrelated dependency resolution. The upstream change skips DX11 support-device initialization on successful DX12 startup while retaining explicit DX11, automatic fallback, shared device reuse, redraw ticks, and device recovery. Nine new regression tests cover the lazy initialization and recovery behavior; all 58 Windows tests, the extended headless/windowed smoke, both DX11 build configurations, strict Clippy, formatting, and diff checks passed in the GPUI worktree. The clean prior trace attributed 33.7 ms sampled CPU to eager DX11 device creation. The measured 18e1b2f baseline had median first submission 538.0 ms and repository readiness 831.9 ms over five warm-cache runs. These measurements precede both this pin and the app handoff in c55d94a4; the combined release comparison follows separately. Validation: locked Windows cargo metadata resolves the exact new revision; the lockfile diff contains only GPUI source substitutions. --- Cargo.lock | 44 ++++++++++++++-------------- Cargo.toml | 12 ++++---- crates/gitcomet-ui-kit/gpui-revision | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c43c9d6b..c264d231b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "block", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4109,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "gpui-ce", "naga 24.0.0", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "schemars", "serde", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "log", @@ -4220,7 +4220,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "anyhow", "core-foundation", @@ -4275,7 +4275,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "accesskit", "accesskit_windows", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -10092,7 +10092,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "encoding_rs", ] @@ -10100,7 +10100,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=18e1b2f65899ccbc36304fbb8f9ee703ea86adc4#18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index b356d2c64..ef3fd89ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "18e1b2f65899ccbc36304fbb8f9ee703ea86adc4" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index fd4693bad..4db27378b 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -18e1b2f65899ccbc36304fbb8f9ee703ea86adc4 +164485d7d856c0e57607501480f255d027b92709 From e41fe6cd79b9201473f821acbc0b1f8515dec1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 15:35:33 +0300 Subject: [PATCH 42/67] Document measured Windows startup tradeoffs and remaining renderer costs Record the clean GPUI 18e1b2f baseline, DXC/opaque diagnostics, and the combined c55d94a4 Git-probe handoff plus GPUI 164485d7 DX11-device deferral in 7ebb3e91. Preserve historical measurements while separating the retired experimental worktree confound, distinct clock origins, and sampled-memory limitations. Five alternating warm-cache DX12/FXC release-with-debug pairs on Ryzen AI 9 HX 370 / Radeon 890M measured: - Median repository readiness 897.1 -> 658.8 ms; every pair improved, with median paired saving 244.5 ms. All five candidates loaded repositories early. - Median first submission 553.9 -> 646.6 ms; four pairs regressed, with median paired regression 74.4 ms. Drawing and submission duration themselves fell; the extra delay precedes drawing. The 500 ms target remains unmet. - Sampled peak working set 412.4 -> 342.6 MiB and private bytes 656.1 -> 523.3 MiB; these short-run samples do not establish general retained-memory savings. Three separate diagnostic stage/Home pairs confirm shorter native setup but longer later renderer initialization. Home first interaction improved while Home-ready regressed; do not pool these diagnostics with the primary pairs. A successful fresh 1 kHz ETW capture has no sampled DX11 support-device creation and confirms handoff at 117.1 ms before renderer/view initialization. Status finishes before drawing. Main DX12 initialization CPU 64.2 -> 101.7 ms and total compiler CPU 368.2 -> 447.2 ms identify remaining work without proving a single cause. The first-present warm rebuild remains 17.5 ms CPU, with no second full compiler pass. Prioritize initial renderer/appearance work and investigate background overlap; gitlink status is now hidden behind renderer startup. Validation: 168 app tests and 58 native GPUI Windows tests, extended headless smoke, strict GPUI Clippy, both DX11 compile configurations, successful exact- profile release build, ten valid primary measurement runs, six valid stage runs, six graceful Home captures, DX12 History/Settings and explicit DX11 Settings live checks. The fresh ETW foreground capture and diff checks pass. --- docs/windows-startup-performance.md | 423 ++++++++++++++++++++++++---- 1 file changed, 369 insertions(+), 54 deletions(-) diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index 88f3c1449..4743e635a 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -1,15 +1,278 @@ # Windows startup investigation Measured on October 20, 2026 with a Ryzen AI 9 HX 370 and Radeon 890M. -DX12 remains the default; DX11 was used only as a diagnostic comparison. - -The final candidate's warm-start medians are **671.7 ms to first draw** and -**847.8 ms to repository readiness** across eight runs in two alternating-pair -sessions. Seven of eight runs reached readiness below one second; the remaining -run took 1,014.7 ms. The median target is met on this machine; a strict -subsecond maximum was not achieved, and reboot-cold startup was not measured. - -## Baseline and bottleneck +DX12 remains the default; DX11 was used for diagnostic comparisons and +compatibility checks. + +The combined startup changes reached median **540.6 ms first CPU draw**, +**646.6 ms first platform submission**, and **658.8 ms repository readiness** +over five runs. Readiness improved in every pair, but first-frame submission +became slower in four of five pairs. The 500 ms target has not been achieved. +These runs reused OS/driver caches; reboot-cold startup was not measured. + +The measured source integration is GitComet +`7ebb3e912d931944c4b26e21f1ed62a4459b4002`, including app bootstrap handoff +`c55d94a43d87a1373a49a4c0402cc8c4ce120a91` and GPUI +`164485d7d856c0e57607501480f255d027b92709`. The new GPUI revision defers the +separate DX11 support device while retaining DX12 rendering. Both changes are +committed, tested, and measured together. A fresh CPU/graphics trace confirms +early repository dispatch and finds no positive CPU samples from DX11 device +creation. Renderer initialization and first presentation gate readiness in +that capture. + +## Combined handoff and DX11 deferral measurements + +Five alternating pairs compared the frozen GPUI `18e1b2f` baseline with the +combined Git-probe handoff and GPUI `164485d7` candidate. Both used normal +transparent windows, DX12/FXC, the same `release-with-debug` profile, and the +visible-desktop startup scenario. No compilation or profiling ran during these +measurements. + +| Median launch latency, five runs per arm | Exact-pin baseline | Combined candidate | +| --- | ---: | ---: | +| First CPU draw complete | 460.3 ms | 540.6 ms | +| First platform submission complete | 553.9 ms | 646.6 ms | +| Repository ready | 897.1 ms | 658.8 ms | +| Repository-ready range | 821.8–923.3 ms | 635.1–730.2 ms | + +The candidate loaded repositories early in all five runs; the baseline loaded +early once and deferred four times. Ready improved in every pair by a median +**244.5 ms** (median paired ratio 0.729), while first submission regressed in +four pairs; the median paired change across all five was **+74.4 ms**. +The first-submit duration itself +decreased from a median 92.8 to 85.3 ms: the launch-to-submit regression occurs +before or around starting that submission, rather than a longer submission +duration. The first frame can contain a different amount of loaded repository +content. The fresh trace below shows longer renderer initialization overlapping +repository workers, but does not establish contention as the cause. + +This is a readiness/first-frame tradeoff, not a uniform startup improvement. +These measurements evaluate both changes together and a changed bootstrap-path +mix; they do not isolate the DX11 deferral's elapsed saving. The single pair +where both loaded early also improved ready by 91.6 ms, which alone cannot +establish an independent cause. Compare against the baseline rerun in this +session, not the earlier session's 831.9 ms ready median. + +Median sampled peak working set was 412.4 → 342.6 MiB, and sampled peak private +bytes were 656.1 → 523.3 MiB. Both were lower in all five pairs, but bootstrap +timing and lifetime overlap changed. These are short-run process samples +approximately every 250 ms, not exact native peaks, allocation stacks, retained +heap, steady-state memory, or GPU memory. No general memory reduction is +established. + +DX12 history-jump and Settings checks passed on the frozen candidate. Explicit +DX11 Settings also passed, confirming that native DX11 remains available. +Source behavior and regression tests establish that DX12 selection does not +initialize the separate DX11 support device; the fresh capture has no positive +CPU samples from its constructor or D3D11 creation API. Submission completion +remains distinct from physical display scanout, and none of these candidate +runs achieved 500 ms loaded startup. + +## Separate stage and empty-Home diagnostics + +Three alternating pairs per scenario used the same frozen binaries with stage +tracing and no sampling profiler. These diagnostics are separate from the +primary five-pair benchmark and must not be pooled with it. Home runs used new +empty application profiles. + +| Scenario and stage metric, median | Baseline | Combined candidate | +| --- | ---: | ---: | +| Repository: native application construction duration | 64.9 ms | 11.6 ms | +| Repository: open-window start to renderer-ready interval | 257.0 ms | 335.6 ms | +| Repository: first-paint callback | 408.3 ms | 446.7 ms | +| Repository: first-interactive callback | 427.8 ms | 466.2 ms | +| Repository: active-repository model ready | 796.2 ms | 455.3 ms | +| Empty Home: native application construction duration | 113.0 ms | 14.3 ms | +| Empty Home: open-window start to renderer-ready interval | 272.8 ms | 338.8 ms | +| Empty Home: first-paint callback | 474.2 ms | 451.9 ms | +| Empty Home: first-interactive callback | 546.5 ms | 522.6 ms | +| Empty Home: Home-ready callback | 576.5 ms | 625.1 ms | + +Callback/model milestones use the app trace's own origin, not process-spawn +time. First paint is a prepaint callback, and first interactive is a subsequent +frame callback that can still show Git checking. Model readiness excludes the +harness's viewport-geometry and polling requirements. None is display scanout. +The separate Home parent observer also adds launch work and 100 ms polling; +its timings cannot be substituted for these stage values. + +The worker handoff occurred at a median 94.2 ms, before view initialization in +all three repository runs. Repository model readiness improved, while first +paint and the interactive callback regressed. Empty Home painted/interacted +earlier, but **Home-ready regressed in all three pairs**, by 15.7, 38.2, and +73.3 ms (median paired regression 38.2 ms). Its interactive-to-Home-ready +interval grew from a median 29.0 to 105.8 ms. No repository handoff runs on +empty Home. The markers locate increased elapsed time; they do not establish +a shader, driver, scheduling, or repaint cause, or an overall Home improvement. + +## Fresh combined CPU and presentation trace + +The successful `samply-deferred-dx11-retry` capture used the frozen candidate, +hardware DX12, a visible foreground window, and 1 kHz sampling with graphics +events. The original `samply-deferred-dx11` attempt is invalid and excluded. +Both this capture and the clean pinned baseline were analyzed over the first +1100 ms of process age with matching symbols. + +The probe completed at app-trace age 116.929 ms and the worker dispatched +bootstrap at **117.058 ms**, before renderer completion at **454.763 ms** and +view initialization at **538.428 ms**. Initial bootstrap preparation still +records deferred-for-Git, but the subsequent worker dispatch makes this an +early-loading run. Later UI adoption is not the start of repository loading. + +| Inclusive sampled CPU | Clean pinned baseline | Combined capture | +| --- | ---: | ---: | +| Separate DX11 device constructor | 33.732 ms | No positive samples | +| Main DX12 context/surface initialization | 64.225 ms | 101.730 ms | +| Native shader compiler, all app threads | 368.236 ms | 447.192 ms | +| Warm first-present pipeline rebuild | 13.351 ms | 17.542 ms | + +No positive samples show `DirectXDevices::*` or `D3D11CreateDevice` in the new +capture's analysis window. Sampling cannot exclude an arbitrarily brief +execution. DX12 setup starts earlier but finishes later; adapter/device initialization and ordinary +pipeline compilation remain substantial. Repository workers and watcher setup +now overlap them. Inclusive categories overlap, and one diagnostic capture per +source cannot isolate cache, driver, scheduling, clock-frequency, or source +effects. The trace does not prove that contention caused the increased time, +or that device-creation CPU moved one-for-one into DX12. + +Status took 320.9 ms and completed at 542.6 ms after spawn, before the first +CPU draw at 607.5 ms. Log loading was also complete. First submission finished +at 695.0 ms and scenario ready followed at 697.1 ms. Repository status is +therefore no longer the final readiness dependency in this capture. These are +diagnostic timings, not additional warm-benchmark pairs. + +The first successful ETW `Present/Info` was at **691.1924 ms process age** with +`ReturnStatus=0`. This confirms a present event, not GPU completion or display +scanout. ETL process age, app-trace age, and journal/process-spawn clocks remain +distinct; their raw timestamps were not directly subtracted. + +Startup size changes and the first transparent frame still configure the +surface. The subsequent warm pipeline rebuild contains no sampled FXC work; +its 17.542 ms CPU is distinct from the 447.192 ms initial compiler-inclusive +CPU across threads. Watcher setup consumed 127.6 ms sampled +CPU, overlapping shader workers. Optional LFS/annex discovery consumed +128.6 ms sampled CPU around first presentation and beyond ready. The gitlink +subprocess still lasted roughly 101.5 ms, now hidden behind renderer startup. +These concurrent costs are not additive elapsed savings. + +## Clean upstream-pin baseline + +GitComet `1da63f84a8329efed88b90701c0a6a5ac336d8cb` replaced the vendored +renderer with GPUI `18e1b2f65899ccbc36304fbb8f9ee703ea86adc4`, which contains +the lazy-smoothing and parallel ordinary-pipeline changes. Five alternating +pairs compared this pin with the frozen previous parallel-renderer build. +Both used `release-with-debug`, DX12/FXC, optimization level 3, fat LTO, one +codegen unit, and `x86-64-v3`. The visible client area was 2100×1350 at 144 DPI, +using Radeon driver `32.0.13058.8001`. No compilation or profiling ran during +these latency measurements. + +| Median launch latency, five runs per arm | Previous parallel build | Exact upstream pin | +| --- | ---: | ---: | +| First CPU draw complete | 458.5 ms | 455.4 ms | +| First platform submission complete | 540.2 ms | 538.0 ms | +| Repository ready | 828.2 ms | 831.9 ms | + +The previous build loaded repositories early once and deferred four times; +the pinned build deferred in all five runs. Its ready range was +814.7–861.5 ms, with no result below 500 ms. The four deferred/deferred pairs +had median paired changes of +7.6 ms draw, +12.7 ms submission, and −14.8 ms +ready. This small sample establishes comparable practical latency; it does not +establish a pin-related improvement or regression. + +First CPU draw ends scene construction before platform drawing. First-submit +completion includes CPU/platform submission work; neither metric establishes +GPU completion or physical display scanout. Repository ready is the startup +scenario's usable-repository condition, observed on a 50 ms polling interval. +These three milestones must remain separate when assessing the 500 ms target. + +Earlier pinned measurements and the first pinned CPU capture were confounded +by temporary linked worktrees created for this investigation. GitComet scanned +those additional worktrees, whose generated parser files triggered substantial +normalization work against their indexes. After preserving and removing only +the experimental worktrees, we repeated the measurements and capture. The +clean results above supersede the earlier pinned 635.6 ms draw / 765.0 ms ready +medians; differences between those sessions are not a controlled optimization +gain. + +Two diagnostic comparisons then used the exact pinned executable in both arms, +with five alternating pairs each: + +| Experiment | Baseline draw / submit / ready | Candidate draw / submit / ready | Median paired submit change | +| --- | ---: | ---: | ---: | +| FXC → staged SDK DXC | 442.4 / 517.0 / 817.3 ms | 444.8 / 521.2 / 799.7 ms | −5.5 ms | +| Transparent → opaque background | 449.4 / 529.2 / 843.5 ms | 456.4 / 480.6 / 837.6 ms | −47.4 ms | + +Staged DXC did not establish a compelling submission improvement under the +parallel renderer. Submission-duration medians were 74.1 ms with FXC and +74.2 ms with DXC. All FXC runs deferred; two DXC runs loaded early, confounding +the aggregate ready comparison. The three deferred/deferred pairs favored DXC +by medians of 11.1 ms submission and 17.6 ms ready. Production packaging still +does not bundle the SDK DLLs. + +Opaque mode reduced submission duration from a median 86.5 to 21.9 ms. All five +pairs improved, with a median paired duration saving of 61.1 ms. +Launch-to-submit improved in four of five pairs by a median 47.4 ms. +The four deferred/deferred pairs saved +53.9 ms submission but regressed ready by 12.1 ms. Opaque mode changes the +desktop-visible pixels around GitComet's inset rounded frame, so it is not a +visually equivalent shipping fix. GPUI starts opaque, then reconfigures and +rebuilds pipelines for the requested transparency. Passing the intended initial +appearance upstream is the relevant follow-up. + +## Completed Git-probe handoff + +The clean 1 kHz capture deferred bootstrap at app-entry age **89.2195 ms**. +The background Git probe finished at **95.9663 ms**, only **6.7468 ms** later, +but runtime adoption began at **434.0131 ms**. The completed result waited +338.0468 ms while native window/renderer initialization ran. This identifies +an overlap opportunity, not a guaranteed additive end-to-end saving. + +GitComet commit `c55d94a43d87a1373a49a4c0402cc8c4ce120a91` lets the prepared +bootstrap adopt the completed probe and dispatch loading before view attachment. +Ownership transfers exactly once; stale or late completion cannot duplicate +recovery. Entry gates, focused tools, missing-Git recovery, multiwindow behavior, +and store release when a prepared window is dropped remain covered. + +Validation against GPUI `18e1b2f` passed **168 tests**: bootstrap 12, app 67, +notifications 32, window lifecycle 53, runtime probe 2, and GPUI version contract +2. Formatting and diff checks passed. The retained-diagnostic fixture checks +acknowledgment of its specific seeded diagnostic and retains both exactly-once +toast assertions, allowing unrelated actor-handle errors from the synthetic +repository. Its release measurement is the combined comparison above; +the handoff was not benchmarked independently. + +## Deferred DX11 support-device creation + +GPUI `164485d7d856c0e57607501480f255d027b92709`, pushed to +`lazy_load_shaders`, creates shared DX11 devices only when DX11 is selected +or actual renderer fallback needs them. DX12 capability probing uses a DXGI +factory without creating a D3D11 device. Software DirectWrite glyph +initialization remains independent of renderer devices. + +The windowed redraw/vsync loop runs with an empty DX11 device slot and observes +devices initialized later. Recovery takes an unlocked device snapshot and +publishes platform replacement before notifying windows. Consuming invalidation +before taking that snapshot preserves invalidation from devices created during +the tick. Initial alpha, surface resizing, shader behavior, and public APIs +remain unchanged. + +Validation passed **58 Windows library tests**, including nine new regressions +covering unused DX11 devices on DX12 and forced-DX12 failure, retryable/shared +fallback creation, native device reuse, windowed/headless startup, redraw +without DX11, late-device recovery, recovery failure, and late invalidation. +The existing native DX11 pixel, software/headless glyph, and native color-glyph +recovery tests also passed. The final extended windowed/headless smoke test +passed separately. + +Strict Clippy passed before the final log wording and smoke-test extension. +Both DX11-only configurations compiled: the default configuration with +test-support and tests, and the minimal production library with +`--no-default-features`. Formatting and diff checks passed on the final files. +Recovery control flow is covered by deterministic tests; physical GPU +removal/reset was not forced. The combined release comparison above does not +isolate this change's saving; the baseline's 33.7 ms device-creation CPU does +not establish an equal wall-time improvement. + +## Historical baseline and bottleneck GitComet baseline: `848514d8b4b9e3d11e37565ebc6399ded0a707fd`. GPUI baseline: `0665fb07d59cf7af185e58f44b9f890590abe204`. @@ -36,12 +299,12 @@ original shaders and compiles before encoding the first frame that needs them. It supports reuse, resource rebuilds, failure rollback, and retries. A future feature requesting smoothing can still incur a one-time compilation delay. -These measurements used the renderer then vendored in `vendor/gpui-wgpu`. -The smoothing and parallel pipeline changes are now included in upstream GPUI -commit `18e1b2f65899ccbc36304fbb8f9ee703ea86adc4`. All GPUI dependencies now -pin that revision, replacing the vendored renderer and its Cargo patch. +The following earlier experiments used the renderer then vendored in +`vendor/gpui-wgpu`. Their within-session comparisons remain useful, but their +absolute timings and bootstrap-path mixtures must not be pooled with the clean +upstream-pin baseline above. -## Controlled measurements +## Historical smoothing measurements Five alternating baseline/candidate pairs used the same `release-with-debug` profile, DX12/FXC, real GitComet repository, isolated application profiles, and @@ -81,10 +344,11 @@ Additional diagnostic comparisons: | Lazy smoothing, SDK DXC, same 5 pairs | 626.8 ms | 1,052.5 ms | DXC was Windows SDK 1.8.2502.11, staged next to isolated executables; loaded -modules confirmed the DLLs used. Once smoothing is deferred, DXC adds only a -small improvement. Production packaging does not bundle these SDK DLLs. +modules confirmed the DLLs used. Once smoothing was deferred, DXC added only a +small improvement in this earlier experiment. The current parallel-renderer +comparison is reported above. -## Repository bootstrap overlap +## Historical repository bootstrap overlap The next change creates the `AppStore` and prepares repository bootstrap before `cx.open_window`. Repository workers can then load status and history while @@ -131,7 +395,7 @@ sampled compiler CPU to ordinary shaders (quads 188.1 ms, shadows 161.1 ms). There were still no eager smoothing pipeline calls. This is diagnostic evidence for the next renderer experiment, not a controlled attribution of contention. -## Parallel ordinary pipeline creation +## Historical parallel ordinary pipeline comparison The renderer now has a bounded parallel initialization path for Windows DX12. It uses at most three compilation lanes, including the caller, and respects @@ -179,7 +443,7 @@ repository readiness at 1,019.6 ms. With the actual saved profile, app tracing recorded first paint at 623.3 ms, repository ready at 709.8 ms, and interaction at 755.3 ms; the parent observed interaction at 879.2 ms. An empty profile reached ready Home at 539.2 ms in app tracing, observed at 630.6 ms by the parent. -History navigation and opening Settings passed live checks on the final binary. +History navigation and opening Settings passed live checks on that binary. Parallel compilation moves work onto a bounded set of threads; these results do not establish a reduction in total shader compiler CPU. The exact pre-format @@ -188,51 +452,47 @@ source are retained in the local provenance artifacts. ## Remaining work -The earlier lazy-only trace attributed ordinary shader work to quads (~121 ms -sampled compiler CPU), shadows (~67 ms), and path rasterization (~39 ms). -Those shaders still compile; their initialization can now overlap. - -After window creation, repository `LoadStatus` took about 376 ms wall time -in the diagnostic trace. It includes a 149 ms child-command wait while -supplementing status for the populated `fixtures/syntax_test` gitlink. -That supplement preserves submodule correctness; the implementation already -limits its query to gitlinks and skips positively empty, unchanged checkouts. - -Watcher initialization (~142 ms sampled CPU) and directory traversal run -concurrently. A worktree-dirty scan also overlaps the main status scan. -These inclusive and parallel costs must not be added as elapsed-time savings. - -Further experiments include: - -- Adopt a completed background Git probe before window attachment, with explicit - ownership of the deferred restore and protection against duplicate recovery. - The current UI handoff can still serialize loading when the probe finishes - just after bootstrap preparation. -- Start the gitlink status supplement alongside the gix worktree scan after - selecting paths. This could overlap part of the observed 149 ms wait while - preserving submodule status, cancellation, and error handling. -- Reduce repeated filesystem lookups during watcher initialization while - preserving watch coverage. Other-worktree scans already exclude the active - checkout; they are not duplicate active-repository status requests. - -These are unmeasured opportunities. Public `wgpu::PipelineCache` in version +The next priorities are: + +- Instrument ordinary pipeline requests and DX12 device initialization + separately, then evaluate background-task scheduling with a controlled + comparison. The new trace shows overlap and renderer-gated readiness, but + does not establish how much waiting or CPU time scheduling can remove. +- Coalesce intermediate startup surface resizes while preserving final size, + runtime resizing, readback, and recovery. Pass the intended initial background + appearance upstream to avoid the opaque-to-transparent rebuild without + changing the frame. These changes overlap; their potential gains cannot be + added. +- Investigate repeated watcher lookups and optional-tool overlap without + sacrificing watch coverage, descendant cancellation, or eventual capability + checks. Prioritize the renderer for this startup case: the populated gitlink + supplement remains real work, but status finishes before the first frame. +- Resolve the separate empty-Home readiness regression with callback/frame + timings before attributing it to a particular renderer operation. + +The combined candidate needs about 147 ms less elapsed time for 500 ms +submission and 159 ms for 500 ms ready. These are target gaps, not predicted +savings, and overlapping changes must not be added into a projected time. +Public `wgpu::PipelineCache` in version 29.0.4 supports Vulkan only, so it does not provide persistent DX12 shader caching. More shader deferral requires evidence that the initial frame does not need those pipelines. ## Validation and reproduction -Four smoothing regression tests passed on the candidate: first-use pixel -equivalence, backdrop/isolated-filter planning, creation rollback, and DX12 +Four smoothing regression tests passed on the earlier renderer candidate: +first-use pixel equivalence, backdrop/isolated-filter planning, creation rollback, and DX12 retry behavior. Three GPU tests explicitly selected the Radeon 890M/DX12; the fourth tests planning. History navigation and opening Settings also passed live UI smoke checks. Use `scripts/profiling/live-ui.py measure` for alternating startup measurements. The startup scenario disables updates, network tag fetching, and signature -checks through its isolated profile. First draw measures completed UI drawing, -not physical display presentation. Memory is sampled every 250 ms and can miss -short peaks; it is not an allocation stack trace or exact retained Rust heap. +checks through its isolated profile. The first-submit metric is derived from +the journal-start-to-spawn offset plus the first journal `submit.at_ms`. +Do not subtract timestamps across the distinct app-entry, UI probe, external +spawn, and ETL process-age clock origins. Memory is sampled every 250 ms and can +miss short peaks; it is not an allocation stack trace or exact retained Rust heap. The PowerShell helpers provide: @@ -257,12 +517,67 @@ Large local artifacts remain under ignored `target/startup-investigation/`: `samply-lazy-candidate/` with the matching symbols, ETL, and analysis. The bootstrap experiment adds `bootstrap-runtime/`, `repo-bootstrap-pairs/`, `bootstrap-stage-real-profile/`, and `samply-bootstrap-candidate/`. -The final experiment adds `parallel-runtime/`, `repo-parallel-pairs/`, +The historical parallel experiment adds `parallel-runtime/`, `repo-parallel-pairs/`, `repo-parallel-confirmation/`, `parallel-comparison-summary.json`, the `parallel-stage-*` traces, and `parallel-compiled-source/`. Their availability is local; this document records the durable measurements. -## Upstream precompilation +The clean pin and runtime comparisons are `pinned-final-comparison.json`, +`pinned-dxc-comparison.json`, and `pinned-opaque-comparison.json`, backed by +`repo-pinned-final-pairs/session.json`, `repo-pinned-dxc-pairs/session.json`, +and `repo-pinned-opaque-pairs/session.json`. The clean diagnostic is +`samply-pinned-final/startup.json.gz`, its retained kernel/user ETLs, +`recording.json`, `process-analysis.json`, `process-analysis.symbols.json`, +`repository-startup-findings.txt`, and `live-ui/frames.jsonl`, with adjacent +`samply-pinned-final-stage.jsonl`. Source interpretation is preserved in +`pinned-renderer-opportunities.md`. + +The previous executable SHA256 is +`fd0c7e7592aceb7d405d58d2459f539dfb7fa6df8a7df7ab5daceb5f45979b90`; +`parallel-runtime/provenance.json` maps its preserved sources to integrated +GitComet `b37e714ecf8218a06cd299cb33c5e8a32fb696a5`. The exact-pin executable +SHA256 is `c142745a43484a986df94b092b54bd3ab2ecb045758bd25842a9e4afd40b819d` +and PDB SHA256 is +`b03359b130dbfe55bc967531cbaad6a6378465ad881e8fe2848f4dc34f908d6c`. +`pinned-gpui-runtime/provenance.json` records its `1da63f84` / `18e1b2f` +source pair. DXC experiment DLL versions and hashes are in +`pinned-dxc-runtime/provenance.json`. Session checkout metadata alone does not +identify a separately frozen executable's sources. + +The handoff's 168-test results, source hashes, executable hashes, and build/test +logs are under `app-bootstrap-handoff-preserved/`, particularly +`root-validation-summary.json`, `root-validated-source-sha256.json`, and +`root-validated-test-binaries-sha256.json`. Its validation summary records the +pre-commit base `1da63f84`; the source manifest identifies the changes later +committed as `c55d94a4`. + +`gpui-lazy-dx11-validation.txt` records the renderer validation performed before +commit `164485d7`, with final smoke-test and minimal-configuration raw output in +`gpui-lazy-dx11-headless.log` and `gpui-lazy-dx11-minimal-check.log`. + +The combined measurement is `deferred-dx11-comparison.json`, backed by +`repo-deferred-dx11-pairs/session.json` (session +`29550899-a4d9-4ca3-a73e-380f031cf1d6`). Frozen source/build provenance and +artifacts are in `deferred-dx11-runtime/`. Candidate executable SHA256 is +`9f7ebd94a7d9c38c69fe653f5096d95985e61b47757dd6613c394247cd234706`; +PDB SHA256 is +`0312e433a5f8c4e073581761fc8aa886052a3ba6532e961793c06ddb531ae127`. +The provenance identifies GitComet `7ebb3e91`, GPUI `164485d7`, and the included +handoff source hashes. Valid live-check summaries are +`deferred-dx11-history-jump/summary.json`, +`deferred-dx11-settings/summary.json`, and +`deferred-dx11-explicit-dx11/summary.json`, all with that executable hash. +The successful fresh diagnostic is `samply-deferred-dx11-retry/`, including +`startup.json.gz`, retained ETLs, `recording.json`, `foreground-summary.json`, +`startup-findings.txt`, `graphics-marker-evidence.json`, and +`main-initialization-evidence.json`. Its derived comparisons are +`deferred-dx11-retry-capture-comparison.json` and +`deferred-dx11-renderer-phase-comparison.json`. The failed +`samply-deferred-dx11/` capture is excluded. Separate three-pair stage/Home +results and clock definitions are in `deferred-stage-home-comparison.json` +and `deferred-stage-home-comparison.txt`. + +## Historical upstream precompilation Official GPUI-CE revision `9a82fba4e5f1f9194a5489b7a417d164083dd67a` precompiles **DX11** shaders in From afcb771a57833bff8bb58a4f04648450a5ab1f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 15:59:27 +0300 Subject: [PATCH 43/67] Use opaque Windows surfaces to avoid first-present alpha reconfiguration Use GPUI's default opaque background for main and Settings windows on Windows, retaining the explicit diagnostic override. The owner inspected the opaque build and approved its appearance. A prior five-pair same-binary DX12 diagnostic measured median first submission at 529.2 ms transparent versus 480.6 ms opaque; submission duration fell from 86.5 to 21.9 ms. These are historical measurements of the previous GPUI revision, not a prediction for the current combined build. The pending combined comparison will remeasure startup. Validation: rustfmt check; owner visual comparison on the frozen DX12 build with GITCOMET_WINDOW_BACKGROUND=opaque. --- crates/gitcomet-ui-gpui/src/app/launch.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index 1d0be6506..e6b2db91f 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -827,19 +827,16 @@ pub(super) fn open_gitcomet_window( window } -/// Client-side decorations inset a rounded frame into the surface; the pixels -/// outside it must show the desktop, not a solid fill, so every platform but -/// macOS asks for a transparent surface. +/// Windows and macOS use their default opaque surfaces. Linux client-side +/// decorations retain transparency around the inset rounded frame. /// -/// `GITCOMET_WINDOW_BACKGROUND=opaque|transparent` overrides the choice. It is -/// a diagnostic knob: on Windows a transparent surface changes how the -/// compositor blends the window, so this is the quickest way to tell whether -/// that is what makes a build feel slow. +/// `GITCOMET_WINDOW_BACKGROUND=opaque|transparent` overrides the choice for +/// compositor and startup diagnostics. pub(crate) fn with_main_window_background(mut options: WindowOptions) -> WindowOptions { let transparent = match std::env::var("GITCOMET_WINDOW_BACKGROUND") { Ok(value) if value.trim().eq_ignore_ascii_case("opaque") => false, Ok(value) if value.trim().eq_ignore_ascii_case("transparent") => true, - _ => !cfg!(target_os = "macos"), + _ => !cfg!(any(target_os = "macos", target_os = "windows")), }; #[cfg(target_os = "macos")] { From c6f19db76c1da13c2ac6deff2914235e3a2cb65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 16:19:20 +0300 Subject: [PATCH 44/67] Pin GPUI to demand-driven filters and coalesced Windows startup resizes Pin all six workspace dependencies, 22 lockfile sources, and the revision contract to a4ee85008357c2e8e659bf8383ccae65ab62f94f on lazy_load_shaders. This includes startup resize coalescing, no surface pipeline for unsupported integrations, lazy ordinary filter pipelines, and the test-window accessor used by application frame-order regressions. The preceding DX12 trace measured 447.2 ms inclusive shader-compiler CPU across app threads and repeated surface configuration before the first frame. This pin removes unnecessary work; its elapsed startup effect will be measured on the rebuilt combined candidate. GPUI validation: 63 Windows tests; 66 WGPU unit and 5 integration tests with default features; 67 unit and 5 integration tests with external surfaces; strict default Clippy. Surface-enabled Clippy passes with only two pre-existing missing_transmute_annotations warnings allowed. Locked Windows metadata resolves successfully. GitComet UI/watcher regression validation is running against this exact pin. --- Cargo.lock | 44 ++++++++++++++-------------- Cargo.toml | 12 ++++---- crates/gitcomet-ui-kit/gpui-revision | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c264d231b..56ab5ae2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "block", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4109,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "gpui-ce", "naga 24.0.0", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "schemars", "serde", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "log", @@ -4220,7 +4220,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "anyhow", "core-foundation", @@ -4275,7 +4275,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "accesskit", "accesskit_windows", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -10092,7 +10092,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "encoding_rs", ] @@ -10100,7 +10100,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=164485d7d856c0e57607501480f255d027b92709#164485d7d856c0e57607501480f255d027b92709" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index ef3fd89ae..1370dd469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "164485d7d856c0e57607501480f255d027b92709" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index 4db27378b..5e08c88cb 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -164485d7d856c0e57607501480f255d027b92709 +a4ee85008357c2e8e659bf8383ccae65ab62f94f From ebcf9d4e787c26702b2356d5000b1b8d2f17597a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 16:34:40 +0300 Subject: [PATCH 45/67] Adopt completed Git discovery before attaching the empty Home view Extend the existing pre-window handoff to a normal empty Home whose store still reports Git Checking. The worker adopts the already-running probe without creating repository work, and attachment retains exact ownership of late results. Missing Git, stale preferences, closed preparation, and later runtime recovery keep their existing behavior. Three prior diagnostic pairs showed Home readiness regressing from 576.5 to 625.1 ms despite earlier interaction. The store was created before discovery finished, then Home waited for a foreground runtime report and another frame. This removes that unnecessary handoff delay without changing the ready predicate. The combined release build will measure its elapsed effect. Regression coverage checks usable Open/Clone/Init controls on first attached Home, no legacy repository restore, exactly-once handoff, stale/unavailable results, and recovery after attachment. Validation against GPUI a4ee8500: all 15 bootstrap/Home tests pass, together with 67 app, 32 notification, 53 window-lifecycle, 2 runtime-probe, and 2 GPUI-version-contract checks. Tests use the ci-test profile on Windows. --- .../src/view/window_bootstrap.rs | 216 ++++++++++++++++-- 1 file changed, 203 insertions(+), 13 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs index 9b1bf27be..f14e57105 100644 --- a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs +++ b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs @@ -1,17 +1,17 @@ //! Prepare repository work before native window/renderer initialization. use super::*; -use gitcomet_core::process::GitRuntimeState; +use gitcomet_core::process::{GitExecutableAvailability, GitRuntimeState}; use std::sync::{Mutex, Weak}; enum GitBootstrapState { - Waiting(DeferredRepoBootstrap), + Waiting(Option), Dispatched(GitRuntimeState), Attached, } -/// The worker can consume a restore only until the native window attaches. -/// Queueing both messages under this lock makes that ownership transfer exact, -/// even if the store has not reduced either message when the view is created. +/// The worker can adopt Git and consume an optional restore only until the +/// native window attaches. Queueing the messages under this lock makes that +/// ownership transfer exact even before the store has reduced the messages. struct GitBootstrapHandoff { store: AppStore, expected_runtime: GitRuntimeState, @@ -35,22 +35,28 @@ impl GitBootstrapHandoff { unreachable!(); }; self.store.dispatch(Msg::SetGitRuntimeState(runtime)); + let has_repository_bootstrap = bootstrap.is_some(); match bootstrap { - DeferredRepoBootstrap::RestoreSession { + Some(DeferredRepoBootstrap::RestoreSession { open_repos, active_repo, - } => self.store.dispatch(Msg::RestoreSession { + }) => self.store.dispatch(Msg::RestoreSession { open_repos, active_repo, }), - DeferredRepoBootstrap::OpenRepos(paths) => { + Some(DeferredRepoBootstrap::OpenRepos(paths)) => { for path in paths { self.store.dispatch(Msg::OpenRepo(path)); } } + None => {} } drop(state); - gitcomet_core::startup_trace::mark("repository_bootstrap_dispatched_after_git_probe"); + gitcomet_core::startup_trace::mark(if has_repository_bootstrap { + "repository_bootstrap_dispatched_after_git_probe" + } else { + "home_git_runtime_dispatched_after_probe" + }); } } @@ -98,10 +104,26 @@ impl PreparedWindowBootstrap { } fn begin_git_probe_handoff(&mut self, store: &AppStore) -> Option> { - let bootstrap = self.deferred_repo_bootstrap.take()?; + if self.git_probe_handoff.is_some() { + return None; + } + let initial_state = store.snapshot(); + // Home also needs Git before its Open/Clone/Init actions are usable. + // A store created before discovery finishes would otherwise attach + // with Checking and wait for a foreground report after its first frame. + let home_waiting_for_git = self.config.view_mode == GitCometViewMode::Normal + && initial_state.repos.is_empty() + && matches!( + initial_state.git_runtime.availability, + GitExecutableAvailability::Checking + ); + if self.deferred_repo_bootstrap.is_none() && !home_waiting_for_git { + return None; + } + let bootstrap = self.deferred_repo_bootstrap.take(); let handoff = Arc::new(GitBootstrapHandoff { store: store.clone(), - expected_runtime: store.snapshot().git_runtime.clone(), + expected_runtime: initial_state.git_runtime.clone(), state: Mutex::new(GitBootstrapState::Waiting(bootstrap)), }); let worker = Arc::downgrade(&handoff); @@ -114,7 +136,7 @@ impl PreparedWindowBootstrap { let mut state = handoff.state.lock().unwrap_or_else(|err| err.into_inner()); match std::mem::replace(&mut *state, GitBootstrapState::Attached) { GitBootstrapState::Waiting(bootstrap) => { - self.deferred_repo_bootstrap = Some(bootstrap); + self.deferred_repo_bootstrap = bootstrap; None } GitBootstrapState::Dispatched(runtime) => Some(runtime), @@ -447,6 +469,169 @@ mod tests { .expect("feedback acknowledgments are reduced"); } + #[gpui::test] + fn empty_home_adopts_git_before_attachment_and_renders_usable_actions( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(false, false); + let mut bootstrap = prepare( + &store, + GitCometViewConfig { + workspace: WorkspaceBootstrap::Empty, + ..GitCometViewConfig::normal(None) + }, + session::UiSession { + open_repos: vec![PathBuf::from("/repos/legacy-home-session")], + ..Default::default() + }, + cx, + ); + assert!(!bootstrap.startup_repo_bootstrap_pending); + assert!(bootstrap.deferred_repo_bootstrap.is_none()); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .expect("empty Home must adopt the running Git probe") + .upgrade() + .unwrap(); + assert!( + bootstrap.begin_git_probe_handoff(&store).is_none(), + "preparation must not replace an already-owned handoff" + ); + let runtime = AppState::test_default().git_runtime; + // The main thread remains occupied creating the native window. Home's + // store must become usable without a foreground runtime-report task. + std::thread::scope(|scope| { + scope.spawn(|| handoff.complete(runtime.clone(), &runtime)); + }); + wait_for_state(&store, |state| state.git_runtime == runtime); + handoff.complete(runtime.clone(), &runtime); + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + cx.update(|window, app| { + let view = view.read(app); + assert!(view.state.git_runtime.is_available()); + assert!(view.is_home_screen_active()); + assert!(!view.blocks_repository_management_actions()); + assert!(!view.startup_repo_bootstrap_pending); + assert!(view.deferred_repo_bootstrap.is_none()); + assert!(view.startup_repo_runtime_adoption.is_none()); + let _ = window.draw(app); + }); + for selector in [ + "home_open_repo_action", + "home_clone_repo_action", + "home_init_repo_action", + ] { + assert!( + cx.debug_bounds(selector).is_some(), + "the first attached Home must render usable {selector}" + ); + } + assert!(cx.debug_bounds("repository_loading_screen").is_none()); + assert!(cx.debug_bounds("git_unavailable_screen").is_none()); + handoff.complete(runtime.clone(), &runtime); + shutdown(&store_for_assert); + assert!(store_for_assert.snapshot().repos.is_empty()); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + fn empty_home_rejects_missing_and_stale_git_then_recovers_after_attachment( + cx: &mut gpui::TestAppContext, + ) { + let (store, events, opens) = store(false, false); + let mut bootstrap = prepare( + &store, + GitCometViewConfig::normal(None), + session::UiSession::default(), + cx, + ); + let handoff = bootstrap + .begin_git_probe_handoff(&store) + .unwrap() + .upgrade() + .unwrap(); + let runtime = AppState::test_default().git_runtime; + let mut missing = runtime.clone(); + missing.availability = GitExecutableAvailability::Unavailable { + detail: "Git is unavailable".into(), + }; + handoff.complete(missing.clone(), &missing); + handoff.complete(runtime.clone(), &missing); + let mut other = runtime.clone(); + other.preference = GitExecutablePreference::Custom(PathBuf::from("/other/home-git")); + handoff.complete(other.clone(), &other); + store + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert!(matches!( + store.snapshot().git_runtime.availability, + GitExecutableAvailability::Checking + )); + + let store_for_assert = store.clone(); + let (view, cx) = cx.add_window_view(|window, cx| { + GitCometView::new_with_bootstrap(store, events, bootstrap, window, cx) + }); + // Attachment owns reporting now; even a valid late worker result must + // not make unavailable Git appear usable or change its recovery path. + handoff.complete(runtime.clone(), &runtime); + store_for_assert + .barrier_for_test() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + assert!(!store_for_assert.snapshot().git_runtime.is_available()); + store_for_assert.dispatch(Msg::SetGitRuntimeState(missing)); + wait_for_state(&store_for_assert, |state| { + state.git_runtime.unavailable_detail().is_some() + }); + cx.update(|window, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(view.blocks_repository_management_actions()); + assert!(view.deferred_repo_bootstrap.is_none()); + }); + let _ = window.draw(app); + }); + assert!(cx.debug_bounds("git_unavailable_screen").is_some()); + assert!(cx.debug_bounds("home_open_repo_action").is_none()); + + store_for_assert.dispatch(Msg::SetGitRuntimeState(runtime.clone())); + wait_for_state(&store_for_assert, |state| state.git_runtime == runtime); + cx.update(|window, app| { + view.update(app, |view, cx| { + view.apply_state_snapshot(store_for_assert.snapshot(), cx); + assert!(view.state.git_runtime.is_available()); + }); + let _ = window.draw(app); + // Rendering reconciles the Git-unavailable window gate with the + // recovered snapshot, just as it does for ordinary runtime recovery. + assert!(!view.read(app).blocks_repository_management_actions()); + }); + assert!(cx.debug_bounds("home_open_repo_action").is_some()); + assert!(cx.debug_bounds("git_unavailable_screen").is_none()); + shutdown(&store_for_assert); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + + #[gpui::test] + fn available_empty_home_does_not_wait_for_another_probe(cx: &mut gpui::TestAppContext) { + let (store, _events, opens) = store(true, false); + let mut bootstrap = prepare( + &store, + GitCometViewConfig::normal(None), + session::UiSession::default(), + cx, + ); + assert!(bootstrap.begin_git_probe_handoff(&store).is_none()); + assert!(!bootstrap.startup_repo_bootstrap_pending); + shutdown(&store); + assert_eq!(opens.load(Ordering::SeqCst), 0); + } + #[gpui::test] fn git_probe_handoff_loads_before_attachment_and_dispatches_only_once( cx: &mut gpui::TestAppContext, @@ -1112,7 +1297,12 @@ mod tests { assert!(empty.ui_session.open_repos.is_empty()); assert!(empty.deferred_repo_bootstrap.is_none()); assert!(!empty.startup_repo_bootstrap_pending); - assert!(empty.begin_git_probe_handoff(&store).is_none()); + assert!( + empty.begin_git_probe_handoff(&store).is_some(), + "an empty workspace adopts Git without restoring legacy tabs" + ); + assert!(empty.attach_git_probe_handoff().is_none()); + assert!(empty.deferred_repo_bootstrap.is_none()); let mut focused = prepare( &store, From 71db65d2fc369cf9be417d1d55e87f9066c9bee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 16:34:51 +0300 Subject: [PATCH 46/67] Defer automatic LFS and annex discovery through the initial frame Schedule automatic large-file tool discovery after the initial platform presentation attempt, using the existing next-frame callback lifecycle. GPUI's opening draw is CPU-only and callbacks run before presentation, so two callbacks are required. This is frame ordering, not a GPU completion fence. The previous DX12 capture sampled 128.6 ms of optional detector CPU around first presentation and beyond readiness. These checks do not gate startup and can wait until the first frame. Read the current runtime and tool state when the callback runs; Settings rechecks remain immediate and results still respect invalidation, window ownership, cancellation, and stale-result guards. Tests exercise actual platform frame requests/presents and native process counts, delivery without a later state snapshot, runtime changes, Settings results before release, and closed or dropped views. Existing cancellation and large-file tool lifecycle coverage remains enabled. Validation against GPUI a4ee8500: 29 large-file tool tests pass; two ignored entries are child-process fixtures. App, notification, window-lifecycle and runtime-probe regression groups also pass on Windows with the ci-test profile. --- .../src/view/gitcomet_view.rs | 1 + .../src/view/large_file_tools_probe.rs | 41 ++++++- .../gitcomet-ui-gpui/src/view/state_apply.rs | 9 +- .../src/view/tests/large_file_tools_home.rs | 42 +++++++ .../src/view/tests/large_file_tools_probe.rs | 111 +++++++++++++++++- 5 files changed, 193 insertions(+), 11 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs index 9b11dba31..2e7a3c99d 100644 --- a/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/gitcomet_view.rs @@ -1350,6 +1350,7 @@ impl GitCometView { view.drive_submodule_diff_bootstrap(); runtime_probe::request(cx, false); view.refresh_signing_tools(false, cx); + view.schedule_large_file_tools_after_initial_frame(window, cx); view.sync_workspace_and_registry(cx); if view.is_home_screen_active() { diff --git a/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs index 32d172766..2a65df123 100644 --- a/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/large_file_tools_probe.rs @@ -31,6 +31,7 @@ pub(super) struct LargeFileToolsPublication { /// One window's request lifetime; results continue to live in AppState. #[derive(Default)] pub(super) struct LargeFileToolsProbeOwner { + initial_frame_complete: bool, sequence: u64, closed: bool, runtime: Option, @@ -131,6 +132,30 @@ pub(super) struct HeldLargeFileToolsDetector { } impl GitCometView { + pub(super) fn schedule_large_file_tools_after_initial_frame( + &self, + window: &mut Window, + cx: &gpui::Context, + ) { + let view = cx.weak_entity(); + // GPUI draws once while opening a window without presenting it. Its + // next-frame callbacks run before that platform frame's presentation, + // so one callback would still compete with the first submitted frame. + // A second callback leaves that platform presentation attempt free to + // finish, even when startup is slow. This is not a GPU completion fence: + // a lost device or stale atlas can still require a later presentation. + window.on_next_frame(move |window, _cx| { + window.on_next_frame(move |_window, cx| { + let _ = view.update(cx, |view, cx| { + view.large_file_tools_probe.initial_frame_complete = true; + // Read the current snapshot/runtime: preferences or a + // Settings result may have changed while frames were pending. + view.refresh_large_file_tools(cx); + }); + }); + }); + } + pub(super) fn close_large_file_tools_probe(&mut self) { self.large_file_tools_probe.close(); } @@ -178,13 +203,25 @@ impl GitCometView { current_git_runtime() } - /// Probe `git lfs` / `git annex` once per Git runtime. Not gated by any - /// preference: the answer decides whether their commands are offered. + /// Automatically probe `git lfs` / `git annex` once per Git runtime, after + /// the initial platform frame. Settings rechecks remain immediate and can + /// publish a fresher result without waiting for this automatic discovery. + /// No result is persisted across processes or reused solely by Git version: + /// rechecking Settings still discovers PATH/tool installation changes. pub(super) fn refresh_large_file_tools(&mut self, cx: &mut gpui::Context) { #[cfg(test)] if self.large_file_tools_detector_for_test.is_none() { return; } + if !self.large_file_tools_probe.initial_frame_complete + || self.state.repos.is_empty() + || !matches!( + self.state.large_file_tools.git_lfs, + gitcomet_core::signing_tools::SigningToolAvailability::NotChecked + ) + { + return; + } let runtime = self.large_file_tools_runtime(); if self.state.git_runtime != runtime { return; diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index 941dbe6c2..e9f67cbba 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -665,14 +665,7 @@ impl GitCometView { self.refresh_signing_tools(false, cx); } self.reconcile_large_file_tools_runtime(); - if !self.state.repos.is_empty() - && matches!( - self.state.large_file_tools.git_lfs, - gitcomet_core::signing_tools::SigningToolAvailability::NotChecked - ) - { - self.refresh_large_file_tools(cx); - } + self.refresh_large_file_tools(cx); // Only an open palette shows enablement; `open` takes a fresh context. if self.command_palette_open { let context = self.command_palette_context(cx); diff --git a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs index e94f86a61..877488720 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_home.rs @@ -141,6 +141,10 @@ fn witness( } fn assert_published(view: &Entity, cx: &mut gpui::VisualTestContext) { + cx.update(|window, cx| { + window.simulate_next_frame(cx); + window.simulate_next_frame(cx); + }); pump_until(cx, "native large-file result publication", |cx| { cx.update(|_, app| { let state = view.read(app).store.snapshot(); @@ -155,9 +159,39 @@ fn assert_published(view: &Entity, cx: &mut gpui::VisualTestContex }); } +#[gpui::test] +fn large_file_probe_native_commands_wait_until_after_initial_platform_frame( + cx: &mut gpui::TestAppContext, +) { + let (native, view, cx) = fixture(cx, true); + apply_snapshots(&view, cx); + let handle = cx.update(|window, _| window.window_handle()); + let platform_window = cx.cx.test_window(handle); + assert_eq!(platform_window.presents(), 0); + assert_eq!(witness("before-platform-frame", &native, &view, cx), 0); + platform_window.simulate_frame_request(gpui::RequestFrameOptions { + require_presentation: true, + ..Default::default() + }); + assert_eq!(platform_window.presents(), 1); + apply_snapshots(&view, cx); + assert_eq!(witness("after-first-present", &native, &view, cx), 0); + // No repository completion is needed: this fixture's open stays blocked. + platform_window.simulate_frame_request(gpui::RequestFrameOptions { + require_presentation: true, + ..Default::default() + }); + assert_published(&view, cx); + assert_eq!(witness("following-platform-turn", &native, &view, cx), 2); +} + #[gpui::test] fn large_file_probe_home_defers_automatic_native_detectors(cx: &mut gpui::TestAppContext) { let (native, view, cx) = fixture(cx, false); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + window.simulate_next_frame(cx); + }); apply_snapshots(&view, cx); assert!(cx.update(|_, app| view.read(app).state.repos.is_empty())); if request_count(&view, cx) != 0 { @@ -176,6 +210,10 @@ fn large_file_probe_home_defers_automatic_native_detectors(cx: &mut gpui::TestAp #[gpui::test] fn large_file_probe_home_first_opening_repo_starts_one_native_pair(cx: &mut gpui::TestAppContext) { let (native, view, cx) = fixture(cx, false); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + window.simulate_next_frame(cx); + }); apply_snapshots(&view, cx); if request_count(&view, cx) != 0 { assert_published(&view, cx); @@ -255,6 +293,10 @@ fn large_file_probe_home_settings_publication_prevents_redundant_detection( .dispatch(Msg::OpenRepo(native.root.path().join("repository"))); }); apply_snapshots(&view, cx); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + window.simulate_next_frame(cx); + }); assert_eq!( witness("settings-then-first-repository", &native, &view, cx), 0 diff --git a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs index 0aaa3834c..1fc3781aa 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests/large_file_tools_probe.rs @@ -24,7 +24,9 @@ fn tools(version: &str) -> LargeFileToolsState { } } -fn fixture(cx: &mut gpui::TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { +fn unpresented_fixture( + cx: &mut gpui::TestAppContext, +) -> (Entity, &mut gpui::VisualTestContext) { let (store, events) = AppStore::new_test(Arc::new(TestBackend)); store.dispatch(Msg::SetGitRuntimeState(runtime("A"))); // A repository keeps these request-lifecycle fixtures eligible for automatic @@ -42,6 +44,20 @@ fn fixture(cx: &mut gpui::TestAppContext) -> (Entity, &mut gpui::V }); view.apply_state_snapshot(view.store.snapshot(), cx); }); + assert_eq!(requests(&view, cx).len(), 0); + (view, cx) +} + +fn next_frame(cx: &mut gpui::VisualTestContext) { + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); +} + +fn fixture(cx: &mut gpui::TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { + let (view, cx) = unpresented_fixture(cx); + next_frame(cx); + next_frame(cx); assert_eq!(requests(&view, cx).len(), 1); (view, cx) } @@ -117,6 +133,94 @@ fn stored_tools( cx.update(|_, app| view.read(app).store.snapshot().large_file_tools.clone()) } +#[gpui::test] +fn large_file_probe_waits_for_initial_platform_frame_without_another_snapshot( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = unpresented_fixture(cx); + publish_store(&view, cx); + let handle = cx.update(|window, _| window.window_handle()); + let platform_window = cx.cx.test_window(handle); + assert_eq!(platform_window.presents(), 0); + assert!(requests(&view, cx).is_empty()); + platform_window.simulate_frame_request(gpui::RequestFrameOptions { + require_presentation: true, + ..Default::default() + }); + assert_eq!(platform_window.presents(), 1); + assert!( + requests(&view, cx).is_empty(), + "the first callback runs before the platform presents" + ); + platform_window.simulate_frame_request(gpui::RequestFrameOptions { + require_presentation: true, + ..Default::default() + }); + assert_eq!(requests(&view, cx).len(), 1); + next_frame(cx); + publish_store(&view, cx); + assert_eq!(requests(&view, cx).len(), 1); +} + +#[gpui::test] +fn large_file_probe_frame_gate_uses_the_latest_runtime(cx: &mut gpui::TestAppContext) { + let (view, cx) = unpresented_fixture(cx); + next_frame(cx); + change_runtime(&view, cx, runtime("B")); + publish_store(&view, cx); + assert!(requests(&view, cx).is_empty()); + next_frame(cx); + let pending = requests(&view, cx); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].runtime, runtime("B")); +} + +#[gpui::test] +fn large_file_probe_settings_result_before_frame_prevents_automatic_work( + cx: &mut gpui::TestAppContext, +) { + let (view, cx) = unpresented_fixture(cx); + let queued = cx.update(|_, app| view.read(app).state.clone()); + accept_settings_tools(&view, cx, tools("installed since startup")); + // Even the stale NotChecked snapshot must not restart the settled runtime. + view.update(cx, |view, cx| { + view.apply_state_snapshot(queued, cx); + }); + next_frame(cx); + next_frame(cx); + assert!(requests(&view, cx).is_empty()); + assert_eq!(stored_tools(&view, cx), tools("installed since startup")); +} + +#[gpui::test] +fn large_file_probe_closed_owner_cannot_start_when_frame_gate_opens(cx: &mut gpui::TestAppContext) { + let (view, cx) = unpresented_fixture(cx); + next_frame(cx); + view.update(cx, |view, _| view.close_large_file_tools_probe()); + next_frame(cx); + publish_store(&view, cx); + assert!(requests(&view, cx).is_empty()); +} + +#[gpui::test] +fn large_file_probe_frame_callbacks_do_not_retain_a_closed_view(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new_test(Arc::new(TestBackend)); + let window = cx.add_window(|window, cx| GitCometView::new(store, events, None, window, cx)); + let weak = window + .update(cx, |_, window, cx| { + let weak = cx.weak_entity(); + window.remove_window(); + weak + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|_| {}); + assert!( + weak.upgrade().is_none(), + "pending frame callbacks must not retain the closed view" + ); +} + #[gpui::test] fn large_file_probe_runtime_change_cancels_the_owned_request(cx: &mut gpui::TestAppContext) { let (view, cx) = fixture(cx); @@ -259,6 +363,11 @@ fn large_file_probe_drop_cancels_the_owned_request(cx: &mut gpui::TestAppContext .recv_timeout(Duration::from_secs(2)) .unwrap(); let window = cx.add_window(|window, cx| GitCometView::new(store, events, None, window, cx)); + cx.update_window(window.into(), |_, window, cx| { + window.simulate_next_frame(cx); + window.simulate_next_frame(cx); + }) + .unwrap(); let (request, weak) = window .update(cx, |view, _, cx| { view.large_file_tools_detector_for_test = Some(HeldLargeFileToolsDetector { From 5a48946a1b4a70c86950487467d2097f351486c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 16:37:11 +0300 Subject: [PATCH 47/67] Reuse validated ancestor metadata during watcher input expansion Sibling watcher inputs repeatedly stat the same ordinary directory prefixes. Cache successful proper-ancestor metadata within one expansion, then validate each reused identity before returning. Never cache terminal inputs, missing paths, errors, symlinks, or Windows reparse points. A detected identity change forces the existing setup validation and retry. Stable entries are discarded, so this adds no steady-state polling or delay to native watcher coverage. Preserve the first mismatching observation even when an earlier stamp equals the current replacement, preventing an ABA replacement from concealing the race. The preceding startup trace sampled 127.6 ms in watcher setup, including 53.8 ms under input expansion; inclusive categories overlap. Tests verify fewer metadata calls including validation, input creation and ordinary sibling churn, missing/error re-reads, replacement during expansion, and fresh-pass recovery. The combined release benchmark will measure the elapsed effect. Validation: 202 repository-monitor tests passed on Windows in the ci-test profile; one existing real-repository timing probe remains ignored. The new cache/read-count and race tests pass alongside native lifecycle, recovery, symlink, Windows path-identity, and watcher synchronization regressions. --- .../src/store/repo_monitor/policy.rs | 308 ++++++++++++++++-- 1 file changed, 277 insertions(+), 31 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index 74530007c..8454e7c80 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -232,38 +232,40 @@ enum Stamp { fn stamp(path: &Path) -> Stamp { match fs::symlink_metadata(path) { - Ok(metadata) => { - let directory = metadata.is_dir(); - // A .git directory changes mtime whenever a lock file changes. - // Revalidate its identity, not its entries, to avoid self-reloads. - Stamp::Present { - len: if directory { 0 } else { metadata.len() }, - modified: (!directory).then(|| metadata.modified().ok()).flatten(), - created: metadata.created().ok(), - directory, - link: metadata - .file_type() - .is_symlink() - .then(|| fs::read_link(path).ok()) - .flatten(), - #[cfg(unix)] - inode: std::os::unix::fs::MetadataExt::ino(&metadata), - #[cfg(unix)] - ctime: if directory { - (0, 0) - } else { - ( - std::os::unix::fs::MetadataExt::ctime(&metadata), - std::os::unix::fs::MetadataExt::ctime_nsec(&metadata), - ) - }, - } - } + Ok(metadata) => metadata_stamp(path, &metadata), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Stamp::Missing, Err(error) => Stamp::Failed(error.kind()), } } +fn metadata_stamp(path: &Path, metadata: &fs::Metadata) -> Stamp { + let directory = metadata.is_dir(); + // A .git directory changes mtime whenever a lock file changes. + // Revalidate its identity, not its entries, to avoid self-reloads. + Stamp::Present { + len: if directory { 0 } else { metadata.len() }, + modified: (!directory).then(|| metadata.modified().ok()).flatten(), + created: metadata.created().ok(), + directory, + link: metadata + .file_type() + .is_symlink() + .then(|| fs::read_link(path).ok()) + .flatten(), + #[cfg(unix)] + inode: std::os::unix::fs::MetadataExt::ino(metadata), + #[cfg(unix)] + ctime: if directory { + (0, 0) + } else { + ( + std::os::unix::fs::MetadataExt::ctime(metadata), + std::os::unix::fs::MetadataExt::ctime_nsec(metadata), + ) + }, + } +} + #[derive(Default)] pub(super) struct Stamps(FxHashMap); impl Stamps { @@ -353,8 +355,12 @@ impl WatchInputs { }; let mut links = Vec::new(); let mut incomplete = inputs.info.discovery_incomplete; - inputs.cache_roots = - expand_watch_paths(inputs.info.cache_dirs.clone(), &mut links, &mut incomplete); + inputs.cache_roots = expand_watch_paths( + inputs.info.cache_dirs.clone(), + &mut links, + &mut incomplete, + &mut inputs.stamps, + ); let mut controls = inputs.info.ignore_inputs.clone(); controls.extend(links); controls.extend([workdir.join(".git"), workdir.join(".gitignore")]); @@ -376,7 +382,12 @@ impl WatchInputs { pub fn add_inputs(&mut self, paths: Vec) { let mut links = Vec::new(); - let expanded = expand_watch_paths(paths, &mut links, &mut self.info.discovery_incomplete); + let expanded = expand_watch_paths( + paths, + &mut links, + &mut self.info.discovery_incomplete, + &mut self.stamps, + ); for path in expanded.into_iter().chain(links) { if fs::metadata(&path).is_ok_and(|metadata| !metadata.is_file() && !metadata.is_dir()) { continue; @@ -421,9 +432,27 @@ fn expand_watch_paths( inputs: Vec, links: &mut Vec, incomplete: &mut bool, + stamps: &mut Stamps, +) -> Vec { + expand_watch_paths_with_metadata(inputs, links, incomplete, stamps, |path| { + fs::symlink_metadata(path) + }) +} + +fn expand_watch_paths_with_metadata( + inputs: Vec, + links: &mut Vec, + incomplete: &mut bool, + stamps: &mut Stamps, + mut metadata: impl FnMut(&Path) -> std::io::Result, ) -> Vec { let mut paths = FxHashSet::default(); let mut visited = FxHashSet::default(); + // Sibling inputs share most directory prefixes. Reuse only ordinary + // ancestor directories within this expansion, then validate reused entries + // before returning. Never reuse a missing input, error, symlink, or terminal + // cache entry such as index.lock. + let mut ancestors = FxHashMap::::default(); for input in inputs { let mut current = input; for hop in 0..=64 { @@ -464,7 +493,23 @@ fn expand_watch_paths( let mut target = None; while let Some(component) = components.next() { prefix.push(component); - match fs::symlink_metadata(&prefix) { + let prefix_metadata = if !components.as_path().as_os_str().is_empty() { + if let Some((metadata, reused)) = ancestors.get_mut(&prefix) { + *reused = true; + Ok(metadata.clone()) + } else { + let result = metadata(&prefix); + if let Ok(metadata) = &result + && cacheable_ancestor(metadata) + { + ancestors.insert(prefix.clone(), (metadata.clone(), false)); + } + result + } + } else { + metadata(&prefix) + }; + match prefix_metadata { Ok(metadata) if metadata.file_type().is_symlink() => { links.push(normalized(&prefix)); match fs::read_link(&prefix) { @@ -498,11 +543,42 @@ fn expand_watch_paths( } } } + for (path, (first, reused)) in ancestors { + if !reused { + continue; + } + let first = metadata_stamp(&path, &first); + let current = match metadata(&path) { + Ok(metadata) => metadata_stamp(&path, &metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Stamp::Missing, + Err(error) => Stamp::Failed(error.kind()), + }; + if current != first { + // Preserve a changed first observation so setup retries rather than + // accepting mixed identities. Stable per-call cache entries never + // become extra steady-state polling work. + // An earlier input stamp can already equal `current` after an ABA + // replacement; explicitly retain this mismatching observation. + stamps.0.insert(path, first); + } + } let mut paths: Vec<_> = paths.into_iter().collect(); paths.sort(); paths } +fn cacheable_ancestor(metadata: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return false; + } + } + metadata.is_dir() && !metadata.file_type().is_symlink() +} + #[cfg(test)] mod tests { use super::*; @@ -548,6 +624,7 @@ mod tests { vec![direct.clone(), link.join("../ignore")], &mut links, &mut incomplete, + &mut Stamps::default(), ); // link/../ignore resolves against the link's destination. It is a // different input from root/ignore despite the same lexical spelling. @@ -556,4 +633,173 @@ mod tests { assert!(links.contains(&link)); assert!(!incomplete); } + + #[test] + fn sibling_inputs_share_ancestor_reads_including_validation_without_polling_them() { + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + let requested: Vec<_> = (0..8) + .map(|index| root.join(format!("ignore-{index}"))) + .collect(); + let mut reads = FxHashMap::::default(); + let mut stamps = Stamps::default(); + let mut incomplete = false; + let paths = expand_watch_paths_with_metadata( + requested.clone(), + &mut Vec::new(), + &mut incomplete, + &mut stamps, + |path| { + *reads.entry(path.to_path_buf()).or_default() += 1; + fs::symlink_metadata(path) + }, + ); + assert!(requested.iter().all(|path| paths.contains(path))); + assert_eq!(reads[&root], 2, "one read plus final identity validation"); + assert!( + reads.values().sum::() + < requested.iter().map(|path| path.components().count()).sum(), + "validation must preserve a net reduction in metadata calls: {reads:?}" + ); + assert!(!incomplete); + assert!( + stamps.0.is_empty(), + "stable cached ancestors must not add steady-state stamp reads" + ); + + let mut inputs = WatchInputs::default(); + inputs.add_inputs(requested.clone()); + fs::write(root.join("unrelated.txt"), "ordinary directory churn").unwrap(); + assert!( + !inputs.stamps.changed(), + "creating an unrelated sibling must not invalidate ignore policy" + ); + fs::write(&requested[0], "*.log\n").unwrap(); + assert!( + inputs.stamps.changed(), + "actual input changes still invalidate" + ); + } + + #[test] + fn missing_ancestor_created_during_expansion_is_read_again() { + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + let ancestor = root.join("created-during-expansion"); + let mut ancestor_reads = 0; + let mut stamps = Stamps::default(); + let mut incomplete = false; + let paths = expand_watch_paths_with_metadata( + vec![ancestor.join("first"), ancestor.join("second")], + &mut Vec::new(), + &mut incomplete, + &mut stamps, + |path| { + let result = fs::symlink_metadata(path); + if path == ancestor { + ancestor_reads += 1; + if ancestor_reads == 1 { + assert_eq!( + result.as_ref().unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + fs::create_dir(&ancestor).unwrap(); + } else { + assert!(result.as_ref().unwrap().is_dir()); + } + } + result + }, + ); + assert_eq!(ancestor_reads, 2, "missing ancestors are never cached"); + assert!(paths.contains(&ancestor.join("first"))); + assert!(paths.contains(&ancestor.join("second"))); + assert!(!incomplete); + assert!(!stamps.changed()); + } + + #[test] + fn failed_ancestor_metadata_is_not_reused() { + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + let mut failures = 0; + let mut incomplete = false; + expand_watch_paths_with_metadata( + vec![root.join("first"), root.join("second")], + &mut Vec::new(), + &mut incomplete, + &mut Stamps::default(), + |path| { + if path == root { + failures += 1; + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + } else { + fs::symlink_metadata(path) + } + }, + ); + assert_eq!(failures, 2); + assert!( + incomplete, + "errors must retain degraded discovery semantics" + ); + } + + #[test] + fn ancestor_replaced_by_symlink_invalidates_cached_pass_and_next_pass_resolves_it() { + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + let ancestor = root.join("ancestor"); + let destination = root.join("destination"); + fs::create_dir(&ancestor).unwrap(); + fs::create_dir(&destination).unwrap(); + let incoming = root.join("incoming-link"); + if !gitcomet_core::test_support::symlink::directory(&destination, &incoming) { + return; + } + let requested = vec![ancestor.join("first"), ancestor.join("second")]; + let mut replaced = false; + let mut stamps = Stamps::default(); + // A previously stamped input can equal the eventual current identity + // even though this pass observes an intervening ordinary directory. + // Local invalidation must override that old stamp rather than let it + // conceal the mismatch. + let replacement_stamp = stamp(&incoming); + stamps.0.insert(ancestor.clone(), replacement_stamp.clone()); + let mut incomplete = false; + expand_watch_paths_with_metadata( + requested.clone(), + &mut Vec::new(), + &mut incomplete, + &mut stamps, + |path| { + let result = fs::symlink_metadata(path); + if path == requested[0] && !replaced { + fs::rename(&ancestor, root.join("retired-ancestor")).unwrap(); + fs::rename(&incoming, &ancestor).unwrap(); + replaced = true; + } + result + }, + ); + assert!(replaced); + assert!(!incomplete); + assert_eq!(stamp(&ancestor), replacement_stamp); + assert!( + stamps.changed(), + "setup must reject an expansion that reused a replaced ancestor" + ); + + let mut fresh_stamps = Stamps::default(); + let mut links = Vec::new(); + let paths = expand_watch_paths(requested, &mut links, &mut incomplete, &mut fresh_stamps); + assert!(links.contains(&ancestor)); + assert!(paths.contains(&destination.join("first"))); + assert!(paths.contains(&destination.join("second"))); + assert!( + !fresh_stamps.changed(), + "an independent invocation must accept the stable replacement" + ); + assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); + } } From 8bd06006d90b96026611928d15663371ceb0d781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 16:44:19 +0300 Subject: [PATCH 48/67] Exercise watcher ancestor replacement without Windows symlink privileges Fall back to an NTFS directory junction in the new cached-ancestor replacement test when Windows denies symlink creation. Keep every identity, ABA, retry, resolved-path, and fresh-pass assertion, and print an explicit executed marker. The fixture command uses fixed relative names in its temporary directory. An explicit nocapture rerun revealed that Windows privilege error 1314 had previously bypassed this test body. The fallback now executes the complete race on this host as directory-junction. All six policy tests pass; production code is unchanged. --- .../src/store/repo_monitor/policy.rs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index 8454e7c80..f7e8cd68c 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -754,9 +754,30 @@ mod tests { fs::create_dir(&ancestor).unwrap(); fs::create_dir(&destination).unwrap(); let incoming = root.join("incoming-link"); - if !gitcomet_core::test_support::symlink::directory(&destination, &incoming) { - return; - } + let fixture_kind = + if gitcomet_core::test_support::symlink::directory(&destination, &incoming) { + "directory-symlink" + } else { + #[cfg(windows)] + { + // Fixed relative names avoid shell interpolation and a + // junction exercises the reparse race without privileges. + let result = std::process::Command::new("cmd") + .current_dir(&root) + .args(["/d", "/c", "mklink", "/J", "incoming-link", "destination"]) + .output() + .unwrap(); + assert!( + result.status.success(), + "junction fixture failed: {result:?}" + ); + "directory-junction" + } + #[cfg(not(windows))] + { + unreachable!("only Windows may lack the symlink fixture privilege") + } + }; let requested = vec![ancestor.join("first"), ancestor.join("second")]; let mut replaced = false; let mut stamps = Stamps::default(); @@ -801,5 +822,6 @@ mod tests { "an independent invocation must accept the stable replacement" ); assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); + println!("WATCH_PREFIX_REPLACEMENT_RACE exercised=true kind={fixture_kind}"); } } From 86d78e6adb2e6a4b1939a235e0d3fa8c02bbd2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 17:53:19 +0300 Subject: [PATCH 49/67] Batch startup diagnostic records to avoid redirected stderr stalls Format each startup trace and allocation payload into a complete JSON line before write_all. Preserve immediate trace visibility, successful stderr flushing, JSON escaping, timestamps, and every existing readiness predicate. Also record startup milestones on the UI journal's anchored clock before synchronous diagnostic output. A Windows experiment using the real serde_json formatter counted 59 writes per stderr payload and 44 per trace record. With PowerShell redirection, the interval between adjacent callback markers reached 29.709 ms without GPUI. Batching both records reduced the median from 3.406 to 0.079 ms. These are isolated logging measurements, not a normal application startup speedup. Regression coverage checks complete escaped JSON, a single full-write sink call per record, flushing, timestamp preservation, and file visibility before shutdown. Focused ci-test validation passed all three startup_trace tests and all eight startup_probe tests; formatting and whitespace checks passed. Production application timings remain tied to the frozen 8bd06006 binary; its rendering and readiness behavior is unchanged by this fix. --- crates/gitcomet-base/src/startup_trace.rs | 58 +++++++++++++++-- crates/gitcomet-ui-gpui/src/startup_probe.rs | 68 ++++++++++++++++++-- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/crates/gitcomet-base/src/startup_trace.rs b/crates/gitcomet-base/src/startup_trace.rs index 371d6fbb7..3b4002621 100644 --- a/crates/gitcomet-base/src/startup_trace.rs +++ b/crates/gitcomet-base/src/startup_trace.rs @@ -6,7 +6,7 @@ //! environment values or command arguments are recorded. use std::fs::{File, OpenOptions}; -use std::io::Write as _; +use std::io::Write; use std::sync::{Mutex, OnceLock}; use std::time::Instant; @@ -80,11 +80,18 @@ impl Trace { thread.name(), ); if let Ok(mut file) = self.file.lock() { - let _ = writeln!(file, "{record}"); + let _ = write_record(&mut *file, &record); } } } +fn write_record(writer: &mut impl Write, record: &serde_json::Value) -> std::io::Result<()> { + // Value's Display implementation streams many tiny writes. Assemble one + // complete record so tracing does not add a filesystem call per JSON token. + let line = format!("{record}\n"); + writer.write_all(line.as_bytes()) +} + fn record( kind: &str, name: &str, @@ -123,8 +130,12 @@ mod tests { origin: Instant::now(), file: Mutex::new(temporary.reopen().unwrap()), }; - trace.write("begin", "font\"catalog", None); - trace.write("end", "font\"catalog", Some(2.0)); + trace.write("begin", "font\"catalog\n日本語", None); + let first = std::fs::read_to_string(temporary.path()).unwrap(); + assert_eq!(first.lines().count(), 1); + let first: serde_json::Value = serde_json::from_str(first.trim_end()).unwrap(); + assert_eq!(first["name"], "font\"catalog\n日本語"); + trace.write("end", "font\"catalog\n日本語", Some(2.0)); let contents = std::fs::read_to_string(temporary.path()).unwrap(); let rows: Vec = contents .lines() @@ -132,8 +143,45 @@ mod tests { .collect(); assert_eq!(rows.len(), 2); assert_eq!(rows[0]["kind"], "begin"); - assert_eq!(rows[0]["name"], "font\"catalog"); + assert_eq!(rows[0]["name"], "font\"catalog\n日本語"); assert!(rows[0]["duration_ms"].is_null()); assert_eq!(rows[1]["duration_ms"], 2.0); } + + #[test] + fn trace_serializes_one_complete_record_per_write_without_changing_timestamps() { + #[derive(Default)] + struct Writes(Vec>); + impl std::io::Write for Writes { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.push(bytes.to_vec()); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let value = record( + "end", + "quote\" slash\\ newline\n雪", + 125.125, + Some(95.25), + Some("worker\tthread"), + ); + let mut writer = Writes::default(); + write_record(&mut writer, &value).unwrap(); + assert_eq!( + writer.0.len(), + 1, + "JSON tokens must not cause separate writes" + ); + let bytes = &writer.0[0]; + assert_eq!(bytes.last(), Some(&b'\n')); + assert_eq!(bytes.iter().filter(|byte| **byte == b'\n').count(), 1); + let parsed: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + assert_eq!(parsed, value); + assert_eq!(parsed["elapsed_ms"], 125.125); + assert_eq!(parsed["duration_ms"], 95.25); + } } diff --git a/crates/gitcomet-ui-gpui/src/startup_probe.rs b/crates/gitcomet-ui-gpui/src/startup_probe.rs index 1849deffc..787b4c7b5 100644 --- a/crates/gitcomet-ui-gpui/src/startup_probe.rs +++ b/crates/gitcomet-ui-gpui/src/startup_probe.rs @@ -1,7 +1,7 @@ use gitcomet_state::model::{AppState, Loadable}; use serde_json::{Map, Value, json}; use std::env; -use std::io::Write as _; +use std::io::Write; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -162,6 +162,7 @@ fn mark_once(event: StartupProbeEvent, emitted: &AtomicBool) -> bool { } fn emit_event(event: StartupProbeEvent) { + crate::ui_probe::scenario_record("startup_milestone", json!({"name": event.as_str()})); gitcomet_core::startup_trace::mark(event.as_str()); let mut payload = Map::new(); payload.insert("name".to_string(), json!(event.as_str())); @@ -185,8 +186,18 @@ fn emit_repo_progress(repos_loaded: usize, repos_total: usize) { fn emit_payload(payload: serde_json::Value) { let mut stderr = std::io::stderr().lock(); - let _ = writeln!(stderr, "{EVENT_PREFIX}{payload}"); - let _ = stderr.flush(); + let _ = write_payload_record(&mut stderr, &payload); +} + +fn write_payload_record( + writer: &mut impl Write, + payload: &serde_json::Value, +) -> std::io::Result<()> { + // Formatting Value directly into stderr sends each JSON token separately. + // In particular, redirected Windows pipes can stall between those writes. + let line = format!("{EVENT_PREFIX}{payload}\n"); + writer.write_all(line.as_bytes())?; + writer.flush() } fn count_ready_repos(state: &AppState) -> usize { @@ -233,13 +244,62 @@ fn current_rss_kib() -> Option { #[cfg(test)] mod tests { use super::{ - StartupProbeConfig, active_repo_ready, count_ready_repos, pack_repo_progress, should_exit, + EVENT_PREFIX, StartupProbeConfig, active_repo_ready, count_ready_repos, pack_repo_progress, + should_exit, write_payload_record, }; use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{AppState, Loadable, RepoId, RepoState}; use gitcomet_ui_kit::ui_probe::parse_bool_flag; use std::path::PathBuf; + #[test] + fn startup_payload_is_one_complete_flushed_write_with_escaped_json_preserved() { + #[derive(Default)] + struct Writes { + records: Vec>, + flushes: usize, + } + impl std::io::Write for Writes { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.records.push(bytes.to_vec()); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + self.flushes += 1; + Ok(()) + } + } + + let payload = serde_json::json!({ + "name": "first_interactive", + "rss_kib": null, + "alloc_bytes": 0, + "note": "quote\" slash\\ newline\n雪", + }); + let mut writer = Writes::default(); + write_payload_record(&mut writer, &payload).unwrap(); + assert_eq!( + writer.records.len(), + 1, + "JSON tokens must not cause separate writes" + ); + assert_eq!(writer.flushes, 1, "publish the complete record immediately"); + let line = std::str::from_utf8(&writer.records[0]).unwrap(); + assert!(line.ends_with('\n')); + assert_eq!(line.lines().count(), 1); + let parsed: serde_json::Value = + serde_json::from_str(line.strip_prefix(EVENT_PREFIX).unwrap()).unwrap(); + assert_eq!(parsed, payload); + + let home = serde_json::json!({"name": "home_ready", "rss_kib": null}); + write_payload_record(&mut writer, &home).unwrap(); + assert_eq!(writer.records.len(), 2); + assert_eq!(writer.flushes, 2, "do not buffer milestones until shutdown"); + let parsed: serde_json::Value = + serde_json::from_slice(&writer.records[1][EVENT_PREFIX.len()..]).unwrap(); + assert_eq!(parsed, home); + } + #[test] fn parse_bool_flag_accepts_common_truthy_values() { for value in ["1", "true", "TRUE", "Yes", "on"] { From c4b00cb7731bb538a0f0d2586d0f1555ffd46b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 17:53:19 +0300 Subject: [PATCH 50/67] Document Windows startup fixes, 496 ms confirmation, and timing variance Record the opaque surface, coalesced resize, lazy filter, early Home adoption, deferred optional-tool, and validated watcher-metadata changes with their source revisions, frozen executable/PDB hashes, and test evidence. The later five-pair DX12 confirmation improves every pair: median draw 481.0 -> 440.5 ms, submission 561.2 -> 481.0 ms, and repository readiness 566.7 -> 495.8 ms. Only three candidates are ready below 500 ms; the range is 466.5-512.5 ms. Preserve the earlier 707.2 ms candidate regression separately, because its slow renderer state was not reproduced or causally explained. Include valid CPU/graphics evidence, separate watcher and Home diagnostics, the measured diagnostic-output overhead, and the bounded 3/4/6/8-worker experiment that does not justify changing the three-participant limit. Keep app callback, process-spawn, and ETW clock origins distinct, and identify status, renderer work, and observer polling as separate remaining costs. --- docs/windows-startup-performance.md | 281 +++++++++++++++++++++++++--- 1 file changed, 254 insertions(+), 27 deletions(-) diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index 4743e635a..f5b9d22a2 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -4,7 +4,197 @@ Measured on October 20, 2026 with a Ryzen AI 9 HX 370 and Radeon 890M. DX12 remains the default; DX11 was used for diagnostic comparisons and compatibility checks. -The combined startup changes reached median **540.6 ms first CPU draw**, +The latest completed application changes use opaque Windows surfaces, coalesce +startup resizes, defer unused filter shaders and optional-tool discovery, adopt +Git discovery before attaching Home, and reuse validated watcher ancestor +metadata. DX12 remains the default. + +The final five-pair confirmation session reached median **440.5 ms first CPU +draw**, **481.0 ms first platform submission**, and **495.8 ms repository +readiness**. All five pairs improved. Three of five candidate runs reached ready +below 500 ms; the range was **466.5–512.5 ms**. This is not a reliable sub-500 ms +guarantee: an earlier session with the same binaries regressed, and its slower +renderer-construction state was not reproduced in the successful CPU capture. +Both sessions are retained below. + +The measured candidate is GitComet +`8bd06006d90b96026611928d15663371ceb0d781`, pinned to GPUI +`a4ee85008357c2e8e659bf8383ccae65ab62f94f`. All comparisons reused OS/driver +caches; reboot-cold startup was not measured. Callback, CPU-submission, and ETW +Present times do not prove physical display scanout. + +## Opaque surfaces, deferred work, and Home adoption + +The implementation consists of these committed changes: + +- `afcb771a`: opaque Windows main and Settings windows. The user inspected and + accepted the appearance. The explicit background diagnostic override remains. +- `c6f19db7`: pin GPUI to checked startup-resize coalescing and demand-driven + filter pipelines. Initial configuration/fallback remains checked, runtime + resizes remain immediate, and pending dimensions survive renderer recovery. + Ordinary direct frames and retained texture copies do not create the filter + group. Actual blur, opacity groups, or a retained shader blit create it on + first use. Unsupported external-surface integrations omit their pipeline. +- `ebcf9d4e`: empty Home shares the completed background Git-probe handoff, with + the same stale-result and exactly-once attachment guards as repository + bootstrap. Its readiness predicate is unchanged. +- `71db65d2`: automatic LFS/annex discovery starts after the initial platform + presentation attempt. This is frame-loop ordering, not a GPU/display fence. + Settings rechecks, runtime invalidation, and descendant cancellation remain. +- `5a48946a`: watcher expansion reuses successful ordinary ancestor metadata + within one expansion and revalidates reused identities. Terminal paths, + missing/error results, symlinks, and Windows reparse points are not cached. + Stable cache entries are discarded; native watch coverage is not deferred. +- `8bd06006`: the new watcher replacement-race test uses a directory junction + when Windows does not grant symlink creation privileges, retaining all race + and invalidation assertions. + +GPUI commits `5fbcd42c`, `ccbedc27`, and `a4ee8500` are pushed to +`lazy_load_shaders` in the GPUI worktree. The GitComet branch has not been pushed. + +## Repository measurements: both sessions retained + +Each session alternates five pairs of the previous frozen `7ebb3e91` / +`164485d7` binary and the new `8bd06006` / `a4ee8500` binary. Both arms use +DX12/FXC, identical release-with-debug settings, fresh app profiles, and the +desktop startup scenario. All twenty runs are valid and use early repository +bootstrap. No compilation or sampling profiler ran during measurement. + +| Median, five runs per arm | Earlier baseline | Earlier candidate | Confirmation baseline | Confirmation candidate | +| --- | ---: | ---: | ---: | ---: | +| Spawn to first CPU draw | 516.0 ms | 644.9 ms | 481.0 ms | 440.5 ms | +| Spawn to first submission | 596.3 ms | 705.9 ms | 561.2 ms | 481.0 ms | +| First submission duration | 78.8 ms | 47.5 ms | 70.6 ms | 32.7 ms | +| Spawn to repository ready | 655.3 ms | 707.2 ms | 566.7 ms | 495.8 ms | +| Candidate ready range | — | 557.9–726.6 ms | — | 466.5–512.5 ms | + +The earlier median paired ready change was **+67.2 ms**. In confirmation it was +**−64.9 ms**, with every pair improving by 54.4–99.8 ms. Three confirmation +candidates were ready below 500 ms and four submitted below 500 ms. Independent +medians and median paired changes are different statistics. + +Separate three-pair stage diagnostics reproduced the earlier slowdown before +renderer construction: open-window-to-renderer increased from 334.3 to +558.9 ms, while renderer-to-view initialization fell from 70.3 to 27.5 ms and +submission duration fell from 78.3 to 39.0 ms. These intervals include native +window/driver/pipeline work and do not identify a specific cause. + +A subsequent three-pair control launched the *same candidate executable* from +its frozen path and the profiler's copied path. Both were fast: ready medians +499.7 and 492.8 ms. This does not establish a fixed-path effect. The successful +ETW capture and later five-pair confirmation also did not reproduce the slow +state. Temperature, frequency, driver-cache state, and competing workload were +not recorded sufficiently to assign the earlier regression to any of them. +The results are neither pooled nor selectively discarded. + +Repository readiness requires open/status/history readiness and a laid-out +history viewport; the harness polls at 50 ms intervals. It does not require the +entire indexed history in the startup scenario. It is a usable-content +measurement with polling delay, not a timestamp for the exact last worker. +Status is the last required data publication in all five confirmation runs. +In pairs 1/4/5 it arrives after the first CPU draw and reaches the view after +first submission. In pairs 2/3, which miss 500 ms, status is already available +at the first state application. Both content readiness and rendering matter; +shorter status alone would not resolve those two misses. + +## Home and diagnostic logging overhead + +The original three-pair Home diagnostics retained one severe candidate outlier: +baseline Home-ready markers were 588.4/574.8/558.0 ms and candidates were +926.8/524.9/486.1 ms. A later three-pair confirmation improved all pairs, +641.8/586.4/612.4 → 544.2/519.3/581.2 ms. These are app-trace times, not +spawn-to-ready times. + +Further investigation found that the callback gap after `first_interactive` +does not necessarily represent another frame. When Home is already ready, +`first_interactive` and `home_ready` run sequentially in the same callback. +Synchronous diagnostic output between them can inflate the interval. +The real serde_json emission experiment measured 59 writes per stderr payload +and 44 per trace record. Under PowerShell redirected stderr, a callback gap +reached 29.7 ms without GPUI; formatting each complete record before writing +reduced the experiment's median from 3.406 to 0.079 ms. + +The diagnostic code now formats each complete JSON line before `write_all`, +while preserving immediate record visibility, flushing, escaping, and recorded +timestamps. Startup milestones also enter the UI journal before synchronous +output, so future captures can use its existing process-spawn clock anchor. +Readiness conditions are unchanged. The application timings in this document +use the frozen `8bd06006` binary, before this diagnostic-only correction; +the emission experiment is separate evidence, not a rebuilt-app speedup. +The correction passed all three startup-trace tests and all eight startup-probe +tests in the cached `ci-test` build. + +An additional frozen-binary comparison used the existing Python harness's +direct stderr file and seeded empty profiles. Baseline Home-ready markers were +433.4/442.1/456.2 ms; candidates were **343.6/364.8/368.7 ms**. The candidate +interactive-to-Home interval was only 0.189–0.207 ms. This supports the early +Home handoff and identifies diagnostic I/O as a source of inflated callback +gaps. The Python profiles also disable background update checks, seed survey +preferences, and set window geometry, so the entire PowerShell-versus-Python +latency difference must not be attributed to stderr alone. + +The earlier repository benchmark did not enable these startup-stage markers. +The logging finding therefore does **not** explain away its regression. + +## Fresh trace and watcher verification + +The successful `samply-lean-startup-final` capture used the frozen candidate, +matching PDB, foreground DX12, 1 kHz CPU sampling, scheduling events, and graphics +events. Two preceding elevation-helper failures recorded no valid trace and +are excluded. The fixed analysis window is process age 0–1100 ms. + +| Inclusive sampled CPU | Previous capture | New capture | +| --- | ---: | ---: | +| Native shader compiler, all app threads | 447.2 ms | 402.1 ms | +| Main-thread compiler | 169.8 ms | 164.6 ms | +| Watcher thread | 127.6 ms | 100.1 ms | +| Watcher input reload | 74.9 ms | 34.5 ms | + +These nested categories overlap and are not additive elapsed savings. Ordinary +pipeline creation accounts for 383.1 ms of the new compiler CPU across three +participants; device-internal compilation and earlier context work account for +the remainder. Both pipeline workers finish before the main compiler tail. +The trace has no long new main-thread join delay and no positive samples of +the separate DX11 device constructor. + +A separate bounded-worker experiment compared 3/4/6/8 participants across four +alternating rounds, each with a fresh hardware DX12 device and the same eight +ordinary pipelines. With release-equivalent shader flags (no DEBUG/VALIDATION; +VALIDATION_INDIRECT_CALL retained), pipeline-construction medians were +195.7/195.2/199.5/198.4 ms. The first three-worker run was 258.5 ms and is retained. +The quad pipeline finished last in all sixteen runs. Increasing worker count +provided no convincing benefit, so the production limit stays at three. +The test host code was still a debug build and spent about 56 ms parsing/setup, +so these are relative diagnostic results, not release application latencies or +proof of the release trace's longest pipeline label. The temporary experiment +patch and logs are archived locally; no additional GPUI change was shipped. + +The new trace reaches first CPU draw at 526.3 ms, first submission at 561.0 ms, +and scenario readiness at 602.9 ms after spawn. Status finishes at 507.6 ms, +13.4 ms before the first draw starts and 53.4 ms before submission. Overlapping +the roughly 115 ms gitlink child with the base status walk could shorten status, +but no end-to-end saving is established while renderer/viewport work finishes +later. First successful ETW Present is at 556.2561 ms process age with status +zero. Automatic LFS/annex probes start after that first Present. + +Separate watcher diagnostics show one setup attempt and one policy revalidation +in every run, with no retry/rebuild loop. The sum of setup phase wall times +falls from median 233.6 to 192.3 ms across three pairs. Input expansion improves +while directory discovery is slower; these concurrent wall intervals include +scheduling and do not establish per-phase CPU savings. + +The application validation reports 402 passes, with three explicit ignored +fixtures. Existing unrelated symlink-only tests may self-skip without Windows +privileges; the new replacement race explicitly executed via a directory +junction. GPUI validation passed 63 Windows tests, 66 WGPU default tests plus +five integration tests, and 67 surface-enabled tests plus five integration +tests. Default strict Clippy passed; the surface-enabled run narrowly allowed +two verified pre-existing warnings in unchanged code. DX12 history navigation +and Settings, and explicit DX11 Settings, passed on the frozen application. + +## Historical handoff and DX11 deferral result + +The preceding combined startup changes reached median **540.6 ms first CPU draw**, **646.6 ms first platform submission**, and **658.8 ms repository readiness** over five runs. Readiness improved in every pair, but first-frame submission became slower in four of five pairs. The 500 ms target has not been achieved. @@ -212,11 +402,12 @@ Opaque mode reduced submission duration from a median 86.5 to 21.9 ms. All five pairs improved, with a median paired duration saving of 61.1 ms. Launch-to-submit improved in four of five pairs by a median 47.4 ms. The four deferred/deferred pairs saved -53.9 ms submission but regressed ready by 12.1 ms. Opaque mode changes the -desktop-visible pixels around GitComet's inset rounded frame, so it is not a -visually equivalent shipping fix. GPUI starts opaque, then reconfigures and -rebuilds pipelines for the requested transparency. Passing the intended initial -appearance upstream is the relevant follow-up. +53.9 ms submission but regressed ready by 12.1 ms. Opaque mode can change the +desktop-visible pixels around GitComet's inset rounded frame. The user later +inspected this mode, saw no unwanted difference, and accepted it as the default +in `afcb771a`. This avoids starting opaque and then reconfiguring/rebuilding for +transparency. The table above records the earlier diagnostic, before that +product decision. ## Completed Git-probe handoff @@ -452,27 +643,26 @@ source are retained in the local provenance artifacts. ## Remaining work -The next priorities are: - -- Instrument ordinary pipeline requests and DX12 device initialization - separately, then evaluate background-task scheduling with a controlled - comparison. The new trace shows overlap and renderer-gated readiness, but - does not establish how much waiting or CPU time scheduling can remove. -- Coalesce intermediate startup surface resizes while preserving final size, - runtime resizing, readback, and recovery. Pass the intended initial background - appearance upstream to avoid the opaque-to-transparent rebuild without - changing the frame. These changes overlap; their potential gains cannot be - added. -- Investigate repeated watcher lookups and optional-tool overlap without - sacrificing watch coverage, descendant cancellation, or eventual capability - checks. Prioritize the renderer for this startup case: the populated gitlink - supplement remains real work, but status finishes before the first frame. -- Resolve the separate empty-Home readiness regression with callback/frame - timings before attributing it to a particular renderer operation. - -The combined candidate needs about 147 ms less elapsed time for 500 ms -submission and 159 ms for 500 ms ready. These are target gaps, not predicted -savings, and overlapping changes must not be added into a projected time. +The resize, opaque-background, optional-tool, watcher, and early-Home changes +described above are implemented and tested. The remaining priorities are: + +- Establish more margin below 500 ms and understand the earlier slower renderer + state. The later median alone does not establish consistent startup. +- Attribute individual pipelines in a release application capture before + changing shader code. The bounded 3/4/6/8-worker experiment is complete and + does not justify changing the current limit. +- Revisit status overlap when it delays first displayed content. It arrives + after the first CPU draw in three confirmation runs, although it is already + ready before rendering in both runs that miss 500 ms. +- Keep remaining shader deferral tied to actual scene requirements. Home uses + a colored image; repository tabs and frame corners use paths. Shadows can be + required by light-theme controls and focus rings. +- Treat diagnostic logging overhead separately from application work, and keep + Home callback times separate from spawn-to-submit and repository-ready times. + +The final confirmation median is below 500 ms, but its slowest candidate is +12.5 ms above it. The earlier session still required more than 200 ms of +improvement. These are observed gaps, not predicted additive savings. Public `wgpu::PipelineCache` in version 29.0.4 supports Vulkan only, so it does not provide persistent DX12 shader caching. More shader deferral requires evidence that the initial frame does @@ -522,6 +712,43 @@ The historical parallel experiment adds `parallel-runtime/`, `repo-parallel-pair `parallel-stage-*` traces, and `parallel-compiled-source/`. Their availability is local; this document records the durable measurements. +The latest implementation is frozen in `lean-startup-runtime/` with source +revision `8bd06006`, clean patch, source hashes, and successful build log +`lean-startup-release-build.log`. Executable SHA256: +`d62aa0087d9e9e6e3925b3d35a46ab2c0898e41dd2c0a2e4b496d62aace20584`. +PDB SHA256: +`9c0f0a8165364dfb03b6cbc20536e704b87bb0164bc8cb447bc1025bfc0cb7e9`. +Its evidence includes: + +- `lean-startup-comparison.json` and `repo-lean-startup-pairs/session.json`, + session `b72848e8-ca7a-453f-98ff-fd6965ab6779`: earlier regression. +- `lean-confirmation-comparison.json` and `repo-lean-confirmation/session.json`, + session `a4beaa49-9fb9-4ca5-8f72-e65bdf7f92f3`: final five-pair confirmation. +- `lean-confirmation-readiness-findings.txt` and + `lean-confirmation-readiness-evidence.json`: per-run data/publication/frame + dependencies and threshold counts. +- `lean-stage-home-comparison.json`, `lean-stage-home-findings.txt`, + `lean-confirm-home-*`, and `lean-home-file-*`: separate stage/Home diagnostics. +- `lean-path-control-comparison.json` and `lean-dx11-control-comparison.json`: + separate path and renderer controls. +- `lean-watcher-comparison.json` and `lean-watcher-findings.txt`: setup phases, + attempts, and reload coverage. +- `samply-lean-startup-final/`: valid capture, matching symbols, ETLs, + foreground evidence, CPU analysis, renderer findings, and + `status-readiness-decision.txt`. The adjacent stage trace is + `samply-lean-startup-final-stage.jsonl`. +- `gpui-final-startup-validation.txt` and `lean-startup-app-validation.txt`: + test commands, complete logs, source hashes, fixture corrections and limits. +- `lean-startup-history-jump/`, `lean-startup-settings/`, and + `lean-startup-explicit-dx11/`: successful frozen-binary live checks. +- `probe-emission/run-20261020-144202-187/summary.json`: isolated serde_json + write-count and redirection experiment. +- `probe-emission/validation-20261020-144838/`: diagnostic correction's successful + build, eleven focused test results, and source/executable hashes. +- `gpui-worker-count-release-flags.log`: bounded-worker control using shipping + native shader compiler flags. The earlier `gpui-worker-count-diagnostic.log` + uses debug flags and is retained separately. + The clean pin and runtime comparisons are `pinned-final-comparison.json`, `pinned-dxc-comparison.json`, and `pinned-opaque-comparison.json`, backed by `repo-pinned-final-pairs/session.json`, `repo-pinned-dxc-pairs/session.json`, From 29171fa38f29517457810362b26fcb97aaf2fd6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Tue, 20 Oct 2026 21:24:30 +0300 Subject: [PATCH 51/67] use latest gpui-ce fork --- Cargo.lock | 80 ++++++------- Cargo.toml | 12 +- .../assets/open_source_licenses.tsv | 63 ++-------- .../src/view/panels/bottom_status_bar.rs | 4 +- crates/gitcomet-ui-gpui/src/view/rows/mod.rs | 4 +- .../gitcomet-ui-gpui/src/view/rows/status.rs | 4 +- .../src/components/picker_prompt.rs | 2 +- .../src/components/text_fade.rs | 2 +- crates/gitcomet-ui-kit/src/interaction.rs | 6 +- crates/gitcomet-ui-kit/src/test_support.rs | 53 ++++++++- .../gitcomet-ui-kit/src/text_input/render.rs | 4 +- .../gitcomet-ui-kit/src/window_chrome/mod.rs | 2 +- docs/windows-startup-performance.md | 108 ++++++++++++++++++ 13 files changed, 228 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56ab5ae2e..a11ffa281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,9 +1306,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "block", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4109,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "gpui-ce", "naga 24.0.0", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "schemars", "serde", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "log", @@ -4220,7 +4220,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "anyhow", "core-foundation", @@ -4275,7 +4275,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "accesskit", "accesskit_windows", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -4982,9 +4982,9 @@ dependencies = [ [[package]] name = "json5" -version = "1.3.2" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a3f1d413a994290ebe4f96047763a84d04a1f31492c51328402986d7fe5fa" +checksum = "6efe947c4a8f66438994e559a1e1309ae2a315cabf67476f155e27281f11cbc1" dependencies = [ "serde", "ucd-trie", @@ -5204,9 +5204,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" dependencies = [ "lyon_path", "num-traits", @@ -5214,9 +5214,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" dependencies = [ "arrayvec", "euclid", @@ -5235,9 +5235,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" dependencies = [ "float_next_after", "lyon_path", @@ -7124,9 +7124,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -7471,9 +7471,9 @@ dependencies = [ [[package]] name = "smol_str" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" dependencies = [ "borsh", "serde_core", @@ -7944,9 +7944,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "pin-project-lite", ] @@ -9426,9 +9426,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -10092,7 +10092,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "encoding_rs", ] @@ -10100,7 +10100,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=a4ee85008357c2e8e659bf8383ccae65ab62f94f#a4ee85008357c2e8e659bf8383ccae65ab62f94f" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index 1370dd469..78d0959c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "a4ee85008357c2e8e659bf8383ccae65ab62f94f" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv index cf4e4bc88..538796645 100644 --- a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv +++ b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv @@ -85,7 +85,6 @@ bit_field 0.10.3 Apache-2.0/MIT bitflags 1.3.2 MIT/Apache-2.0 bitflags 2.13.2 MIT OR Apache-2.0 bitstream-io 4.10.0 MIT/Apache-2.0 -block 0.1.6 MIT block-buffer 0.10.4 MIT OR Apache-2.0 block-buffer 0.12.1 MIT OR Apache-2.0 block-padding 0.3.3 MIT OR Apache-2.0 @@ -147,7 +146,7 @@ core_detect 1.0.0 MIT/Apache-2.0 core_maths 0.1.1 MIT cpufeatures 0.2.17 MIT OR Apache-2.0 cpufeatures 0.3.1 MIT OR Apache-2.0 -crc32fast 1.5.2 MIT OR Apache-2.0 +crc32fast 1.5.3 MIT OR Apache-2.0 criterion 0.8.2 Apache-2.0 OR MIT criterion-plot 0.8.2 Apache-2.0 OR MIT crossbeam-channel 0.5.17 MIT OR Apache-2.0 @@ -175,7 +174,6 @@ derive_more 2.1.1 MIT derive_more-impl 2.1.1 MIT derive_setters 0.1.9 MIT/Apache-2.0 diff 0.1.13 MIT OR Apache-2.0 -difftastic 0.71.0 MIT digest 0.10.7 MIT OR Apache-2.0 digest 0.11.3 MIT OR Apache-2.0 dirs 6.0.0 MIT OR Apache-2.0 @@ -401,7 +399,7 @@ jni-sys 0.4.1 MIT OR Apache-2.0 jni-sys-macros 0.4.1 MIT OR Apache-2.0 jobserver 0.1.35 MIT OR Apache-2.0 js-sys 0.3.106 MIT OR Apache-2.0 -json5 1.3.2 MIT +json5 1.3.3 MIT khronos-egl 6.0.0 MIT/Apache-2.0 khronos_api 3.1.0 Apache-2.0 kqueue 1.2.1 MIT @@ -428,10 +426,10 @@ loop9 0.1.5 MIT lru 0.18.5 MIT lru 0.19.0 MIT lyon 1.0.19 MIT OR Apache-2.0 -lyon_algorithms 1.0.21 MIT OR Apache-2.0 -lyon_geom 1.0.19 MIT OR Apache-2.0 +lyon_algorithms 1.0.22 MIT OR Apache-2.0 +lyon_geom 1.0.20 MIT OR Apache-2.0 lyon_path 1.0.19 MIT OR Apache-2.0 -lyon_tessellation 1.0.22 MIT OR Apache-2.0 +lyon_tessellation 1.0.23 MIT OR Apache-2.0 mac-notification-sys 0.6.15 MIT/Apache-2.0 mach2 0.7.0 BSD-2-Clause OR MIT OR Apache-2.0 malloc_buf 0.0.6 MIT @@ -442,7 +440,6 @@ memchr 2.8.3 Unlicense OR MIT memfd 0.6.6 MIT OR Apache-2.0 memmap2 0.9.11 MIT OR Apache-2.0 memoffset 0.9.1 MIT -mermaid-rs-renderer 0.3.1 MIT metal 0.33.0 MIT OR Apache-2.0 minimal-lexical 0.2.1 MIT/Apache-2.0 miniz_oxide 0.8.9 MIT OR Zlib OR Apache-2.0 @@ -616,7 +613,7 @@ rustix 1.1.5 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT rustix-openpty 0.2.0 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT rustls 0.23.46 Apache-2.0 OR ISC OR MIT rustls-pki-types 1.15.1 MIT OR Apache-2.0 -rustls-webpki 0.103.15 ISC +rustls-webpki 0.103.16 ISC rustversion 1.0.23 MIT OR Apache-2.0 rusty-fork 0.3.1 MIT/Apache-2.0 ryu 1.0.23 Apache-2.0 OR BSL-1.0 @@ -654,7 +651,7 @@ slab 0.4.12 MIT slotmap 1.1.1 Zlib smallvec 1.16.3 MIT OR Apache-2.0 smol 2.0.2 Apache-2.0 OR MIT -smol_str 0.3.6 MIT OR Apache-2.0 +smol_str 0.3.7 MIT OR Apache-2.0 snafu 0.8.9 MIT OR Apache-2.0 snafu-derive 0.8.9 MIT OR Apache-2.0 spin 0.12.3 MIT @@ -702,7 +699,7 @@ tiny-skia-path 0.12.0 BSD-3-Clause tinystr 0.8.4 Unicode-3.0 tinytemplate 1.2.1 Apache-2.0 OR MIT tinyvec 1.13.3 Zlib OR Apache-2.0 OR MIT -tokio 1.53.3 MIT +tokio 1.54.0 MIT toml 1.1.8+spec-1.1.0 MIT OR Apache-2.0 toml_datetime 1.1.2+spec-1.1.0 MIT OR Apache-2.0 toml_edit 0.25.17+spec-1.1.0 MIT OR Apache-2.0 @@ -713,95 +710,53 @@ tracing-attributes 0.1.31 MIT tracing-core 0.1.36 MIT tree-sitter 0.27.1 MIT tree-sitter-ada 0.1.0 MIT -tree-sitter-asm 0.24.0 MIT tree-sitter-bash 0.25.1 MIT tree-sitter-bicep 1.1.0 MIT -tree-sitter-build 0.1.0 MIT tree-sitter-c 0.24.2 MIT -tree-sitter-c-sharp 0.23.5 MIT -tree-sitter-caddyfile 0.1.0 MIT -tree-sitter-cil 0.1.0 MIT tree-sitter-clojure-orchard 0.2.8 CC0-1.0 tree-sitter-cmake 0.7.5 MIT -tree-sitter-coffee 0.1.0 MIT tree-sitter-commonlisp 0.4.1 MIT tree-sitter-containerfile 0.9.2 MIT -tree-sitter-cpp 0.23.4 MIT -tree-sitter-crontab 0.1.0 MIT -tree-sitter-css 0.25.0 MIT -tree-sitter-csv 1.2.0 MIT -tree-sitter-cue 0.0.1 MIT tree-sitter-dart 0.2.0 MIT tree-sitter-devicetree 0.15.0 MIT -tree-sitter-dhall 0.1.0 MIT tree-sitter-diff 0.1.0 MIT -tree-sitter-ebnf 0.1.0 MIT tree-sitter-elisp 1.7.2 MIT tree-sitter-elixir 0.3.5 Apache-2.0 tree-sitter-elm 5.9.4 MIT tree-sitter-erlang 0.21.0 MIT tree-sitter-fish 3.6.0 MIT -tree-sitter-fsharp 0.3.12 MIT tree-sitter-gitcommit 0.0.1 MIT -tree-sitter-gitignore 0.1.0 MIT tree-sitter-gleam 1.0.0 Apache-2.0 tree-sitter-go 0.25.0 MIT tree-sitter-gomod 1.1.1 MIT tree-sitter-gowork 0.0.1 MIT -tree-sitter-haskell 0.24.1 MIT tree-sitter-hcl 1.1.0 Apache-2.0 -tree-sitter-html 0.23.2 MIT tree-sitter-ini 1.4.0 Apache-2.0 -tree-sitter-janet-simple 0.0.3 CC0-1.0 tree-sitter-java 0.23.5 MIT tree-sitter-javascript 0.25.0 MIT -tree-sitter-jinja-dialects 0.1.1 MIT tree-sitter-jsdoc 0.25.0 MIT tree-sitter-json 0.24.8 MIT tree-sitter-jsonnet 0.0.1 MIT -tree-sitter-julia 0.23.1 MIT -tree-sitter-just 0.1.0 Apache-2.0 -tree-sitter-kdl 1.1.0 MIT -tree-sitter-kotlin-sg 0.4.1 MIT tree-sitter-language 0.1.9 MIT -tree-sitter-latex 0.4.0 MIT tree-sitter-llvm 1.1.0 MIT tree-sitter-lua 0.5.0 MIT tree-sitter-make 1.1.1 MIT tree-sitter-md 0.3.2 MIT tree-sitter-newick 1.1.0 CECILL-C tree-sitter-nix 0.3.0 MIT -tree-sitter-objc 3.0.2 MIT -tree-sitter-ocaml 0.26.0 MIT tree-sitter-pascal 0.10.2 MIT tree-sitter-perl 1.1.2 MIT -tree-sitter-php 0.25.1 MIT -tree-sitter-powershell 0.26.4 MIT -tree-sitter-properties 0.3.0 MIT tree-sitter-proto 0.6.0 MIT tree-sitter-python 0.25.0 MIT tree-sitter-qmljs 0.3.1 MIT tree-sitter-r 1.3.0 MIT tree-sitter-racket 0.25.0 MIT tree-sitter-regex 0.25.0 MIT -tree-sitter-ron 0.2.0 MIT -tree-sitter-ruby 0.23.1 MIT -tree-sitter-rust 0.24.2 MIT -tree-sitter-scala 0.26.2 MIT tree-sitter-scheme 0.25.0 MIT -tree-sitter-sequel 0.3.11 MIT tree-sitter-sfapex 3.0.1 MIT -tree-sitter-smali 1.0.0 MIT -tree-sitter-solidity 1.2.13 MIT -tree-sitter-spirv 0.1.0 MIT tree-sitter-svelte-ng 1.0.2 MIT -tree-sitter-swift 0.7.4 MIT tree-sitter-toml-ng 0.7.0 MIT -tree-sitter-typescript 0.23.2 MIT -tree-sitter-v 0.0.4 ISC tree-sitter-vhdl 1.4.0 MIT -tree-sitter-vue 0.1.0 MIT -tree-sitter-wat 0.1.0 Apache-2.0 WITH LLVM-exception tree-sitter-xml 0.7.0 MIT tree-sitter-yaml 0.7.2 MIT tree-sitter-zig 1.1.2 MIT @@ -862,7 +817,7 @@ wayland-scanner 0.31.11 MIT wayland-sys 0.31.11 MIT web-sys 0.3.106 MIT OR Apache-2.0 web-time 1.1.0 MIT OR Apache-2.0 -webpki-roots 1.0.9 CDLA-Permissive-2.0 +webpki-roots 1.0.10 CDLA-Permissive-2.0 weezl 0.1.12 MIT OR Apache-2.0 wgpu 29.0.4 MIT OR Apache-2.0 wgpu-core 29.0.4 MIT OR Apache-2.0 diff --git a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs index e9faeaf84..d7a399aa2 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs @@ -469,7 +469,7 @@ impl BarItemOwner for BottomStatusBarView { .h(scaled_px(12.0)) .flex_shrink_0() .text_color(theme.colors.foreground.secondary) - .group_hover("bottom_status_bar_discord", move |s| { + .group_hover_style("bottom_status_bar_discord", move |s| { s.text_color(theme.colors.accent.foreground) }), ) @@ -539,7 +539,7 @@ impl BarItemOwner for BottomStatusBarView { // so the mark keeps its brand color regardless of the theme. // // The color lives on the link itself and the wordmark inherits it, so - // one `.hover()` on this stateful element tints the text. A style on the + // one `.hover_style()` on this stateful element tints the text. A style on the // stateless child could not do it: gpui only repaints on hover for // elements that carry state, so the child's own hover would compute a // tint that never reaches the screen. diff --git a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs index 05107d2ce..24e6014b5 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs @@ -920,10 +920,10 @@ pub(in crate::view) fn file_row_icon_slot( .rounded_full() .bg(disc.resting) .when_some(disc.hover, |badge, (group, hovered)| { - badge.group_hover(group, |badge| badge.bg(hovered)) + badge.group_hover_style(group, |badge| badge.bg(hovered)) }) .when_some(disc.pressed, |badge, (group, pressed)| { - badge.group_active(group, |badge| badge.bg(pressed)) + badge.group_active_style(group, |badge| badge.bg(pressed)) }) .flex() .items_center() diff --git a/crates/gitcomet-ui-gpui/src/view/rows/status.rs b/crates/gitcomet-ui-gpui/src/view/rows/status.rs index f5fc88d3b..8b03a69b3 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/status.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/status.rs @@ -750,7 +750,7 @@ fn status_folder_action( .flex() .items_center() .invisible() - .group_hover(row_group, |d| d.visible()) + .group_hover_style(row_group, |d| d.visible()) .child(button) .into_any_element() } @@ -1018,7 +1018,7 @@ fn status_row( .flex() .items_center() .invisible() - .group_hover(row_group.clone(), |d| d.visible()) + .group_hover_style(row_group.clone(), |d| d.visible()) .gap(scaled_px(4.0)) .child(stage_button), ) diff --git a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs index be4eea9b4..89ccc9480 100644 --- a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs +++ b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs @@ -1842,7 +1842,7 @@ pub fn remove_row_button( .when(!always_visible, |button| { button .invisible() - .group_hover(row_group, |style| style.visible()) + .group_hover_style(row_group, |style| style.visible()) }) .control_interaction( InteractionStyle::destructive(theme), diff --git a/crates/gitcomet-ui-kit/src/components/text_fade.rs b/crates/gitcomet-ui-kit/src/components/text_fade.rs index 6ebd95a21..f21e2da5c 100644 --- a/crates/gitcomet-ui-kit/src/components/text_fade.rs +++ b/crates/gitcomet-ui-kit/src/components/text_fade.rs @@ -61,7 +61,7 @@ impl FadingText { .w(ui_scale.px(FADE_WIDTH_PX)) .bg(fade_gradient(self.bg)); if let Some((group, hover_bg)) = self.hover { - fade = fade.group_hover(group, move |s| s.bg(fade_gradient(hover_bg))); + fade = fade.group_hover_style(group, move |s| s.bg(fade_gradient(hover_bg))); } div() diff --git a/crates/gitcomet-ui-kit/src/interaction.rs b/crates/gitcomet-ui-kit/src/interaction.rs index 27aa67c3c..94bd4ae58 100644 --- a/crates/gitcomet-ui-kit/src/interaction.rs +++ b/crates/gitcomet-ui-kit/src/interaction.rs @@ -301,7 +301,7 @@ impl InteractionStyle { } let outline = interaction_outline(self.theme.colors.interaction.focus_ring); - control = control.focus_visible(move |style| style.shadow(vec![outline])); + control = control.focus_visible_style(move |style| style.shadow(vec![outline])); if !self.pointer_feedback { return control.cursor(CursorStyle::Arrow); } @@ -311,12 +311,12 @@ impl InteractionStyle { control .cursor(CursorStyle::PointingHand) .when(self.hover_feedback, |control| { - control.hover(move |mut style| { + control.hover_style(move |mut style| { style.refine(&hover); style }) }) - .active(move |mut style| { + .active_style(move |mut style| { style.refine(&pressed); style }) diff --git a/crates/gitcomet-ui-kit/src/test_support.rs b/crates/gitcomet-ui-kit/src/test_support.rs index 8503ec1f7..433dfc819 100644 --- a/crates/gitcomet-ui-kit/src/test_support.rs +++ b/crates/gitcomet-ui-kit/src/test_support.rs @@ -349,13 +349,15 @@ pub mod source_guards { } /// Discrete controls must style and activate through the shared - /// interaction APIs: no ad-hoc `.hover(|..`/`.active(|..`, raw + /// interaction APIs: no ad-hoc `.hover_style(|..`/`.active_style(|..` + /// (including their deprecated aliases), raw /// `.on_click`, bare `.on_mouse_up`, or raw button presses. pub fn discrete_control_violations( src_dir: &Path, allow: &DiscreteControlAllowlist<'_>, ) -> Vec { - let pattern = regex::Regex::new(r"\.(?:hover|active)\(\s*(?:move\s+)?\|").unwrap(); + let pattern = + regex::Regex::new(r"\.(?:hover|active)(?:_style)?\(\s*(?:move\s+)?\|").unwrap(); let raw_click = regex::Regex::new(r"\.on_click\(\s*(?:cx\.listener|on_click\b|(?:move\s+)?\|)") .unwrap(); @@ -439,6 +441,53 @@ pub mod source_guards { #[cfg(test)] mod tests { + #[test] + fn discrete_control_guard_covers_current_and_legacy_style_methods() { + let sources = tempfile::tempdir().unwrap(); + let fixture = sources.path().join("control.rs"); + for method in ["hover", "active", "hover_style", "active_style"] { + for closure in ["|style| style", "move |style| style"] { + std::fs::write(&fixture, format!("div().{method}({closure})")).unwrap(); + let allow = super::DiscreteControlAllowlist { + custom_styling: &[], + release_gestures: &[], + primary_press: &[], + context_press: &[], + }; + assert_eq!( + super::discrete_control_violations(sources.path(), &allow), + ["control.rs: custom hover/press styling"], + "{method}: {closure}" + ); + assert!( + super::discrete_control_violations( + sources.path(), + &super::DiscreteControlAllowlist { + custom_styling: &["control.rs"], + ..allow + }, + ) + .is_empty(), + "{method}: allowlist remains necessary" + ); + } + } + std::fs::write(&fixture, "InteractionStyle::new().hover(feedback)").unwrap(); + assert!( + super::discrete_control_violations( + sources.path(), + &super::DiscreteControlAllowlist { + custom_styling: &[], + release_gestures: &[], + primary_press: &[], + context_press: &[], + }, + ) + .is_empty(), + "the shared interaction API remains allowed" + ); + } + #[test] fn allowlist_entries_that_are_missing_or_unneeded_are_violations() { let sources = tempfile::tempdir().unwrap(); diff --git a/crates/gitcomet-ui-kit/src/text_input/render.rs b/crates/gitcomet-ui-kit/src/text_input/render.rs index f09daa986..bd1939d8d 100644 --- a/crates/gitcomet-ui-kit/src/text_input/render.rs +++ b/crates/gitcomet-ui-kit/src/text_input/render.rs @@ -315,10 +315,10 @@ impl Render for TextInput { } else { input = input .border_color(style.border) - .hover(move |s| s.border_color(style.hover_border)); + .hover_style(move |s| s.border_color(style.hover_border)); } - input = input.focus(move |s| s.border_color(style.focus_border)); + input = input.focus_style(move |s| s.border_color(style.focus_border)); } let mut outer = div().id(ElementId::from(("text_input_root", entity_id))); diff --git a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs index a8a43c59f..3462c160f 100644 --- a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs +++ b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs @@ -268,7 +268,7 @@ pub fn titlebar_control_button( .h(icon_size) .flex_shrink_0() .text_color(idle_color) - .group_hover(id, move |s| s.text_color(hover_color)), + .group_hover_style(id, move |s| s.text_color(hover_color)), ), ), ) diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md index f5b9d22a2..a778b34b7 100644 --- a/docs/windows-startup-performance.md +++ b/docs/windows-startup-performance.md @@ -23,6 +23,114 @@ The measured candidate is GitComet caches; reboot-cold startup was not measured. Callback, CPU-submission, and ETW Present times do not prove physical display scanout. +## Fresh samply capture: remaining costs + +A new 1 kHz CPU/context-switch/graphics capture on October 20 used the same +frozen release-with-debug executable from its original path. The foreground +repository-startup check passed on hardware DX12. No compilation ran during +capture. This is one diagnostic run with warm OS/driver caches, not a replacement +for the unprofiled five-pair confirmation above. + +| Milestone, from process spawn | Fresh sampled run | Previous sampled run | +| --- | ---: | ---: | +| First recorded CPU draw complete | 561.2 ms | 526.3 ms | +| First platform submission complete | 616.1 ms | 561.0 ms | +| Repository-ready observation | 689.2 ms | 602.9 ms | +| Status task complete | 516.4 ms | 507.6 ms | + +The remaining critical path is predominantly renderer initialization and the +first two frame submissions. Required repository data is already available +before the first recorded draw starts. + +| Work | Fresh evidence | Interpretation | +| --- | --- | --- | +| DX12 context initialization | Main-thread samples span 71.2–274.6 ms of ETW process age; 84.6 ms sampled CPU | Adapter enumeration accounts for 43.4 ms CPU and device creation 27.4 ms CPU within this category. The observed interval also contains 75.7 ms off-CPU time following preemption. | +| Ordinary render pipelines | Main-thread samples span 283.7–490.4 ms; 485.1 ms compiler CPU across the main thread and two workers | Largest remaining compiler cost. The main pipeline interval has only about 2.0 ms off CPU; this is predominantly executing work, not a long worker join. | +| First platform submission | 55.0 ms instrumented elapsed time | Includes surface resize/configuration, atlas upload and render-target allocation. Its native sampled interval has 7.1 ms sampled CPU and 41.1 ms off CPU. | +| Second draw and submission | 16.1 ms drawing, then 47.5 ms submitting | Its native submission interval has 6.1 ms sampled CPU and 37.8 ms off CPU. Atlas staging-buffer and render-target allocations appear near the long gaps. No compiler CPU was sampled in either submission. | + +CPU totals are summed across threads where stated. Inclusive categories overlap +and must not be added to elapsed intervals. Sample spans are observed first/last +samples, not instrumented function entry/exit. Scheduler on-CPU markers and +sampled `threadCPUDelta` are separate measurements. + +The relevant frame-allocation call chains are +`PreparedTargets::prepare -> Device::create_texture` and +`WgpuAtlas::before_frame -> StagingBuffer::new -> create_buffer`, reaching +`gpu_allocator::MemoryBlock::new -> ID3D12Device::CreateHeap`. The longest +nearby off-CPU intervals are 10.9 ms in the first frame and 7.3/28.3 ms in the +second. Samples near these gaps include +`NtGdiDdDDIWaitForSynchronizationObjectFromCpu`; however, the switch-out events +are labelled preempted. They establish resource-call-associated scheduling +gaps, not exact driver-wait durations. The trace does not label texture extents +or prove which path/filter target was allocated. + +The first-submission interval includes an observed 18.7 ms resize/configure +span. This is a changed-size configuration: native DX12 construction starts +with a 1×1 surface, the first Present uses 2100×1350, and equal dimensions return +before configuration. It is not evidence of a redundant equal-size resize. +Investigate whether construction can use the intended size directly. +Retained path textures can grow with scene bounds; confirming why another +allocation is needed in frame two requires target labels, sizes and invalidation +reasons. GPUI does not rotate these textures through retained per-frame slots. +This capture is not a Rust heap-allocation profile. + +Repository work still consumes substantial parallel resources: + +- Status takes 322.1 ms elapsed and finishes at 516.4 ms after spawn. Its result + is present in the initial recorded view state applications, before drawing. + Initial log loading finishes at 227.0 ms. +- The watcher thread consumes 142.9 ms CPU in the first 1.1 seconds, including + 52.5 ms in watcher reload and 25.5 ms in watch-path expansion. These nested + values are not additive. +- Optional LFS/annex discovery consumes 125.0 ms CPU, with sampled work beginning + around ETW process age 624 ms, after the first successful native Present at + 615.1 ms. It no longer contributes sampled CPU before that presentation. + This does not exclude contention during the subsequent readiness interval. + +The tail after first submission is observable on one journal clock: +8.7 ms until the second draw, 16.1 ms drawing, 47.5 ms submitting, then 0.7 ms +until ready. Failed readiness predicates are not logged, so the full 73.1 ms +tail cannot be called polling waste or assumed removable. Indexed-history +state is applied immediately before readiness, but full indexing is not a +startup requirement and no late initial status/log result appears. + +Compared with the preceding sampled run, ordinary pipeline compiler CPU rose +from 383.1 to 485.1 ms, while the main pipeline sample span grew by about +41.7 ms. First/second submission durations rose from 34.7/19.2 to 55.0/47.5 ms. +Status elapsed time remained essentially unchanged at 321.9/322.1 ms. +These observations locate the slower work in this run; they do not identify +the cause of run-to-run variation. The two runs used different executable +launch paths and opened different repository revisions. + +The next concrete investigations are render-target/atlas allocation and +surface configuration during the first two frames, followed by per-pipeline +release timings to reduce the remaining shader work. Status and watcher work +remain relevant to CPU contention and to establishing reliable sub-500 ms +readiness once rendering improves. No additive speedup is claimed from these +overlapping costs. + +Evidence is retained under +`target/startup-investigation/samply-remaining-20261020/`: `startup.json.gz`, +matching symbols, kernel/user ETLs, `recording.json`, `process-analysis.json`, +`renderer-findings.txt`, `renderer-phase-evidence.json`, `renderer-submission-evidence.json`, +`remaining-readiness-findings.{txt,json}`, and `status-thread-evidence.json`. +`pre-present-analysis.json` covers native process age 0–615.0769 ms; +`post-present-to1100-analysis.json` covers 615.0769–1100 ms. They contain +1290.6 and 195.7 ms aggregate app CPU respectively. All 505.1 ms sampled compiler +CPU, including approximately 20 ms of internal device initialization, precedes +the first native Present. + +The capture run is `ed9bbc81-abd2-4893-8ca1-093fb9943733`, PID 14888. +The executable remains source `8bd06006` / GPUI `a4ee8500`, SHA256 +`d62aa0087d9e9e6e3925b3d35a46ab2c0898e41dd2c0a2e4b496d62aace20584`. +The measured repository checkout is `c4b00cb7`; this is not the executable's +source revision. The binary includes all runtime optimizations but predates +the diagnostic-only log batching correction. ETW process-age, app-stage and +spawn-aligned journal clocks are kept separate; profile epoch metadata was +not used to align ETW to readiness. No display scanout or reboot-cold latency +was measured. + ## Opaque surfaces, deferred work, and Home adoption The implementation consists of these committed changes: From 662603142f4b160f13dd59d3ecebd111541d7f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 08:45:59 +0300 Subject: [PATCH 52/67] Pin gpui-ce to precompiled DX12 shaders Moves gpui-ce to Havunen/gpui-ce#35, which creates DX12 pipelines from build-time shader bytecode and switches a failing DX12 window to DX11 in place, and brings in #33 and #34. - Migrate the style builders deprecated by #34 to their *_style names. - Teach the interaction-kit source guard the *_style names; it matched only .hover(/.active( and so stopped seeing custom styling after the rename. - A runtime DX12 failure now leaves the app running on DX11, so record it like a startup failure: save DX11 without asking for a restart. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 +++++++++---------- Cargo.toml | 12 ++--- .../src/view/panels/bottom_status_bar.rs | 2 +- crates/gitcomet-ui-gpui/src/view/rows/mod.rs | 4 +- .../gitcomet-ui-gpui/src/view/rows/status.rs | 4 +- .../gitcomet-ui-gpui/src/windows_renderer.rs | 34 +++++++------- crates/gitcomet-ui-kit/gpui-revision | 2 +- .../src/components/picker_prompt.rs | 2 +- .../src/components/text_fade.rs | 2 +- crates/gitcomet-ui-kit/src/interaction.rs | 6 +-- crates/gitcomet-ui-kit/src/test_support.rs | 39 ++++++++++++++-- .../gitcomet-ui-kit/src/text_input/render.rs | 4 +- .../gitcomet-ui-kit/src/window_chrome/mod.rs | 2 +- 13 files changed, 96 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b1f75cf4..dc08c72b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "block", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +4001,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4098,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4109,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "gpui-ce", "naga 24.0.0", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "schemars", "serde", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "log", @@ -4220,7 +4220,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "anyhow", "core-foundation", @@ -4275,7 +4275,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "accesskit", "accesskit_windows", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -10092,7 +10092,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "encoding_rs", ] @@ -10100,7 +10100,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=45e4145b91ebafd7514da1e2763699efc7ac7160#45e4145b91ebafd7514da1e2763699efc7ac7160" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index 84fa7055a..df8de6b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "45e4145b91ebafd7514da1e2763699efc7ac7160" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs index e9faeaf84..b4747b736 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs @@ -469,7 +469,7 @@ impl BarItemOwner for BottomStatusBarView { .h(scaled_px(12.0)) .flex_shrink_0() .text_color(theme.colors.foreground.secondary) - .group_hover("bottom_status_bar_discord", move |s| { + .group_hover_style("bottom_status_bar_discord", move |s| { s.text_color(theme.colors.accent.foreground) }), ) diff --git a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs index 05107d2ce..24e6014b5 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs @@ -920,10 +920,10 @@ pub(in crate::view) fn file_row_icon_slot( .rounded_full() .bg(disc.resting) .when_some(disc.hover, |badge, (group, hovered)| { - badge.group_hover(group, |badge| badge.bg(hovered)) + badge.group_hover_style(group, |badge| badge.bg(hovered)) }) .when_some(disc.pressed, |badge, (group, pressed)| { - badge.group_active(group, |badge| badge.bg(pressed)) + badge.group_active_style(group, |badge| badge.bg(pressed)) }) .flex() .items_center() diff --git a/crates/gitcomet-ui-gpui/src/view/rows/status.rs b/crates/gitcomet-ui-gpui/src/view/rows/status.rs index f5fc88d3b..8b03a69b3 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/status.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/status.rs @@ -750,7 +750,7 @@ fn status_folder_action( .flex() .items_center() .invisible() - .group_hover(row_group, |d| d.visible()) + .group_hover_style(row_group, |d| d.visible()) .child(button) .into_any_element() } @@ -1018,7 +1018,7 @@ fn status_row( .flex() .items_center() .invisible() - .group_hover(row_group.clone(), |d| d.visible()) + .group_hover_style(row_group.clone(), |d| d.visible()) .gap(scaled_px(4.0)) .child(stage_button), ) diff --git a/crates/gitcomet-ui-gpui/src/windows_renderer.rs b/crates/gitcomet-ui-gpui/src/windows_renderer.rs index 4190ad487..8d22caf6e 100644 --- a/crates/gitcomet-ui-gpui/src/windows_renderer.rs +++ b/crates/gitcomet-ui-gpui/src/windows_renderer.rs @@ -132,14 +132,14 @@ mod windows { } self.restart_required() && self.restart_allowed } - fn record_fallback(&mut self, runtime: bool, result: std::io::Result) { + /// GPUI falls back to DirectX 11 itself, at startup or by switching a failed + /// window in place, so the running app already uses the saved renderer. + fn record_fallback(&mut self, result: std::io::Result) { self.fallback = true; match result { Ok(true) => { self.saved = RendererPreference::Dx11; - if !runtime { - self.applied = RendererPreference::Dx11; - } + self.applied = RendererPreference::Dx11; self.error = None; } Ok(false) => unreachable!("foreground fallback is the latest renderer write"), @@ -164,14 +164,14 @@ mod windows { RendererPreference::Dx11 => WindowsRendererPreference::Dx11, }, on_event: Some(Rc::new(move |event| match event { - Event::Failed { runtime, .. } => { - // Rare failure path: finish the narrow transaction before GPUI's fallback/fatal handling. + Event::Failed { .. } => { + // Rare failure path: finish the narrow transaction before GPUI falls back to DirectX 11. let writer = observer.borrow().writer.clone(); let sequence = writer.next(); let result = writer.persist(sequence, || { session::persist_ui_settings(preference_settings(RendererPreference::Dx11)) }); - observer.borrow_mut().record_fallback(runtime, result); + observer.borrow_mut().record_fallback(result); } Event::Selected { backend, @@ -193,13 +193,15 @@ mod windows { mod tests { use super::*; #[test] - fn fallback_updates_saved_selection_but_runtime_keeps_applied_selection() { - for runtime in [false, true] { - let mut state = RendererState::new(RendererPreference::Auto, true); - state.record_fallback(runtime, Ok(true)); - assert_eq!(state.saved, RendererPreference::Dx11); - assert_eq!(state.restart_required(), runtime); - } + fn fallback_saves_dx11_without_requiring_a_restart() { + // A runtime DirectX 12 failure switches the window to DirectX 11 in place, + // so neither a startup nor a runtime fallback needs a restart. + let mut state = RendererState::new(RendererPreference::Auto, true); + state.record_fallback(Ok(true)); + assert_eq!(state.saved, RendererPreference::Dx11); + assert_eq!(state.applied, RendererPreference::Dx11); + assert!(!state.restart_required()); + assert!(state.status_message().contains("DirectX 12 unavailable.")); } #[test] fn save_failure_keeps_previous_selection_and_disables_restart_prompt() { @@ -214,7 +216,7 @@ mod windows { assert!(!state.saving); assert_eq!(state.saved, RendererPreference::Auto); assert!(state.error.is_some()); - state.record_fallback(false, Err(std::io::Error::other("read only"))); + state.record_fallback(Err(std::io::Error::other("read only"))); assert_eq!(state.saved, RendererPreference::Auto); assert_eq!(state.applied, RendererPreference::Auto); assert!(!state.restart_required()); @@ -242,7 +244,7 @@ mod windows { state.writer.persist(manual, || Ok(())).unwrap(); let fallback = state.writer.next(); state.writer.persist(fallback, || Ok(())).unwrap(); - state.record_fallback(false, Ok(true)); + state.record_fallback(Ok(true)); assert!(!state.complete_manual_save(manual, RendererPreference::Auto, Ok(true))); assert_eq!(state.saved, RendererPreference::Dx11); } diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index b6b1ca4fa..69d78837b 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -0665fb07d59cf7af185e58f44b9f890590abe204 +45e4145b91ebafd7514da1e2763699efc7ac7160 diff --git a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs index be4eea9b4..89ccc9480 100644 --- a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs +++ b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs @@ -1842,7 +1842,7 @@ pub fn remove_row_button( .when(!always_visible, |button| { button .invisible() - .group_hover(row_group, |style| style.visible()) + .group_hover_style(row_group, |style| style.visible()) }) .control_interaction( InteractionStyle::destructive(theme), diff --git a/crates/gitcomet-ui-kit/src/components/text_fade.rs b/crates/gitcomet-ui-kit/src/components/text_fade.rs index 6ebd95a21..f21e2da5c 100644 --- a/crates/gitcomet-ui-kit/src/components/text_fade.rs +++ b/crates/gitcomet-ui-kit/src/components/text_fade.rs @@ -61,7 +61,7 @@ impl FadingText { .w(ui_scale.px(FADE_WIDTH_PX)) .bg(fade_gradient(self.bg)); if let Some((group, hover_bg)) = self.hover { - fade = fade.group_hover(group, move |s| s.bg(fade_gradient(hover_bg))); + fade = fade.group_hover_style(group, move |s| s.bg(fade_gradient(hover_bg))); } div() diff --git a/crates/gitcomet-ui-kit/src/interaction.rs b/crates/gitcomet-ui-kit/src/interaction.rs index 27aa67c3c..94bd4ae58 100644 --- a/crates/gitcomet-ui-kit/src/interaction.rs +++ b/crates/gitcomet-ui-kit/src/interaction.rs @@ -301,7 +301,7 @@ impl InteractionStyle { } let outline = interaction_outline(self.theme.colors.interaction.focus_ring); - control = control.focus_visible(move |style| style.shadow(vec![outline])); + control = control.focus_visible_style(move |style| style.shadow(vec![outline])); if !self.pointer_feedback { return control.cursor(CursorStyle::Arrow); } @@ -311,12 +311,12 @@ impl InteractionStyle { control .cursor(CursorStyle::PointingHand) .when(self.hover_feedback, |control| { - control.hover(move |mut style| { + control.hover_style(move |mut style| { style.refine(&hover); style }) }) - .active(move |mut style| { + .active_style(move |mut style| { style.refine(&pressed); style }) diff --git a/crates/gitcomet-ui-kit/src/test_support.rs b/crates/gitcomet-ui-kit/src/test_support.rs index 8503ec1f7..1656107a5 100644 --- a/crates/gitcomet-ui-kit/src/test_support.rs +++ b/crates/gitcomet-ui-kit/src/test_support.rs @@ -349,13 +349,15 @@ pub mod source_guards { } /// Discrete controls must style and activate through the shared - /// interaction APIs: no ad-hoc `.hover(|..`/`.active(|..`, raw - /// `.on_click`, bare `.on_mouse_up`, or raw button presses. + /// interaction APIs: no ad-hoc `.hover_style(|..`/`.active_style(|..` + /// (or their deprecated `.hover`/`.active` names), raw `.on_click`, bare + /// `.on_mouse_up`, or raw button presses. pub fn discrete_control_violations( src_dir: &Path, allow: &DiscreteControlAllowlist<'_>, ) -> Vec { - let pattern = regex::Regex::new(r"\.(?:hover|active)\(\s*(?:move\s+)?\|").unwrap(); + let pattern = + regex::Regex::new(r"\.(?:hover|active)(?:_style)?\(\s*(?:move\s+)?\|").unwrap(); let raw_click = regex::Regex::new(r"\.on_click\(\s*(?:cx\.listener|on_click\b|(?:move\s+)?\|)") .unwrap(); @@ -462,6 +464,37 @@ pub mod source_guards { ); } + #[test] + fn custom_styling_is_found_under_both_style_builder_names() { + let sources = tempfile::tempdir().unwrap(); + for (file, call) in [ + ("hover.rs", ".hover(|s| s)"), + ("hover_style.rs", ".hover_style(move |s| s)"), + ("active_style.rs", ".active_style(|s| s)"), + ("refine.rs", "style.refine(&hover)"), + ] { + std::fs::write(sources.path().join(file), call).unwrap(); + } + let mut violations = super::discrete_control_violations( + sources.path(), + &super::DiscreteControlAllowlist { + custom_styling: &[], + release_gestures: &[], + primary_press: &[], + context_press: &[], + }, + ); + violations.sort(); + assert_eq!( + violations, + [ + "active_style.rs: custom hover/press styling", + "hover.rs: custom hover/press styling", + "hover_style.rs: custom hover/press styling", + ] + ); + } + #[test] fn opacity_uses_count_calls_and_writes_but_not_other_fields() { let sources = tempfile::tempdir().unwrap(); diff --git a/crates/gitcomet-ui-kit/src/text_input/render.rs b/crates/gitcomet-ui-kit/src/text_input/render.rs index f09daa986..bd1939d8d 100644 --- a/crates/gitcomet-ui-kit/src/text_input/render.rs +++ b/crates/gitcomet-ui-kit/src/text_input/render.rs @@ -315,10 +315,10 @@ impl Render for TextInput { } else { input = input .border_color(style.border) - .hover(move |s| s.border_color(style.hover_border)); + .hover_style(move |s| s.border_color(style.hover_border)); } - input = input.focus(move |s| s.border_color(style.focus_border)); + input = input.focus_style(move |s| s.border_color(style.focus_border)); } let mut outer = div().id(ElementId::from(("text_input_root", entity_id))); diff --git a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs index a8a43c59f..3462c160f 100644 --- a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs +++ b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs @@ -268,7 +268,7 @@ pub fn titlebar_control_button( .h(icon_size) .flex_shrink_0() .text_color(idle_color) - .group_hover(id, move |s| s.text_color(hover_color)), + .group_hover_style(id, move |s| s.text_color(hover_color)), ), ), ) From 12a05b4f6cdf28776bc8bcb306954ccb4a25eb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 10:08:13 +0300 Subject: [PATCH 53/67] Update rustls-webpki and webpki-roots for new advisories cargo audit now fails on RUSTSEC-2026-0382 (rustls-webpki 0.103.15 may accept distrusted intermediates) and RUSTSEC-2026-0381 (webpki-roots 1.0.9 trusts distrusted emSign intermediates). Take the patch releases that fix both, and record their versions in the open-source license list. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 +++++----- .../gitcomet-ui-gpui/assets/open_source_licenses.tsv | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc08c72b5..8f9508752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7124,9 +7124,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -7792,7 +7792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -9426,9 +9426,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv index cf4e4bc88..12f03a633 100644 --- a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv +++ b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv @@ -616,7 +616,7 @@ rustix 1.1.5 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT rustix-openpty 0.2.0 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT rustls 0.23.46 Apache-2.0 OR ISC OR MIT rustls-pki-types 1.15.1 MIT OR Apache-2.0 -rustls-webpki 0.103.15 ISC +rustls-webpki 0.103.16 ISC rustversion 1.0.23 MIT OR Apache-2.0 rusty-fork 0.3.1 MIT/Apache-2.0 ryu 1.0.23 Apache-2.0 OR BSL-1.0 @@ -862,7 +862,7 @@ wayland-scanner 0.31.11 MIT wayland-sys 0.31.11 MIT web-sys 0.3.106 MIT OR Apache-2.0 web-time 1.1.0 MIT OR Apache-2.0 -webpki-roots 1.0.9 CDLA-Permissive-2.0 +webpki-roots 1.0.10 CDLA-Permissive-2.0 weezl 0.1.12 MIT OR Apache-2.0 wgpu 29.0.4 MIT OR Apache-2.0 wgpu-core 29.0.4 MIT OR Apache-2.0 From 052ed5a474359543fb7c6986dee533884a1b915d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 12:57:54 +0300 Subject: [PATCH 54/67] Re-run CI after flaky native test jobs The previous run failed only in tests that also fail intermittently on unrelated PRs (the conflict-resolver bar tests on Windows) and in Linux nextest suites that pass on ubuntu22-x64 and locally. Co-Authored-By: Claude Opus 5.5 From 669d668d537c12670ce6f1cd41c06a563f163051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 14:16:39 +0300 Subject: [PATCH 55/67] Add Linux renderer preference persistence --- crates/gitcomet-state/src/session/mod.rs | 4 ++ crates/gitcomet-state/src/session/settings.rs | 2 + crates/gitcomet-state/src/session/tests.rs | 55 +++++++++++++++++++ .../src/view/settings_window/prefs.rs | 1 + .../src/view/ui_persistence.rs | 1 + 5 files changed, 63 insertions(+) diff --git a/crates/gitcomet-state/src/session/mod.rs b/crates/gitcomet-state/src/session/mod.rs index b5f94b575..c336fa564 100644 --- a/crates/gitcomet-state/src/session/mod.rs +++ b/crates/gitcomet-state/src/session/mod.rs @@ -42,6 +42,7 @@ pub struct UiSession { pub image_smooth_filter: Option, pub window_controls_mode: Option, pub windows_renderer: Option, + pub linux_renderer: Option, pub browser_open_target: Option, pub ui_density: Option, pub ui_font_size_px: Option, @@ -193,6 +194,8 @@ pub(crate) struct UiSessionFile { #[serde(default, deserialize_with = "lenient")] windows_renderer: Option, #[serde(default, deserialize_with = "lenient")] + linux_renderer: Option, + #[serde(default, deserialize_with = "lenient")] browser_open_target: Option, #[serde(default, deserialize_with = "lenient")] ui_density: Option, @@ -497,6 +500,7 @@ pub fn load_from_path(path: &Path) -> UiSession { image_smooth_filter: file.image_smooth_filter, window_controls_mode: file.window_controls_mode, windows_renderer: file.windows_renderer, + linux_renderer: file.linux_renderer, browser_open_target: file.browser_open_target, ui_density: file.ui_density, ui_font_size_px: file.ui_font_size_px, diff --git a/crates/gitcomet-state/src/session/settings.rs b/crates/gitcomet-state/src/session/settings.rs index fc408b050..d42760411 100644 --- a/crates/gitcomet-state/src/session/settings.rs +++ b/crates/gitcomet-state/src/session/settings.rs @@ -15,6 +15,7 @@ pub struct UiSettings { pub image_smooth_filter: Option, pub window_controls_mode: Option, pub windows_renderer: Option, + pub linux_renderer: Option, pub browser_open_target: Option, pub ui_density: Option, pub ui_font_size_px: Option, @@ -121,6 +122,7 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res apply_setting!(settings, file, image_smooth_filter); apply_setting!(settings, file, window_controls_mode); apply_setting!(settings, file, windows_renderer); + apply_setting!(settings, file, linux_renderer); apply_setting!(settings, file, browser_open_target); apply_setting!(settings, file, ui_density); apply_setting!(settings, file, ui_font_size_px); diff --git a/crates/gitcomet-state/src/session/tests.rs b/crates/gitcomet-state/src/session/tests.rs index 809b10241..3a69e15a8 100644 --- a/crates/gitcomet-state/src/session/tests.rs +++ b/crates/gitcomet-state/src/session/tests.rs @@ -3066,6 +3066,61 @@ fn persist_ui_settings_round_trips_window_controls_mode() { assert_eq!(loaded.browser_open_target.as_deref(), Some("new_window")); } +#[test] +fn linux_renderer_roundtrips_without_changing_the_windows_preference() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.json"); + assert_eq!(load_from_path(&path).linux_renderer, None); + persist_ui_settings_to_path( + UiSettings { + windows_renderer: Some("dx11".into()), + theme_mode: Some("dark".into()), + ..Default::default() + }, + &path, + ) + .unwrap(); + for value in ["gl", "vulkan", "auto"] { + persist_ui_settings_to_path( + UiSettings { + linux_renderer: Some(value.into()), + ..Default::default() + }, + &path, + ) + .unwrap(); + let loaded = load_from_path(&path); + assert_eq!(loaded.linux_renderer.as_deref(), Some(value)); + assert_eq!(loaded.windows_renderer.as_deref(), Some("dx11")); + assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); + } +} + +#[test] +fn linux_renderer_survives_settings_and_workspace_writes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.json"); + std::fs::write( + &path, + r#"{"version":6,"linux_renderer":"gl","windows_renderer":"dx11","extensions":{"example":{"draft":"keep"}}}"#, + ) + .unwrap(); + persist_ui_settings_to_path( + UiSettings { + theme_mode: Some("dark".into()), + ..Default::default() + }, + &path, + ) + .unwrap(); + persist_workspaces_to_path(&[Workspace::new(vec![dir.path().to_path_buf()])], &path).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(json["linux_renderer"], "gl"); + assert_eq!(json["windows_renderer"], "dx11"); + assert_eq!(json["extensions"]["example"]["draft"], "keep"); + assert_eq!(load_from_path(&path).workspaces.len(), 1); +} + #[test] fn windows_renderer_fallback_survives_other_settings_and_workspace_writes() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs index 055f633ee..b0261282b 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs @@ -28,6 +28,7 @@ impl SettingsWindowView { window_controls_mode: Some(self.window_controls_mode.key().to_string()), // Renderer changes are persisted separately; ordinary preferences must not overwrite a fallback. windows_renderer: None, + linux_renderer: None, browser_open_target: Some(self.browser_open_target.key().to_string()), ui_density: Some(self.appearance_metrics.density.key().to_string()), ui_font_size_px: Some(self.appearance_metrics.ui_font_size_px), diff --git a/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs b/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs index 7155eea37..e43e53b22 100644 --- a/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs +++ b/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs @@ -191,6 +191,7 @@ impl GitCometView { image_smooth_filter: None, // Owned by the settings window; None preserves it. windows_renderer: None, + linux_renderer: None, window_controls_mode: None, browser_open_target: None, ui_density: Some(crate::appearance::current(cx).density.key().to_string()), From a9928b1e0c2d4618761735037f889e71ac75e568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 14:28:17 +0300 Subject: [PATCH 56/67] Update GPUI interaction style method calls --- .../gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs | 2 +- crates/gitcomet-ui-gpui/src/view/rows/mod.rs | 4 ++-- crates/gitcomet-ui-gpui/src/view/rows/status.rs | 4 ++-- crates/gitcomet-ui-kit/src/components/picker_prompt.rs | 2 +- crates/gitcomet-ui-kit/src/components/text_fade.rs | 2 +- crates/gitcomet-ui-kit/src/interaction.rs | 6 +++--- crates/gitcomet-ui-kit/src/text_input/render.rs | 4 ++-- crates/gitcomet-ui-kit/src/window_chrome/mod.rs | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs index e9faeaf84..b4747b736 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/bottom_status_bar.rs @@ -469,7 +469,7 @@ impl BarItemOwner for BottomStatusBarView { .h(scaled_px(12.0)) .flex_shrink_0() .text_color(theme.colors.foreground.secondary) - .group_hover("bottom_status_bar_discord", move |s| { + .group_hover_style("bottom_status_bar_discord", move |s| { s.text_color(theme.colors.accent.foreground) }), ) diff --git a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs index 05107d2ce..24e6014b5 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/mod.rs @@ -920,10 +920,10 @@ pub(in crate::view) fn file_row_icon_slot( .rounded_full() .bg(disc.resting) .when_some(disc.hover, |badge, (group, hovered)| { - badge.group_hover(group, |badge| badge.bg(hovered)) + badge.group_hover_style(group, |badge| badge.bg(hovered)) }) .when_some(disc.pressed, |badge, (group, pressed)| { - badge.group_active(group, |badge| badge.bg(pressed)) + badge.group_active_style(group, |badge| badge.bg(pressed)) }) .flex() .items_center() diff --git a/crates/gitcomet-ui-gpui/src/view/rows/status.rs b/crates/gitcomet-ui-gpui/src/view/rows/status.rs index f5fc88d3b..8b03a69b3 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/status.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/status.rs @@ -750,7 +750,7 @@ fn status_folder_action( .flex() .items_center() .invisible() - .group_hover(row_group, |d| d.visible()) + .group_hover_style(row_group, |d| d.visible()) .child(button) .into_any_element() } @@ -1018,7 +1018,7 @@ fn status_row( .flex() .items_center() .invisible() - .group_hover(row_group.clone(), |d| d.visible()) + .group_hover_style(row_group.clone(), |d| d.visible()) .gap(scaled_px(4.0)) .child(stage_button), ) diff --git a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs index be4eea9b4..89ccc9480 100644 --- a/crates/gitcomet-ui-kit/src/components/picker_prompt.rs +++ b/crates/gitcomet-ui-kit/src/components/picker_prompt.rs @@ -1842,7 +1842,7 @@ pub fn remove_row_button( .when(!always_visible, |button| { button .invisible() - .group_hover(row_group, |style| style.visible()) + .group_hover_style(row_group, |style| style.visible()) }) .control_interaction( InteractionStyle::destructive(theme), diff --git a/crates/gitcomet-ui-kit/src/components/text_fade.rs b/crates/gitcomet-ui-kit/src/components/text_fade.rs index 6ebd95a21..f21e2da5c 100644 --- a/crates/gitcomet-ui-kit/src/components/text_fade.rs +++ b/crates/gitcomet-ui-kit/src/components/text_fade.rs @@ -61,7 +61,7 @@ impl FadingText { .w(ui_scale.px(FADE_WIDTH_PX)) .bg(fade_gradient(self.bg)); if let Some((group, hover_bg)) = self.hover { - fade = fade.group_hover(group, move |s| s.bg(fade_gradient(hover_bg))); + fade = fade.group_hover_style(group, move |s| s.bg(fade_gradient(hover_bg))); } div() diff --git a/crates/gitcomet-ui-kit/src/interaction.rs b/crates/gitcomet-ui-kit/src/interaction.rs index 27aa67c3c..94bd4ae58 100644 --- a/crates/gitcomet-ui-kit/src/interaction.rs +++ b/crates/gitcomet-ui-kit/src/interaction.rs @@ -301,7 +301,7 @@ impl InteractionStyle { } let outline = interaction_outline(self.theme.colors.interaction.focus_ring); - control = control.focus_visible(move |style| style.shadow(vec![outline])); + control = control.focus_visible_style(move |style| style.shadow(vec![outline])); if !self.pointer_feedback { return control.cursor(CursorStyle::Arrow); } @@ -311,12 +311,12 @@ impl InteractionStyle { control .cursor(CursorStyle::PointingHand) .when(self.hover_feedback, |control| { - control.hover(move |mut style| { + control.hover_style(move |mut style| { style.refine(&hover); style }) }) - .active(move |mut style| { + .active_style(move |mut style| { style.refine(&pressed); style }) diff --git a/crates/gitcomet-ui-kit/src/text_input/render.rs b/crates/gitcomet-ui-kit/src/text_input/render.rs index f09daa986..bd1939d8d 100644 --- a/crates/gitcomet-ui-kit/src/text_input/render.rs +++ b/crates/gitcomet-ui-kit/src/text_input/render.rs @@ -315,10 +315,10 @@ impl Render for TextInput { } else { input = input .border_color(style.border) - .hover(move |s| s.border_color(style.hover_border)); + .hover_style(move |s| s.border_color(style.hover_border)); } - input = input.focus(move |s| s.border_color(style.focus_border)); + input = input.focus_style(move |s| s.border_color(style.focus_border)); } let mut outer = div().id(ElementId::from(("text_input_root", entity_id))); diff --git a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs index a8a43c59f..3462c160f 100644 --- a/crates/gitcomet-ui-kit/src/window_chrome/mod.rs +++ b/crates/gitcomet-ui-kit/src/window_chrome/mod.rs @@ -268,7 +268,7 @@ pub fn titlebar_control_button( .h(icon_size) .flex_shrink_0() .text_color(idle_color) - .group_hover(id, move |s| s.text_color(hover_color)), + .group_hover_style(id, move |s| s.text_color(hover_color)), ), ), ) From 1569edc50b782e71352c05447875c40fb8cdcf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 14:33:12 +0300 Subject: [PATCH 57/67] Add Linux graphics renderer settings --- README.md | 7 + crates/gitcomet-ui-gpui/src/app/launch.rs | 8 +- crates/gitcomet-ui-gpui/src/app/tests.rs | 13 +- crates/gitcomet-ui-gpui/src/app/windows.rs | 25 +- crates/gitcomet-ui-gpui/src/lib.rs | 2 + crates/gitcomet-ui-gpui/src/linux_renderer.rs | 264 ++++++++++++++++++ .../src/view/settings_window/cards.rs | 2 +- .../view/settings_window/graphics_renderer.rs | 32 ++- .../src/view/settings_window/mod.rs | 10 +- .../src/view/settings_window/tests.rs | 2 + .../tests/graphics_renderer.rs | 170 +++++++++++ docs/linux-renderer.md | 28 ++ 12 files changed, 548 insertions(+), 15 deletions(-) create mode 100644 crates/gitcomet-ui-gpui/src/linux_renderer.rs create mode 100644 crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs create mode 100644 docs/linux-renderer.md diff --git a/README.md b/README.md index f6441dbc7..e79870518 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,13 @@ To install GnuPG, trust GitHub's signing key, and configure SSH allowed signers, Choose **Settings → Diff → Diff Engine** to make Difftastic the default, or use the diff viewer's cog for a temporary override. Both Split and Inline layouts support it. See [Difftastic](docs/difftastic.md) for partial staging and fallbacks. +### Graphics renderer + +On Linux and Windows, choose **Settings → General → Graphics → Graphics renderer** +to change the renderer for the next launch. See the [Linux](docs/linux-renderer.md) and +[Windows](docs/windows-renderer.md) guides for backend choices and diagnostic +overrides. + ### Themes GitComet supports built-in themes and user-provided custom themes. diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index ed7445d7e..844cf82b2 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -473,7 +473,11 @@ pub(super) fn run_windowed_app( let (application, renderer_session) = crate::windows_renderer::application( launch.view_config.view_mode == GitCometViewMode::Normal, ); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + let (application, renderer_session) = crate::linux_renderer::application( + launch.view_config.view_mode == GitCometViewMode::Normal, + ); + #[cfg(not(any(target_os = "windows", target_os = "linux")))] let application = gpui_platform::application(); let application = application .with_assets(GitCometAssets::with_extensions(extensions.assets())) @@ -504,7 +508,7 @@ pub(super) fn run_windowed_app( application.run(move |cx: &mut App| { cx.set_global(clean_shutdown_tracker); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] cx.set_global(renderer_session); crate::ui_probe::start_if_enabled(cx); crate::environment::initialize(cx); diff --git a/crates/gitcomet-ui-gpui/src/app/tests.rs b/crates/gitcomet-ui-gpui/src/app/tests.rs index 101741be5..368633a6a 100644 --- a/crates/gitcomet-ui-gpui/src/app/tests.rs +++ b/crates/gitcomet-ui-gpui/src/app/tests.rs @@ -12,14 +12,21 @@ use std::time::{Duration, Instant}; struct TestBackend; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] #[gpui::test] fn dismissing_a_restart_terminal_guard_retains_the_saved_renderer(cx: &mut gpui::TestAppContext) { + #[cfg(target_os = "linux")] + use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; + #[cfg(target_os = "windows")] use crate::windows_renderer::{RendererPreference, RendererSession, RendererState}; + #[cfg(target_os = "linux")] + let saved = RendererPreference::Gl; + #[cfg(target_os = "windows")] + let saved = RendererPreference::Dx11; let renderer = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( RendererState::new(RendererPreference::Auto, true), ))); - renderer.0.borrow_mut().saved = RendererPreference::Dx11; + renderer.0.borrow_mut().saved = saved; cx.update(|app| { app.set_global(renderer.clone()); app.set_global(windows::PendingRestart(true)); @@ -47,7 +54,7 @@ fn dismissing_a_restart_terminal_guard_retains_the_saved_renderer(cx: &mut gpui: window_cx.run_until_parked(); window_cx.update(|_, app| { assert!(!app.global::().0); - assert_eq!(renderer.0.borrow().saved, RendererPreference::Dx11); + assert_eq!(renderer.0.borrow().saved, saved); assert!(renderer.0.borrow().restart_required()); assert_eq!(app.windows().len(), 1); }); diff --git a/crates/gitcomet-ui-gpui/src/app/windows.rs b/crates/gitcomet-ui-gpui/src/app/windows.rs index 846f9ab04..11ae96a39 100644 --- a/crates/gitcomet-ui-gpui/src/app/windows.rs +++ b/crates/gitcomet-ui-gpui/src/app/windows.rs @@ -585,10 +585,14 @@ pub(crate) fn close_active_window_or_warn(cx: &mut App) { pub(crate) struct PendingRestart(pub(crate) bool); impl gpui::Global for PendingRestart {} -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] pub(crate) fn request_restart(cx: &mut App) { + #[cfg(target_os = "linux")] + use crate::linux_renderer::RendererSession; + #[cfg(target_os = "windows")] + use crate::windows_renderer::RendererSession; if cx - .try_global::() + .try_global::() .is_none_or(|session| !session.0.borrow().restart_allowed) { return; @@ -813,4 +817,21 @@ mod restart_tests { assert!(session.0.borrow().restart_required()); }); } + + #[cfg(target_os = "linux")] + #[gpui::test] + fn focused_renderer_changes_cannot_request_an_application_restart( + cx: &mut gpui::TestAppContext, + ) { + use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; + cx.update(|cx| { + let mut state = RendererState::new(RendererPreference::Auto, false); + state.saved = RendererPreference::Gl; + let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new(state))); + cx.set_global(session.clone()); + request_restart(cx); + assert!(cx.try_global::().is_none()); + assert_eq!(session.0.borrow().saved, RendererPreference::Gl); + }); + } } diff --git a/crates/gitcomet-ui-gpui/src/lib.rs b/crates/gitcomet-ui-gpui/src/lib.rs index d8d94cd5b..427fd9608 100644 --- a/crates/gitcomet-ui-gpui/src/lib.rs +++ b/crates/gitcomet-ui-gpui/src/lib.rs @@ -15,6 +15,8 @@ mod environment; mod external_editor; pub mod http; mod launch_guard; +#[cfg(target_os = "linux")] +mod linux_renderer; mod menu_labels; #[doc(hidden)] pub mod perf_alloc; diff --git a/crates/gitcomet-ui-gpui/src/linux_renderer.rs b/crates/gitcomet-ui-gpui/src/linux_renderer.rs new file mode 100644 index 000000000..01006c2d3 --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/linux_renderer.rs @@ -0,0 +1,264 @@ +use crate::preference_writer::PreferenceWriter; +use gitcomet_state::session; +use gpui_platform::{ + LinuxRendererBackend, LinuxRendererEvent, LinuxRendererOptions, LinuxRendererPreference, +}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum RendererPreference { + #[default] + Auto, + Vulkan, + Gl, +} + +impl RendererPreference { + pub(crate) fn parse(value: Option<&str>) -> Self { + match value { + Some("vulkan") => Self::Vulkan, + Some("gl") => Self::Gl, + _ => Self::Auto, + } + } + + pub(crate) fn key(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Vulkan => "vulkan", + Self::Gl => "gl", + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Auto => "Automatic", + Self::Vulkan => "Vulkan", + Self::Gl => "OpenGL", + } + } +} + +pub(crate) fn preference_settings(value: RendererPreference) -> session::UiSettings { + session::UiSettings { + linux_renderer: Some(value.key().into()), + ..Default::default() + } +} + +pub(crate) struct RendererState { + pub writer: PreferenceWriter, + pub saved: RendererPreference, + pub applied: RendererPreference, + pub active: Option, + pub overridden: bool, + pub saving: bool, + pub error: Option, + pub restart_allowed: bool, +} + +#[derive(Clone)] +pub(crate) struct RendererSession(pub Rc>); +impl gpui::Global for RendererSession {} + +impl RendererState { + pub(crate) fn new(saved: RendererPreference, restart_allowed: bool) -> Self { + Self { + writer: PreferenceWriter::default(), + saved, + applied: saved, + active: None, + overridden: false, + saving: false, + error: None, + restart_allowed, + } + } + + pub(crate) fn restart_required(&self) -> bool { + self.saved != self.applied && !self.overridden + } + + pub(crate) fn status_message(&self) -> String { + let active = match self.active { + Some(LinuxRendererBackend::Vulkan) => "Vulkan", + Some(LinuxRendererBackend::Gl) => "OpenGL", + None => "Starting", + }; + let mut status = format!("Active: {active}."); + if self.overridden { + status.push_str(" Controlled by WGPU_BACKEND."); + } + if self.restart_required() { + status.push_str(" Restart required."); + } + if !self.restart_allowed { + status.push_str(" Changes apply on the next launch."); + } + if let Some(error) = &self.error { + status.push(' '); + status.push_str(error); + } + status + } + + pub(crate) fn complete_manual_save( + &mut self, + sequence: u64, + value: RendererPreference, + result: std::io::Result, + ) -> bool { + if !self.writer.is_current(sequence) { + return false; + } + self.saving = false; + match result { + Ok(true) => { + self.saved = value; + self.error = None; + } + Ok(false) => return false, + Err(error) => { + self.error = Some(format!("Could not save graphics renderer: {error}")); + return false; + } + } + self.restart_required() && self.restart_allowed + } +} + +pub(crate) fn application(restart_allowed: bool) -> (gpui::Application, RendererSession) { + let saved = RendererPreference::parse(session::load().linux_renderer.as_deref()); + let state = Rc::new(RefCell::new(RendererState::new(saved, restart_allowed))); + let observer = state.clone(); + let options = LinuxRendererOptions { + preference: match saved { + RendererPreference::Auto => LinuxRendererPreference::Auto, + RendererPreference::Vulkan => LinuxRendererPreference::Vulkan, + RendererPreference::Gl => LinuxRendererPreference::Gl, + }, + on_event: Some(Rc::new(move |event| match event { + LinuxRendererEvent::Selected { + backend, + overridden, + } => { + let mut state = observer.borrow_mut(); + state.active = Some(backend); + state.overridden = overridden; + } + })), + }; + ( + gpui_platform::application_with_linux_renderer(options), + RendererSession(state), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persisted_preferences_select_each_backend_and_unknown_values_use_automatic() { + for (value, expected) in [ + (None, RendererPreference::Auto), + (Some("auto"), RendererPreference::Auto), + (Some("vulkan"), RendererPreference::Vulkan), + (Some("gl"), RendererPreference::Gl), + (Some("dx11"), RendererPreference::Auto), + (Some("unknown"), RendererPreference::Auto), + ] { + assert_eq!(RendererPreference::parse(value), expected); + } + for (value, key) in [ + (RendererPreference::Auto, "auto"), + (RendererPreference::Vulkan, "vulkan"), + (RendererPreference::Gl, "gl"), + ] { + assert_eq!( + preference_settings(value), + session::UiSettings { + linux_renderer: Some(key.into()), + ..Default::default() + } + ); + } + } + + #[test] + fn save_completes_before_restart_is_offered_and_keeps_the_active_backend() { + let mut state = RendererState::new(RendererPreference::Auto, true); + state.active = Some(LinuxRendererBackend::Vulkan); + let sequence = state.writer.next(); + state.saving = true; + assert!(!state.restart_required()); + assert!(state.complete_manual_save(sequence, RendererPreference::Gl, Ok(true))); + assert!(!state.saving); + assert_eq!(state.saved, RendererPreference::Gl); + assert_eq!(state.applied, RendererPreference::Auto); + assert_eq!(state.active, Some(LinuxRendererBackend::Vulkan)); + assert_eq!(state.status_message(), "Active: Vulkan. Restart required."); + } + + #[test] + fn failed_save_keeps_the_saved_preference_and_does_not_offer_restart() { + let mut state = RendererState::new(RendererPreference::Auto, true); + let sequence = state.writer.next(); + state.saving = true; + assert!(!state.complete_manual_save( + sequence, + RendererPreference::Gl, + Err(std::io::Error::other("read only")), + )); + assert!(!state.saving); + assert_eq!(state.saved, RendererPreference::Auto); + assert!(!state.restart_required()); + assert!( + state + .status_message() + .contains("Could not save graphics renderer: read only") + ); + } + + #[test] + fn stale_completion_cannot_replace_a_newer_preference_or_finish_its_save() { + let mut state = RendererState::new(RendererPreference::Auto, true); + let stale = state.writer.next(); + let current = state.writer.next(); + state.saving = true; + assert!(!state.complete_manual_save(stale, RendererPreference::Gl, Ok(true))); + assert!(state.saving); + assert_eq!(state.saved, RendererPreference::Auto); + assert!(state.complete_manual_save(current, RendererPreference::Vulkan, Ok(true))); + assert_eq!(state.saved, RendererPreference::Vulkan); + } + + #[test] + fn focused_launch_changes_are_saved_for_the_next_launch_without_a_restart_prompt() { + let mut state = RendererState::new(RendererPreference::Gl, false); + let sequence = state.writer.next(); + assert!(!state.complete_manual_save(sequence, RendererPreference::Auto, Ok(true))); + assert_eq!(state.saved, RendererPreference::Auto); + assert!(state.restart_required()); + assert!( + state + .status_message() + .contains("Changes apply on the next launch.") + ); + } + + #[test] + fn environment_override_reports_the_active_backend_without_requiring_restart() { + let mut state = RendererState::new(RendererPreference::Gl, true); + state.active = Some(LinuxRendererBackend::Vulkan); + state.overridden = true; + let sequence = state.writer.next(); + assert!(!state.complete_manual_save(sequence, RendererPreference::Auto, Ok(true))); + assert_eq!(state.saved, RendererPreference::Auto); + assert!(!state.restart_required()); + assert_eq!( + state.status_message(), + "Active: Vulkan. Controlled by WGPU_BACKEND." + ); + } +} diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs index c68468119..7d6297b64 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs @@ -41,7 +41,7 @@ impl SettingsWindowView { let card = self.card("settings_window_general", "General", theme); let card = self.general_integration_rows(card, theme, cx); let card = self.general_date_time_rows(card, theme, cx); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] let card = self.graphics_renderer_rows(card, theme, cx); card } diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs index 3d12de3eb..9dcca65da 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs @@ -1,6 +1,10 @@ use super::*; use crate::kit::interaction::{self as controls, ControlInteractionExt as _}; -use crate::windows_renderer::{RendererPreference, RendererSession}; +#[cfg(target_os = "linux")] +use crate::linux_renderer as renderer; +#[cfg(target_os = "windows")] +use crate::windows_renderer as renderer; +use renderer::{RendererPreference, RendererSession}; impl SettingsWindowView { pub(super) fn graphics_renderer_rows( @@ -39,7 +43,8 @@ impl SettingsWindowView { return card; } let mut detail = self.detail_container("settings_graphics_renderer_options", theme); - for (value, id, description) in [ + #[cfg(target_os = "windows")] + let options = [ ( RendererPreference::Auto, "settings_graphics_renderer_auto", @@ -50,7 +55,26 @@ impl SettingsWindowView { "settings_graphics_renderer_dx11", "Use DirectX 11 directly", ), - ] { + ]; + #[cfg(target_os = "linux")] + let options = [ + ( + RendererPreference::Auto, + "settings_graphics_renderer_auto", + "Vulkan when available, otherwise OpenGL", + ), + ( + RendererPreference::Vulkan, + "settings_graphics_renderer_vulkan", + "Use Vulkan directly", + ), + ( + RendererPreference::Gl, + "settings_graphics_renderer_gl", + "Use OpenGL directly", + ), + ]; + for (value, id, description) in options { detail = detail.child( self.option_row( id, @@ -101,7 +125,7 @@ impl SettingsWindowView { let sequence = writer.next(); let write = cx.background_spawn(async move { writer.persist(sequence, || { - session::persist_ui_settings(crate::windows_renderer::preference_settings(value)) + session::persist_ui_settings(renderer::preference_settings(value)) }) }); let window_handle = window.window_handle(); diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs index a2d5c9d4b..c3b8b0fb2 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs @@ -306,7 +306,7 @@ fn remote_url_policy_settings_label(policy: RemoteUrlPolicy) -> String { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SettingsSection { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] GraphicsRenderer, UiScale, WindowControls, @@ -341,7 +341,7 @@ impl SettingsSection { /// mapping keeps the visible page and the expanded row in sync. fn category(self) -> SettingsCategory { match self { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] Self::GraphicsRenderer => SettingsCategory::General, Self::UiScale | Self::WindowControls | Self::UiFont | Self::EditorFont => { SettingsCategory::Appearance @@ -547,6 +547,10 @@ impl SettingsCategory { { return true; } + #[cfg(target_os = "linux")] + if self == Self::General && "graphics renderer vulkan opengl".contains(&query) { + return true; + } self.search_haystack().contains(query.as_str()) || (matches!(self, Self::Appearance) && theme_names_match(&query)) } @@ -1602,7 +1606,7 @@ mod cards; pub(crate) mod close_guards; mod extension_host; mod extension_pages; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] mod graphics_renderer; mod prefs; mod render; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs index 66182de6d..b0ac05f76 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs @@ -32,6 +32,8 @@ mod appearance; mod chrome_and_layout; mod executables; mod extensions; +#[cfg(target_os = "linux")] +mod graphics_renderer; mod preferences; mod restoration; mod terminal; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs new file mode 100644 index 000000000..54afbeeff --- /dev/null +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs @@ -0,0 +1,170 @@ +use super::*; +use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; + +#[test] +fn linux_renderer_search_finds_the_general_page() { + for query in ["graphics", "graphics renderer", "vulkan", "opengl"] { + assert!(SettingsCategory::General.matches_query(query)); + assert!(!SettingsCategory::Appearance.matches_query(query)); + } + assert_eq!( + SettingsSection::GraphicsRenderer.category(), + SettingsCategory::General + ); +} + +fn isolated_session(test_name: &str, fail_writes: bool) -> Option { + if let Some(path) = std::env::var_os("GITCOMET_RENDERER_TEST_SESSION") { + return Some(path.into()); + } + let dir = tempfile::tempdir().unwrap(); + let path = if fail_writes { + dir.path().to_path_buf() + } else { + let path = dir.path().join("session.json"); + session::persist_ui_settings_to_path( + session::UiSettings { + linux_renderer: Some("auto".into()), + windows_renderer: Some("dx11".into()), + ..Default::default() + }, + &path, + ) + .unwrap(); + path + }; + let output = Command::new(std::env::current_exe().unwrap()) + .arg(format!( + "view::settings_window::tests::graphics_renderer::{test_name}" + )) + .arg("--exact") + .arg("--nocapture") + .env("GITCOMET_RENDERER_TEST_SESSION", &path) + .env("GITCOMET_SESSION_FILE", &path) + .output() + .unwrap(); + assert!( + output.status.success() + && String::from_utf8_lossy(&output.stdout) + .contains("test result: ok. 1 passed; 0 failed; 0 ignored;"), + "renderer subtest failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + None +} + +#[gpui::test] +fn linux_choices_save_before_restart_and_respect_overrides_and_focused_launches( + cx: &mut gpui::TestAppContext, +) { + let Some(path) = isolated_session( + "linux_choices_save_before_restart_and_respect_overrides_and_focused_launches", + false, + ) else { + return; + }; + let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( + RendererState::new(RendererPreference::Auto, true), + ))); + cx.update(|app| app.set_global(session.clone())); + let (settings, cx) = cx.add_window_view(SettingsWindowView::new); + cx.simulate_resize(size(px(720.0), px(1600.0))); + settings.update(cx, |settings, cx| { + settings.select_category(SettingsCategory::General, cx); + settings.set_expanded_section(Some(SettingsSection::GraphicsRenderer), cx); + assert_eq!(settings.preference_settings().linux_renderer, None); + assert_eq!(settings.preference_settings().windows_renderer, None); + }); + crate::view::test_support::redraw(cx); + let automatic = cx.debug_bounds("settings_graphics_renderer_auto").unwrap(); + let vulkan = cx + .debug_bounds("settings_graphics_renderer_vulkan") + .unwrap(); + let gl = cx.debug_bounds("settings_graphics_renderer_gl").unwrap(); + assert!(automatic.bottom() <= vulkan.top()); + assert!(vulkan.bottom() <= gl.top()); + cx.simulate_click(gl.center(), Modifiers::default()); + cx.run_until_parked(); + assert!(cx.has_pending_prompt()); + assert_eq!(session.0.borrow().saved, RendererPreference::Gl); + let saved = session::load_from_path(&path); + assert_eq!(saved.linux_renderer.as_deref(), Some("gl")); + assert_eq!(saved.windows_renderer.as_deref(), Some("dx11")); + cx.simulate_prompt_answer("Later"); + cx.run_until_parked(); + cx.update(|_, app| { + assert!(app.try_global::().is_none()); + }); + + session.0.borrow_mut().overridden = true; + crate::view::test_support::redraw(cx); + let vulkan = cx + .debug_bounds("settings_graphics_renderer_vulkan") + .unwrap(); + cx.simulate_click(vulkan.center(), Modifiers::default()); + cx.run_until_parked(); + assert!(!cx.has_pending_prompt()); + assert!(!session.0.borrow().saving); + assert_eq!(session.0.borrow().saved, RendererPreference::Gl); + assert_eq!( + session::load_from_path(&path).linux_renderer.as_deref(), + Some("gl") + ); + + { + let mut state = session.0.borrow_mut(); + state.overridden = false; + state.restart_allowed = false; + } + crate::view::test_support::redraw(cx); + let vulkan = cx + .debug_bounds("settings_graphics_renderer_vulkan") + .unwrap(); + cx.simulate_click(vulkan.center(), Modifiers::default()); + cx.run_until_parked(); + assert!(!cx.has_pending_prompt()); + assert_eq!(session.0.borrow().saved, RendererPreference::Vulkan); + assert_eq!( + session::load_from_path(&path).linux_renderer.as_deref(), + Some("vulkan") + ); +} + +#[gpui::test] +fn linux_renderer_save_failure_keeps_the_previous_selection_without_a_restart_prompt( + cx: &mut gpui::TestAppContext, +) { + let Some(path) = isolated_session( + "linux_renderer_save_failure_keeps_the_previous_selection_without_a_restart_prompt", + true, + ) else { + return; + }; + let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( + RendererState::new(RendererPreference::Auto, true), + ))); + cx.update(|app| app.set_global(session.clone())); + let (settings, cx) = cx.add_window_view(SettingsWindowView::new); + cx.simulate_resize(size(px(720.0), px(1600.0))); + settings.update(cx, |settings, cx| { + settings.select_category(SettingsCategory::General, cx); + settings.set_expanded_section(Some(SettingsSection::GraphicsRenderer), cx); + }); + crate::view::test_support::redraw(cx); + let gl = cx.debug_bounds("settings_graphics_renderer_gl").unwrap(); + cx.simulate_click(gl.center(), Modifiers::default()); + cx.run_until_parked(); + assert!(!cx.has_pending_prompt()); + let state = session.0.borrow(); + assert!(!state.saving); + assert_eq!(state.saved, RendererPreference::Auto); + assert!(!state.restart_required()); + assert!( + state + .status_message() + .contains("Could not save graphics renderer:") + ); + assert!(path.is_dir()); + assert_eq!(std::fs::read_dir(path).unwrap().count(), 0); +} diff --git a/docs/linux-renderer.md b/docs/linux-renderer.md new file mode 100644 index 000000000..82a7217ba --- /dev/null +++ b/docs/linux-renderer.md @@ -0,0 +1,28 @@ +# Linux graphics renderer + +GitComet defaults to **Automatic** on Linux, using Vulkan when available and +otherwise trying OpenGL. Choose **GitComet Settings → General → Graphics → +Graphics renderer** to select **Automatic**, **Vulkan**, or **OpenGL**. +Choosing Vulkan or OpenGL requires that backend; it does not fall back to the other. + +Changes are saved before GitComet offers **Restart now** or **Later**. Restart +uses the normal unsaved-file, terminal, Git-operation, and extension guards. +Canceling a guard keeps the new preference saved and cancels the restart. +Focused Git diff/mergetool invocations apply changes on their next launch so +Git's waiting command is not ended by a settings restart. + +The settings show the active backend separately from the saved preference. +Changing the preference does not switch the running renderer. Linux and Windows +renderer preferences are stored separately in the session file. + +For diagnostics, `WGPU_BACKEND=vulkan` or `WGPU_BACKEND=gl` overrides the saved +preference. An override disables the settings choices without changing the +saved preference. Comma-separated overrides retain the existing Vulkan-first +selection order. Unset `WGPU_BACKEND` to use the saved preference again. + +If a saved Vulkan or OpenGL choice prevents startup, close GitComet and set +`"linux_renderer": "auto"` in your `session.json`, or remove that key. +Preserve the other session data. GitComet normally uses +`$XDG_STATE_HOME/gitcomet/session.json`, falling back to +`~/.local/state/gitcomet/session.json`. If `GITCOMET_SESSION_FILE` is set, edit +that file instead. Launch without `WGPU_BACKEND` to use Automatic again. From 8947331f33e60bf83665ef71819582d07c5a225a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 14:43:53 +0300 Subject: [PATCH 58/67] Upgrade GPUI and renderer dependencies --- Cargo.lock | 174 +++++++++--------- Cargo.toml | 12 +- .../assets/open_source_licenses.tsv | 33 ++-- scripts/generate-open-source-licenses.sh | 5 +- 4 files changed, 109 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e366fb3fd..60ee5e85c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,9 +663,9 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec 0.9.1", ] @@ -1692,9 +1692,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -2111,7 +2111,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-kit" version = "0.14.3-1" -source = "git+https://github.com/Havunen/font-kit?rev=cc6f86949e40ef3a5e9b2063f8127cdcb72d7ac4#cc6f86949e40ef3a5e9b2063f8127cdcb72d7ac4" +source = "git+https://github.com/Havunen/font-kit?rev=78fcc88851d7b84e5631c47b99e78d44f20a01a4#78fcc88851d7b84e5631c47b99e78d44f20a01a4" dependencies = [ "bitflags 2.13.2", "byteorder", @@ -3856,30 +3856,10 @@ dependencies = [ "windows", ] -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.2", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3935,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "block", @@ -3981,7 +3961,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3971,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4029,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4078,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4089,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4103,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4118,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4132,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,11 +4140,11 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "gpui-ce", "naga 24.0.0", - "naga 29.0.4", + "naga 30.0.1", "smallvec", "wgsl-rs", "windows-core 0.100.0", @@ -4174,7 +4154,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4169,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "schemars", "serde", @@ -4199,7 +4179,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4190,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "log", @@ -4220,7 +4200,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4224,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "anyhow", "core-foundation", @@ -4275,7 +4255,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "accesskit", "accesskit_windows", @@ -4308,7 +4288,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -5454,22 +5434,22 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", "codespan-reporting 0.13.1", "half", - "hashbrown 0.16.1", - "hexf-parse", + "hashbrown 0.17.1", "indexmap", "libm", "log", + "naga-types", "num-traits", "once_cell", "rustc-hash 1.1.0", @@ -5478,6 +5458,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -5958,6 +5950,7 @@ dependencies = [ "bitflags 2.13.2", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] @@ -9441,9 +9434,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", "bitflags 2.13.2", @@ -9451,10 +9444,10 @@ dependencies = [ "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "log", - "naga 29.0.4", + "naga 30.0.1", "parking_lot", "portable-atomic", "profiling", @@ -9471,21 +9464,22 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set 0.10.0", "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap", "log", - "naga 29.0.4", + "naga 30.0.1", + "naga-types", "once_cell", "parking_lot", "portable-atomic", @@ -9505,50 +9499,50 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-wasm" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", @@ -9557,17 +9551,18 @@ dependencies = [ "glow", "glutin_wgl_sys", "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "khronos-egl", "libc", "libloading", "log", - "naga 29.0.4", + "naga 30.0.1", + "naga-types", "ndk-sys", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", @@ -9582,6 +9577,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", + "static_assertions", "thiserror 2.0.21", "wasm-bindgen", "wayland-sys", @@ -9595,36 +9591,38 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" dependencies = [ - "naga 29.0.4", + "naga 30.0.1", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ "bitflags 2.13.2", "bytemuck", "js-sys", "log", + "naga-types", "raw-window-handle", + "static_assertions", "web-sys", ] [[package]] name = "wgsl-rs" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" dependencies = [ "glam", "half", - "naga 29.0.4", + "naga 30.0.1", "paste", "snafu", "wgsl-rs-ir", @@ -9633,13 +9631,13 @@ dependencies = [ [[package]] name = "wgsl-rs-ir" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" [[package]] name = "wgsl-rs-macros" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" dependencies = [ "itertools 0.14.0", "proc-macro2", @@ -10092,7 +10090,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "encoding_rs", ] @@ -10100,7 +10098,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=0665fb07d59cf7af185e58f44b9f890590abe204#0665fb07d59cf7af185e58f44b9f890590abe204" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca#f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index 84fa7055a..224b7c29f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "0665fb07d59cf7af185e58f44b9f890590abe204" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv index 3b5c4f240..7c6b32500 100644 --- a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv +++ b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv @@ -75,9 +75,9 @@ backtrace 0.3.76 MIT OR Apache-2.0 base64 0.22.1 MIT OR Apache-2.0 base64 0.23.1 MIT OR Apache-2.0 bindgen 0.73.2 BSD-3-Clause +bit-set 0.10.0 Apache-2.0 OR MIT bit-set 0.11.1 Apache-2.0 OR MIT bit-set 0.8.0 Apache-2.0 OR MIT -bit-set 0.9.1 Apache-2.0 OR MIT bit-vec 0.10.1 Apache-2.0 OR MIT bit-vec 0.8.0 Apache-2.0 OR MIT bit-vec 0.9.1 Apache-2.0 OR MIT @@ -178,7 +178,7 @@ diff 0.1.13 MIT OR Apache-2.0 difftastic 0.71.0 MIT digest 0.10.7 MIT OR Apache-2.0 digest 0.11.3 MIT OR Apache-2.0 -dirs 6.0.0 MIT OR Apache-2.0 +dirs 7.0.0 MIT OR Apache-2.0 dirs-sys 0.5.0 MIT OR Apache-2.0 dispatch2 0.3.1 Zlib OR Apache-2.0 OR MIT displaydoc 0.2.7 MIT OR Apache-2.0 @@ -312,8 +312,6 @@ glob 0.3.4 MIT OR Apache-2.0 glow 0.17.0 MIT OR Apache-2.0 OR Zlib glutin_wgl_sys 0.6.1 Apache-2.0 gpu-allocator 0.28.0 MIT OR Apache-2.0 -gpu-descriptor 0.3.2 MIT OR Apache-2.0 -gpu-descriptor-types 0.2.0 MIT OR Apache-2.0 gpui-ce 0.2.2 Apache-2.0 gpui_ce_apple 0.1.0 Apache-2.0 gpui_ce_collections 0.2.2 Apache-2.0 @@ -452,7 +450,8 @@ miow 0.6.1 MIT OR Apache-2.0 moxcms 0.8.1 BSD-3-Clause OR Apache-2.0 multiversion_no_op 1.0.0 Apache-2.0 OR MIT naga 24.0.0 MIT OR Apache-2.0 -naga 29.0.4 MIT OR Apache-2.0 +naga 30.0.1 MIT OR Apache-2.0 +naga-types 30.0.1 MIT OR Apache-2.0 ndk-sys 0.6.0+11769913 MIT OR Apache-2.0 new_debug_unreachable 1.0.6 MIT nix 0.31.3 MIT @@ -864,18 +863,18 @@ web-sys 0.3.106 MIT OR Apache-2.0 web-time 1.1.0 MIT OR Apache-2.0 webpki-roots 1.0.10 CDLA-Permissive-2.0 weezl 0.1.12 MIT OR Apache-2.0 -wgpu 29.0.4 MIT OR Apache-2.0 -wgpu-core 29.0.4 MIT OR Apache-2.0 -wgpu-core-deps-apple 29.0.4 MIT OR Apache-2.0 -wgpu-core-deps-emscripten 29.0.4 MIT OR Apache-2.0 -wgpu-core-deps-wasm 29.0.4 MIT OR Apache-2.0 -wgpu-core-deps-windows-linux-android 29.0.4 MIT OR Apache-2.0 -wgpu-hal 29.0.4 MIT OR Apache-2.0 -wgpu-naga-bridge 29.0.4 MIT OR Apache-2.0 -wgpu-types 29.0.4 MIT OR Apache-2.0 -wgsl-rs 0.1.0 MIT -wgsl-rs-ir 0.1.0 MIT -wgsl-rs-macros 0.1.0 MIT +wgpu 30.0.1 MIT OR Apache-2.0 +wgpu-core 30.0.1 MIT OR Apache-2.0 +wgpu-core-deps-apple 30.0.1 MIT OR Apache-2.0 +wgpu-core-deps-emscripten 30.0.1 MIT OR Apache-2.0 +wgpu-core-deps-wasm 30.0.1 MIT OR Apache-2.0 +wgpu-core-deps-windows-linux-android 30.0.1 MIT OR Apache-2.0 +wgpu-hal 30.0.1 MIT OR Apache-2.0 +wgpu-naga-bridge 30.0.1 MIT OR Apache-2.0 +wgpu-types 30.0.1 MIT OR Apache-2.0 +wgsl-rs 0.1.0-beta.6 MIT +wgsl-rs-ir 0.1.0-beta.6 MIT +wgsl-rs-macros 0.1.0-beta.6 MIT which 8.0.6 MIT winapi 0.3.9 MIT/Apache-2.0 winapi-i686-pc-windows-gnu 0.4.0 MIT/Apache-2.0 diff --git a/scripts/generate-open-source-licenses.sh b/scripts/generate-open-source-licenses.sh index 4b80e7160..b06f1a231 100755 --- a/scripts/generate-open-source-licenses.sh +++ b/scripts/generate-open-source-licenses.sh @@ -39,10 +39,7 @@ BUNDLED_ASSET_LICENSE_ROWS=( # Crates whose manifest names a licence file instead of an SPDX id, keyed by # name@version so an upgrade has to confirm the licence again. LICENSE_OVERRIDES='{ - "tree-sitter-newick@1.1.0": "CECILL-C", - "wgsl-rs@0.1.0": "MIT", - "wgsl-rs-ir@0.1.0": "MIT", - "wgsl-rs-macros@0.1.0": "MIT" + "tree-sitter-newick@1.1.0": "CECILL-C" }' cleanup() { From 69a8543485f7302692854fd9c895bb8150d63b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 15:13:55 +0300 Subject: [PATCH 59/67] Update GPUI source contracts --- crates/gitcomet-ui-kit/gpui-revision | 2 +- crates/gitcomet-ui-kit/src/test_support.rs | 37 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index b6b1ca4fa..c6f6b638c 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -0665fb07d59cf7af185e58f44b9f890590abe204 +f76fe5dc0387cdbeab6db3b05e6cf73ab0caf6ca diff --git a/crates/gitcomet-ui-kit/src/test_support.rs b/crates/gitcomet-ui-kit/src/test_support.rs index 8503ec1f7..219ceb57b 100644 --- a/crates/gitcomet-ui-kit/src/test_support.rs +++ b/crates/gitcomet-ui-kit/src/test_support.rs @@ -355,7 +355,8 @@ pub mod source_guards { src_dir: &Path, allow: &DiscreteControlAllowlist<'_>, ) -> Vec { - let pattern = regex::Regex::new(r"\.(?:hover|active)\(\s*(?:move\s+)?\|").unwrap(); + let pattern = + regex::Regex::new(r"\.(?:hover|active)(?:_style)?\(\s*(?:move\s+)?\|").unwrap(); let raw_click = regex::Regex::new(r"\.on_click\(\s*(?:cx\.listener|on_click\b|(?:move\s+)?\|)") .unwrap(); @@ -439,6 +440,40 @@ pub mod source_guards { #[cfg(test)] mod tests { + #[test] + fn old_and_current_hover_press_methods_require_shared_ownership() { + let sources = tempfile::tempdir().unwrap(); + for method in ["hover", "hover_style", "active", "active_style"] { + std::fs::write( + sources.path().join("control.rs"), + format!("div().{method}(move |style| style.bg(color))"), + ) + .unwrap(); + let allow = super::DiscreteControlAllowlist { + custom_styling: &[], + release_gestures: &[], + primary_press: &[], + context_press: &[], + }; + assert_eq!( + super::discrete_control_violations(sources.path(), &allow), + ["control.rs: custom hover/press styling"], + "{method} must go through shared interaction styling" + ); + assert!( + super::discrete_control_violations( + sources.path(), + &super::DiscreteControlAllowlist { + custom_styling: &["control.rs"], + ..allow + }, + ) + .is_empty(), + "{method} keeps its ownership exemption necessary" + ); + } + } + #[test] fn allowlist_entries_that_are_missing_or_unneeded_are_violations() { let sources = tempfile::tempdir().unwrap(); From f9e3b5b9f2e7e7663f3bcef8fa4d84cf95b5d8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 15:18:45 +0300 Subject: [PATCH 60/67] Explain Linux renderer startup failures --- crates/gitcomet-state/src/session/mod.rs | 2 +- crates/gitcomet-ui-gpui/src/app/launch.rs | 26 +++- crates/gitcomet-ui-gpui/src/app/tests.rs | 11 ++ crates/gitcomet-ui-gpui/src/linux_renderer.rs | 133 +++++++++++++++++- docs/linux-renderer.md | 8 ++ 5 files changed, 169 insertions(+), 11 deletions(-) diff --git a/crates/gitcomet-state/src/session/mod.rs b/crates/gitcomet-state/src/session/mod.rs index c336fa564..25551bd43 100644 --- a/crates/gitcomet-state/src/session/mod.rs +++ b/crates/gitcomet-state/src/session/mod.rs @@ -789,7 +789,7 @@ fn persist_to_path(path: &Path, session: &impl Serialize) -> io::Result<()> { gitcomet_core::fs_utils::write_private_file(path, &contents) } -fn default_session_file_path() -> Option { +pub fn default_session_file_path() -> Option { #[cfg(test)] if let Some(path) = test_session_file_path_override() { return path; diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index 844cf82b2..656845679 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -789,13 +789,14 @@ pub(super) fn open_gitcomet_window( ) }) .unwrap_or_else(|err| { - let name = identity::current().display_name(); - panic!( - "failed to open main {name} window: {err}\n\ - This is usually a GPU/display problem, not a {name} bug. \ - If you just updated your system (kernel, mesa, or vulkan drivers), reboot. \ - For diagnostics, include the crash report from the next launch." - ) + #[cfg(target_os = "linux")] + let renderer_help = cx + .try_global::() + .map(|session| session.0.borrow().startup_failure_help()) + .unwrap_or_default(); + #[cfg(not(target_os = "linux"))] + let renderer_help = String::new(); + panic!("{}", window_open_failure_message(&err, &renderer_help)) }); #[cfg(target_os = "macos")] @@ -807,6 +808,17 @@ pub(super) fn open_gitcomet_window( window } +pub(super) fn window_open_failure_message(err: &anyhow::Error, renderer_help: &str) -> String { + let name = identity::current().display_name(); + let mut message = format!("failed to open main {name} window: {err:#}"); + if !renderer_help.is_empty() { + message.push('\n'); + message.push_str(renderer_help); + } + message.push_str("\nFor diagnostics, include the crash report from the next launch."); + message +} + /// Client-side decorations inset a rounded frame into the surface; the pixels /// outside it must show the desktop, not a solid fill, so every platform but /// macOS asks for a transparent surface. diff --git a/crates/gitcomet-ui-gpui/src/app/tests.rs b/crates/gitcomet-ui-gpui/src/app/tests.rs index 368633a6a..fd875ed17 100644 --- a/crates/gitcomet-ui-gpui/src/app/tests.rs +++ b/crates/gitcomet-ui-gpui/src/app/tests.rs @@ -12,6 +12,17 @@ use std::time::{Duration, Instant}; struct TestBackend; +#[test] +fn window_startup_failure_retains_the_gpu_cause_and_renderer_recovery_help() { + let err = anyhow::anyhow!("Gl: No GPU adapter found that can configure the display surface") + .context("failed to initialize a GPU context"); + let message = launch::window_open_failure_message(&err, "renderer recovery instructions"); + assert!(message.contains("failed to initialize a GPU context")); + assert!(message.contains("Gl: No GPU adapter found that can configure the display surface")); + assert!(message.contains("renderer recovery instructions")); + assert!(!message.contains("not a GitComet bug")); +} + #[cfg(any(target_os = "windows", target_os = "linux"))] #[gpui::test] fn dismissing_a_restart_terminal_guard_retains_the_saved_renderer(cx: &mut gpui::TestAppContext) { diff --git a/crates/gitcomet-ui-gpui/src/linux_renderer.rs b/crates/gitcomet-ui-gpui/src/linux_renderer.rs index 01006c2d3..3575e03f0 100644 --- a/crates/gitcomet-ui-gpui/src/linux_renderer.rs +++ b/crates/gitcomet-ui-gpui/src/linux_renderer.rs @@ -3,7 +3,7 @@ use gitcomet_state::session; use gpui_platform::{ LinuxRendererBackend, LinuxRendererEvent, LinuxRendererOptions, LinuxRendererPreference, }; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, path::PathBuf, rc::Rc}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum RendererPreference { @@ -55,6 +55,8 @@ pub(crate) struct RendererState { pub saving: bool, pub error: Option, pub restart_allowed: bool, + startup_session_path: Option, + startup_backend_override: Option, } #[derive(Clone)] @@ -72,9 +74,60 @@ impl RendererState { saving: false, error: None, restart_allowed, + startup_session_path: None, + startup_backend_override: None, } } + fn from_startup( + session_path: Option, + backend_override: Option, + restart_allowed: bool, + ) -> Self { + let session_path = session_path.map(|path| std::path::absolute(&path).unwrap_or(path)); + let session = session_path + .as_deref() + .map(session::load_from_path) + .unwrap_or_default(); + let saved = RendererPreference::parse(session.linux_renderer.as_deref()); + let mut state = Self::new(saved, restart_allowed); + state.startup_session_path = session_path; + state.startup_backend_override = backend_override; + state + } + + pub(crate) fn startup_failure_help(&self) -> String { + let mut help = format!( + "Linux graphics renderer preference: {} ({}).", + self.applied.label(), + self.applied.key() + ); + if let Some(value) = &self.startup_backend_override { + help.push_str(&format!( + " WGPU_BACKEND={value:?} overrides the saved preference for this launch. \ + Unset WGPU_BACKEND to use the saved preference." + )); + } + let Some(path) = &self.startup_session_path else { + help.push_str( + " No session settings file was loaded; persistence is disabled or unavailable.", + ); + return help; + }; + if path.is_absolute() { + help.push_str(&format!(" Settings file: {path:?}.")); + } else { + help.push_str(&format!( + " Settings file (relative path; startup working directory could not be resolved): {path:?}." + )); + } + help.push_str( + " Close the app, then set \"linux_renderer\": \"auto\" or remove only that key in this file, \ + preserving the other session data. Then launch the app again." + ); + help + } + pub(crate) fn restart_required(&self) -> bool { self.saved != self.applied && !self.overridden } @@ -128,8 +181,13 @@ impl RendererState { } pub(crate) fn application(restart_allowed: bool) -> (gpui::Application, RendererSession) { - let saved = RendererPreference::parse(session::load().linux_renderer.as_deref()); - let state = Rc::new(RefCell::new(RendererState::new(saved, restart_allowed))); + let state = RendererState::from_startup( + session::default_session_file_path(), + std::env::var("WGPU_BACKEND").ok(), + restart_allowed, + ); + let saved = state.applied; + let state = Rc::new(RefCell::new(state)); let observer = state.clone(); let options = LinuxRendererOptions { preference: match saved { @@ -158,6 +216,75 @@ pub(crate) fn application(restart_allowed: bool) -> (gpui::Application, Renderer mod tests { use super::*; + #[test] + fn startup_failure_help_identifies_the_saved_renderer_and_its_exact_session_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom-session.json"); + for (key, label) in [ + ("auto", "Automatic"), + ("vulkan", "Vulkan"), + ("gl", "OpenGL"), + ] { + let contents = + format!(r#"{{"version":6,"linux_renderer":"{key}","theme_mode":"light"}}"#); + std::fs::write(&path, &contents).unwrap(); + let mut state = RendererState::from_startup(Some(path.clone()), None, true); + state.saved = RendererPreference::Vulkan; + assert_eq!(state.active, None); + assert!(!state.overridden); + let help = state.startup_failure_help(); + assert!(help.contains(&format!( + "Linux graphics renderer preference: {label} ({key})." + ))); + assert!(help.contains(&format!("Settings file: {path:?}"))); + assert!(help.contains(r#"set "linux_renderer": "auto" or remove only that key"#)); + assert!(help.contains("preserving the other session data")); + assert!(!help.contains("WGPU_BACKEND")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), contents); + } + } + + #[test] + fn startup_override_is_reported_even_before_a_backend_was_selected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.json"); + std::fs::write(&path, r#"{"version":6,"linux_renderer":"gl"}"#).unwrap(); + let state = RendererState::from_startup(Some(path), Some("vulkan,gl".into()), false); + assert!(!state.overridden); + assert_eq!(state.active, None); + let help = state.startup_failure_help(); + assert!(help.contains("Linux graphics renderer preference: OpenGL (gl).")); + assert!(help.contains(r#"WGPU_BACKEND="vulkan,gl" overrides the saved preference"#)); + assert!(help.contains("Unset WGPU_BACKEND")); + } + + #[test] + fn startup_relative_settings_path_is_anchored_without_requiring_the_file_to_exist() { + let cwd = std::env::current_dir().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + let relative = dir + .path() + .strip_prefix(&cwd) + .unwrap() + .join("missing-session.json"); + let expected = cwd.join(&relative); + let state = RendererState::from_startup(Some(relative), None, true); + assert!( + state + .startup_failure_help() + .contains(&format!("Settings file: {expected:?}")) + ); + } + + #[test] + fn startup_without_session_persistence_does_not_invent_a_settings_file() { + let state = RendererState::from_startup(None, None, true); + let help = state.startup_failure_help(); + assert!(help.contains("No session settings file was loaded")); + assert!(!help.contains("Settings file:")); + assert!(!help.contains("remove only that key")); + } + #[test] fn persisted_preferences_select_each_backend_and_unknown_values_use_automatic() { for (value, expected) in [ diff --git a/docs/linux-renderer.md b/docs/linux-renderer.md index 82a7217ba..57edb7f9d 100644 --- a/docs/linux-renderer.md +++ b/docs/linux-renderer.md @@ -26,3 +26,11 @@ Preserve the other session data. GitComet normally uses `$XDG_STATE_HOME/gitcomet/session.json`, falling back to `~/.local/state/gitcomet/session.json`. If `GITCOMET_SESSION_FILE` is set, edit that file instead. Launch without `WGPU_BACKEND` to use Automatic again. + +OpenGL can fail even when the driver supports it. On Linux, wgpu's +[EGL configuration selection](https://github.com/gfx-rs/wgpu/blob/v30.0.1/wgpu-hal/src/gles/egl.rs#L99-L163) +requires a native-renderable window configuration. Some NVIDIA EGL +configurations do not provide one, leaving no compatible surface formats and +producing `No GPU adapter found that can configure the display surface`. +Use **Automatic** or **Vulkan**; if the app cannot open, reset the saved choice +as described above. From 5fa67a681675847d1f3f7e78c07a4cd3fa45fc86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 16:50:06 +0300 Subject: [PATCH 61/67] Remove the Linux renderer choice Linux goes back to GPUI's default renderer: no Auto/Vulkan/OpenGL setting, no saved preference, and no restart or recovery path for it. The Windows DirectX selector is unchanged. A window that fails to open still reports its full error cause. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 - crates/gitcomet-state/src/session/mod.rs | 6 +- crates/gitcomet-state/src/session/settings.rs | 2 - crates/gitcomet-state/src/session/tests.rs | 55 --- crates/gitcomet-ui-gpui/src/app/launch.rs | 32 +- crates/gitcomet-ui-gpui/src/app/tests.rs | 19 +- crates/gitcomet-ui-gpui/src/app/windows.rs | 25 +- crates/gitcomet-ui-gpui/src/lib.rs | 2 - crates/gitcomet-ui-gpui/src/linux_renderer.rs | 391 ------------------ .../src/view/settings_window/cards.rs | 2 +- .../view/settings_window/graphics_renderer.rs | 32 +- .../src/view/settings_window/mod.rs | 10 +- .../src/view/settings_window/prefs.rs | 1 - .../src/view/settings_window/tests.rs | 2 - .../tests/graphics_renderer.rs | 170 -------- .../src/view/ui_persistence.rs | 1 - docs/linux-renderer.md | 36 -- 17 files changed, 25 insertions(+), 768 deletions(-) delete mode 100644 crates/gitcomet-ui-gpui/src/linux_renderer.rs delete mode 100644 crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs delete mode 100644 docs/linux-renderer.md diff --git a/README.md b/README.md index e79870518..f6441dbc7 100644 --- a/README.md +++ b/README.md @@ -254,13 +254,6 @@ To install GnuPG, trust GitHub's signing key, and configure SSH allowed signers, Choose **Settings → Diff → Diff Engine** to make Difftastic the default, or use the diff viewer's cog for a temporary override. Both Split and Inline layouts support it. See [Difftastic](docs/difftastic.md) for partial staging and fallbacks. -### Graphics renderer - -On Linux and Windows, choose **Settings → General → Graphics → Graphics renderer** -to change the renderer for the next launch. See the [Linux](docs/linux-renderer.md) and -[Windows](docs/windows-renderer.md) guides for backend choices and diagnostic -overrides. - ### Themes GitComet supports built-in themes and user-provided custom themes. diff --git a/crates/gitcomet-state/src/session/mod.rs b/crates/gitcomet-state/src/session/mod.rs index 25551bd43..b5f94b575 100644 --- a/crates/gitcomet-state/src/session/mod.rs +++ b/crates/gitcomet-state/src/session/mod.rs @@ -42,7 +42,6 @@ pub struct UiSession { pub image_smooth_filter: Option, pub window_controls_mode: Option, pub windows_renderer: Option, - pub linux_renderer: Option, pub browser_open_target: Option, pub ui_density: Option, pub ui_font_size_px: Option, @@ -194,8 +193,6 @@ pub(crate) struct UiSessionFile { #[serde(default, deserialize_with = "lenient")] windows_renderer: Option, #[serde(default, deserialize_with = "lenient")] - linux_renderer: Option, - #[serde(default, deserialize_with = "lenient")] browser_open_target: Option, #[serde(default, deserialize_with = "lenient")] ui_density: Option, @@ -500,7 +497,6 @@ pub fn load_from_path(path: &Path) -> UiSession { image_smooth_filter: file.image_smooth_filter, window_controls_mode: file.window_controls_mode, windows_renderer: file.windows_renderer, - linux_renderer: file.linux_renderer, browser_open_target: file.browser_open_target, ui_density: file.ui_density, ui_font_size_px: file.ui_font_size_px, @@ -789,7 +785,7 @@ fn persist_to_path(path: &Path, session: &impl Serialize) -> io::Result<()> { gitcomet_core::fs_utils::write_private_file(path, &contents) } -pub fn default_session_file_path() -> Option { +fn default_session_file_path() -> Option { #[cfg(test)] if let Some(path) = test_session_file_path_override() { return path; diff --git a/crates/gitcomet-state/src/session/settings.rs b/crates/gitcomet-state/src/session/settings.rs index d42760411..fc408b050 100644 --- a/crates/gitcomet-state/src/session/settings.rs +++ b/crates/gitcomet-state/src/session/settings.rs @@ -15,7 +15,6 @@ pub struct UiSettings { pub image_smooth_filter: Option, pub window_controls_mode: Option, pub windows_renderer: Option, - pub linux_renderer: Option, pub browser_open_target: Option, pub ui_density: Option, pub ui_font_size_px: Option, @@ -122,7 +121,6 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res apply_setting!(settings, file, image_smooth_filter); apply_setting!(settings, file, window_controls_mode); apply_setting!(settings, file, windows_renderer); - apply_setting!(settings, file, linux_renderer); apply_setting!(settings, file, browser_open_target); apply_setting!(settings, file, ui_density); apply_setting!(settings, file, ui_font_size_px); diff --git a/crates/gitcomet-state/src/session/tests.rs b/crates/gitcomet-state/src/session/tests.rs index 0dd263b43..c625da8f0 100644 --- a/crates/gitcomet-state/src/session/tests.rs +++ b/crates/gitcomet-state/src/session/tests.rs @@ -3068,61 +3068,6 @@ fn persist_ui_settings_round_trips_window_controls_mode() { assert_eq!(loaded.browser_open_target.as_deref(), Some("new_window")); } -#[test] -fn linux_renderer_roundtrips_without_changing_the_windows_preference() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session.json"); - assert_eq!(load_from_path(&path).linux_renderer, None); - persist_ui_settings_to_path( - UiSettings { - windows_renderer: Some("dx11".into()), - theme_mode: Some("dark".into()), - ..Default::default() - }, - &path, - ) - .unwrap(); - for value in ["gl", "vulkan", "auto"] { - persist_ui_settings_to_path( - UiSettings { - linux_renderer: Some(value.into()), - ..Default::default() - }, - &path, - ) - .unwrap(); - let loaded = load_from_path(&path); - assert_eq!(loaded.linux_renderer.as_deref(), Some(value)); - assert_eq!(loaded.windows_renderer.as_deref(), Some("dx11")); - assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); - } -} - -#[test] -fn linux_renderer_survives_settings_and_workspace_writes() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session.json"); - std::fs::write( - &path, - r#"{"version":6,"linux_renderer":"gl","windows_renderer":"dx11","extensions":{"example":{"draft":"keep"}}}"#, - ) - .unwrap(); - persist_ui_settings_to_path( - UiSettings { - theme_mode: Some("dark".into()), - ..Default::default() - }, - &path, - ) - .unwrap(); - persist_workspaces_to_path(&[Workspace::new(vec![dir.path().to_path_buf()])], &path).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(json["linux_renderer"], "gl"); - assert_eq!(json["windows_renderer"], "dx11"); - assert_eq!(json["extensions"]["example"]["draft"], "keep"); - assert_eq!(load_from_path(&path).workspaces.len(), 1); -} - #[test] fn windows_renderer_fallback_survives_other_settings_and_workspace_writes() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/gitcomet-ui-gpui/src/app/launch.rs b/crates/gitcomet-ui-gpui/src/app/launch.rs index aaaebb491..34e188721 100644 --- a/crates/gitcomet-ui-gpui/src/app/launch.rs +++ b/crates/gitcomet-ui-gpui/src/app/launch.rs @@ -480,11 +480,7 @@ pub(super) fn run_windowed_app( let (application, renderer_session) = crate::windows_renderer::application( launch.view_config.view_mode == GitCometViewMode::Normal, ); - #[cfg(target_os = "linux")] - let (application, renderer_session) = crate::linux_renderer::application( - launch.view_config.view_mode == GitCometViewMode::Normal, - ); - #[cfg(not(any(target_os = "windows", target_os = "linux")))] + #[cfg(not(target_os = "windows"))] let application = gpui_platform::application(); drop(platform_stage); let http_stage = gitcomet_core::startup_trace::span("http_client"); @@ -520,7 +516,7 @@ pub(super) fn run_windowed_app( application.run(move |cx: &mut App| { gitcomet_core::startup_trace::mark("event_loop_callback"); cx.set_global(clean_shutdown_tracker); - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(target_os = "windows")] cx.set_global(renderer_session); crate::ui_probe::start_if_enabled(cx); crate::environment::initialize(cx); @@ -812,16 +808,7 @@ pub(super) fn open_gitcomet_window( }, ) }) - .unwrap_or_else(|err| { - #[cfg(target_os = "linux")] - let renderer_help = cx - .try_global::() - .map(|session| session.0.borrow().startup_failure_help()) - .unwrap_or_default(); - #[cfg(not(target_os = "linux"))] - let renderer_help = String::new(); - panic!("{}", window_open_failure_message(&err, &renderer_help)) - }); + .unwrap_or_else(|err| panic!("{}", window_open_failure_message(&err))); #[cfg(target_os = "macos")] refresh_macos_app_menus(cx); @@ -832,15 +819,12 @@ pub(super) fn open_gitcomet_window( window } -pub(super) fn window_open_failure_message(err: &anyhow::Error, renderer_help: &str) -> String { +pub(super) fn window_open_failure_message(err: &anyhow::Error) -> String { let name = identity::current().display_name(); - let mut message = format!("failed to open main {name} window: {err:#}"); - if !renderer_help.is_empty() { - message.push('\n'); - message.push_str(renderer_help); - } - message.push_str("\nFor diagnostics, include the crash report from the next launch."); - message + format!( + "failed to open main {name} window: {err:#}\n\ + For diagnostics, include the crash report from the next launch." + ) } /// Windows and macOS use their default opaque surfaces. Linux client-side diff --git a/crates/gitcomet-ui-gpui/src/app/tests.rs b/crates/gitcomet-ui-gpui/src/app/tests.rs index fd875ed17..896bca7de 100644 --- a/crates/gitcomet-ui-gpui/src/app/tests.rs +++ b/crates/gitcomet-ui-gpui/src/app/tests.rs @@ -13,31 +13,24 @@ use std::time::{Duration, Instant}; struct TestBackend; #[test] -fn window_startup_failure_retains_the_gpu_cause_and_renderer_recovery_help() { +fn window_startup_failure_retains_the_full_cause() { let err = anyhow::anyhow!("Gl: No GPU adapter found that can configure the display surface") .context("failed to initialize a GPU context"); - let message = launch::window_open_failure_message(&err, "renderer recovery instructions"); + let message = launch::window_open_failure_message(&err); assert!(message.contains("failed to initialize a GPU context")); assert!(message.contains("Gl: No GPU adapter found that can configure the display surface")); - assert!(message.contains("renderer recovery instructions")); + assert!(message.contains("include the crash report from the next launch")); assert!(!message.contains("not a GitComet bug")); } -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(target_os = "windows")] #[gpui::test] fn dismissing_a_restart_terminal_guard_retains_the_saved_renderer(cx: &mut gpui::TestAppContext) { - #[cfg(target_os = "linux")] - use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; - #[cfg(target_os = "windows")] use crate::windows_renderer::{RendererPreference, RendererSession, RendererState}; - #[cfg(target_os = "linux")] - let saved = RendererPreference::Gl; - #[cfg(target_os = "windows")] - let saved = RendererPreference::Dx11; let renderer = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( RendererState::new(RendererPreference::Auto, true), ))); - renderer.0.borrow_mut().saved = saved; + renderer.0.borrow_mut().saved = RendererPreference::Dx11; cx.update(|app| { app.set_global(renderer.clone()); app.set_global(windows::PendingRestart(true)); @@ -65,7 +58,7 @@ fn dismissing_a_restart_terminal_guard_retains_the_saved_renderer(cx: &mut gpui: window_cx.run_until_parked(); window_cx.update(|_, app| { assert!(!app.global::().0); - assert_eq!(renderer.0.borrow().saved, saved); + assert_eq!(renderer.0.borrow().saved, RendererPreference::Dx11); assert!(renderer.0.borrow().restart_required()); assert_eq!(app.windows().len(), 1); }); diff --git a/crates/gitcomet-ui-gpui/src/app/windows.rs b/crates/gitcomet-ui-gpui/src/app/windows.rs index 11ae96a39..846f9ab04 100644 --- a/crates/gitcomet-ui-gpui/src/app/windows.rs +++ b/crates/gitcomet-ui-gpui/src/app/windows.rs @@ -585,14 +585,10 @@ pub(crate) fn close_active_window_or_warn(cx: &mut App) { pub(crate) struct PendingRestart(pub(crate) bool); impl gpui::Global for PendingRestart {} -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(target_os = "windows")] pub(crate) fn request_restart(cx: &mut App) { - #[cfg(target_os = "linux")] - use crate::linux_renderer::RendererSession; - #[cfg(target_os = "windows")] - use crate::windows_renderer::RendererSession; if cx - .try_global::() + .try_global::() .is_none_or(|session| !session.0.borrow().restart_allowed) { return; @@ -817,21 +813,4 @@ mod restart_tests { assert!(session.0.borrow().restart_required()); }); } - - #[cfg(target_os = "linux")] - #[gpui::test] - fn focused_renderer_changes_cannot_request_an_application_restart( - cx: &mut gpui::TestAppContext, - ) { - use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; - cx.update(|cx| { - let mut state = RendererState::new(RendererPreference::Auto, false); - state.saved = RendererPreference::Gl; - let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new(state))); - cx.set_global(session.clone()); - request_restart(cx); - assert!(cx.try_global::().is_none()); - assert_eq!(session.0.borrow().saved, RendererPreference::Gl); - }); - } } diff --git a/crates/gitcomet-ui-gpui/src/lib.rs b/crates/gitcomet-ui-gpui/src/lib.rs index 427fd9608..d8d94cd5b 100644 --- a/crates/gitcomet-ui-gpui/src/lib.rs +++ b/crates/gitcomet-ui-gpui/src/lib.rs @@ -15,8 +15,6 @@ mod environment; mod external_editor; pub mod http; mod launch_guard; -#[cfg(target_os = "linux")] -mod linux_renderer; mod menu_labels; #[doc(hidden)] pub mod perf_alloc; diff --git a/crates/gitcomet-ui-gpui/src/linux_renderer.rs b/crates/gitcomet-ui-gpui/src/linux_renderer.rs deleted file mode 100644 index 3575e03f0..000000000 --- a/crates/gitcomet-ui-gpui/src/linux_renderer.rs +++ /dev/null @@ -1,391 +0,0 @@ -use crate::preference_writer::PreferenceWriter; -use gitcomet_state::session; -use gpui_platform::{ - LinuxRendererBackend, LinuxRendererEvent, LinuxRendererOptions, LinuxRendererPreference, -}; -use std::{cell::RefCell, path::PathBuf, rc::Rc}; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) enum RendererPreference { - #[default] - Auto, - Vulkan, - Gl, -} - -impl RendererPreference { - pub(crate) fn parse(value: Option<&str>) -> Self { - match value { - Some("vulkan") => Self::Vulkan, - Some("gl") => Self::Gl, - _ => Self::Auto, - } - } - - pub(crate) fn key(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::Vulkan => "vulkan", - Self::Gl => "gl", - } - } - - pub(crate) fn label(self) -> &'static str { - match self { - Self::Auto => "Automatic", - Self::Vulkan => "Vulkan", - Self::Gl => "OpenGL", - } - } -} - -pub(crate) fn preference_settings(value: RendererPreference) -> session::UiSettings { - session::UiSettings { - linux_renderer: Some(value.key().into()), - ..Default::default() - } -} - -pub(crate) struct RendererState { - pub writer: PreferenceWriter, - pub saved: RendererPreference, - pub applied: RendererPreference, - pub active: Option, - pub overridden: bool, - pub saving: bool, - pub error: Option, - pub restart_allowed: bool, - startup_session_path: Option, - startup_backend_override: Option, -} - -#[derive(Clone)] -pub(crate) struct RendererSession(pub Rc>); -impl gpui::Global for RendererSession {} - -impl RendererState { - pub(crate) fn new(saved: RendererPreference, restart_allowed: bool) -> Self { - Self { - writer: PreferenceWriter::default(), - saved, - applied: saved, - active: None, - overridden: false, - saving: false, - error: None, - restart_allowed, - startup_session_path: None, - startup_backend_override: None, - } - } - - fn from_startup( - session_path: Option, - backend_override: Option, - restart_allowed: bool, - ) -> Self { - let session_path = session_path.map(|path| std::path::absolute(&path).unwrap_or(path)); - let session = session_path - .as_deref() - .map(session::load_from_path) - .unwrap_or_default(); - let saved = RendererPreference::parse(session.linux_renderer.as_deref()); - let mut state = Self::new(saved, restart_allowed); - state.startup_session_path = session_path; - state.startup_backend_override = backend_override; - state - } - - pub(crate) fn startup_failure_help(&self) -> String { - let mut help = format!( - "Linux graphics renderer preference: {} ({}).", - self.applied.label(), - self.applied.key() - ); - if let Some(value) = &self.startup_backend_override { - help.push_str(&format!( - " WGPU_BACKEND={value:?} overrides the saved preference for this launch. \ - Unset WGPU_BACKEND to use the saved preference." - )); - } - let Some(path) = &self.startup_session_path else { - help.push_str( - " No session settings file was loaded; persistence is disabled or unavailable.", - ); - return help; - }; - if path.is_absolute() { - help.push_str(&format!(" Settings file: {path:?}.")); - } else { - help.push_str(&format!( - " Settings file (relative path; startup working directory could not be resolved): {path:?}." - )); - } - help.push_str( - " Close the app, then set \"linux_renderer\": \"auto\" or remove only that key in this file, \ - preserving the other session data. Then launch the app again." - ); - help - } - - pub(crate) fn restart_required(&self) -> bool { - self.saved != self.applied && !self.overridden - } - - pub(crate) fn status_message(&self) -> String { - let active = match self.active { - Some(LinuxRendererBackend::Vulkan) => "Vulkan", - Some(LinuxRendererBackend::Gl) => "OpenGL", - None => "Starting", - }; - let mut status = format!("Active: {active}."); - if self.overridden { - status.push_str(" Controlled by WGPU_BACKEND."); - } - if self.restart_required() { - status.push_str(" Restart required."); - } - if !self.restart_allowed { - status.push_str(" Changes apply on the next launch."); - } - if let Some(error) = &self.error { - status.push(' '); - status.push_str(error); - } - status - } - - pub(crate) fn complete_manual_save( - &mut self, - sequence: u64, - value: RendererPreference, - result: std::io::Result, - ) -> bool { - if !self.writer.is_current(sequence) { - return false; - } - self.saving = false; - match result { - Ok(true) => { - self.saved = value; - self.error = None; - } - Ok(false) => return false, - Err(error) => { - self.error = Some(format!("Could not save graphics renderer: {error}")); - return false; - } - } - self.restart_required() && self.restart_allowed - } -} - -pub(crate) fn application(restart_allowed: bool) -> (gpui::Application, RendererSession) { - let state = RendererState::from_startup( - session::default_session_file_path(), - std::env::var("WGPU_BACKEND").ok(), - restart_allowed, - ); - let saved = state.applied; - let state = Rc::new(RefCell::new(state)); - let observer = state.clone(); - let options = LinuxRendererOptions { - preference: match saved { - RendererPreference::Auto => LinuxRendererPreference::Auto, - RendererPreference::Vulkan => LinuxRendererPreference::Vulkan, - RendererPreference::Gl => LinuxRendererPreference::Gl, - }, - on_event: Some(Rc::new(move |event| match event { - LinuxRendererEvent::Selected { - backend, - overridden, - } => { - let mut state = observer.borrow_mut(); - state.active = Some(backend); - state.overridden = overridden; - } - })), - }; - ( - gpui_platform::application_with_linux_renderer(options), - RendererSession(state), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn startup_failure_help_identifies_the_saved_renderer_and_its_exact_session_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("custom-session.json"); - for (key, label) in [ - ("auto", "Automatic"), - ("vulkan", "Vulkan"), - ("gl", "OpenGL"), - ] { - let contents = - format!(r#"{{"version":6,"linux_renderer":"{key}","theme_mode":"light"}}"#); - std::fs::write(&path, &contents).unwrap(); - let mut state = RendererState::from_startup(Some(path.clone()), None, true); - state.saved = RendererPreference::Vulkan; - assert_eq!(state.active, None); - assert!(!state.overridden); - let help = state.startup_failure_help(); - assert!(help.contains(&format!( - "Linux graphics renderer preference: {label} ({key})." - ))); - assert!(help.contains(&format!("Settings file: {path:?}"))); - assert!(help.contains(r#"set "linux_renderer": "auto" or remove only that key"#)); - assert!(help.contains("preserving the other session data")); - assert!(!help.contains("WGPU_BACKEND")); - assert_eq!(std::fs::read_to_string(&path).unwrap(), contents); - } - } - - #[test] - fn startup_override_is_reported_even_before_a_backend_was_selected() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session.json"); - std::fs::write(&path, r#"{"version":6,"linux_renderer":"gl"}"#).unwrap(); - let state = RendererState::from_startup(Some(path), Some("vulkan,gl".into()), false); - assert!(!state.overridden); - assert_eq!(state.active, None); - let help = state.startup_failure_help(); - assert!(help.contains("Linux graphics renderer preference: OpenGL (gl).")); - assert!(help.contains(r#"WGPU_BACKEND="vulkan,gl" overrides the saved preference"#)); - assert!(help.contains("Unset WGPU_BACKEND")); - } - - #[test] - fn startup_relative_settings_path_is_anchored_without_requiring_the_file_to_exist() { - let cwd = std::env::current_dir().unwrap(); - let dir = tempfile::tempdir_in(&cwd).unwrap(); - let relative = dir - .path() - .strip_prefix(&cwd) - .unwrap() - .join("missing-session.json"); - let expected = cwd.join(&relative); - let state = RendererState::from_startup(Some(relative), None, true); - assert!( - state - .startup_failure_help() - .contains(&format!("Settings file: {expected:?}")) - ); - } - - #[test] - fn startup_without_session_persistence_does_not_invent_a_settings_file() { - let state = RendererState::from_startup(None, None, true); - let help = state.startup_failure_help(); - assert!(help.contains("No session settings file was loaded")); - assert!(!help.contains("Settings file:")); - assert!(!help.contains("remove only that key")); - } - - #[test] - fn persisted_preferences_select_each_backend_and_unknown_values_use_automatic() { - for (value, expected) in [ - (None, RendererPreference::Auto), - (Some("auto"), RendererPreference::Auto), - (Some("vulkan"), RendererPreference::Vulkan), - (Some("gl"), RendererPreference::Gl), - (Some("dx11"), RendererPreference::Auto), - (Some("unknown"), RendererPreference::Auto), - ] { - assert_eq!(RendererPreference::parse(value), expected); - } - for (value, key) in [ - (RendererPreference::Auto, "auto"), - (RendererPreference::Vulkan, "vulkan"), - (RendererPreference::Gl, "gl"), - ] { - assert_eq!( - preference_settings(value), - session::UiSettings { - linux_renderer: Some(key.into()), - ..Default::default() - } - ); - } - } - - #[test] - fn save_completes_before_restart_is_offered_and_keeps_the_active_backend() { - let mut state = RendererState::new(RendererPreference::Auto, true); - state.active = Some(LinuxRendererBackend::Vulkan); - let sequence = state.writer.next(); - state.saving = true; - assert!(!state.restart_required()); - assert!(state.complete_manual_save(sequence, RendererPreference::Gl, Ok(true))); - assert!(!state.saving); - assert_eq!(state.saved, RendererPreference::Gl); - assert_eq!(state.applied, RendererPreference::Auto); - assert_eq!(state.active, Some(LinuxRendererBackend::Vulkan)); - assert_eq!(state.status_message(), "Active: Vulkan. Restart required."); - } - - #[test] - fn failed_save_keeps_the_saved_preference_and_does_not_offer_restart() { - let mut state = RendererState::new(RendererPreference::Auto, true); - let sequence = state.writer.next(); - state.saving = true; - assert!(!state.complete_manual_save( - sequence, - RendererPreference::Gl, - Err(std::io::Error::other("read only")), - )); - assert!(!state.saving); - assert_eq!(state.saved, RendererPreference::Auto); - assert!(!state.restart_required()); - assert!( - state - .status_message() - .contains("Could not save graphics renderer: read only") - ); - } - - #[test] - fn stale_completion_cannot_replace_a_newer_preference_or_finish_its_save() { - let mut state = RendererState::new(RendererPreference::Auto, true); - let stale = state.writer.next(); - let current = state.writer.next(); - state.saving = true; - assert!(!state.complete_manual_save(stale, RendererPreference::Gl, Ok(true))); - assert!(state.saving); - assert_eq!(state.saved, RendererPreference::Auto); - assert!(state.complete_manual_save(current, RendererPreference::Vulkan, Ok(true))); - assert_eq!(state.saved, RendererPreference::Vulkan); - } - - #[test] - fn focused_launch_changes_are_saved_for_the_next_launch_without_a_restart_prompt() { - let mut state = RendererState::new(RendererPreference::Gl, false); - let sequence = state.writer.next(); - assert!(!state.complete_manual_save(sequence, RendererPreference::Auto, Ok(true))); - assert_eq!(state.saved, RendererPreference::Auto); - assert!(state.restart_required()); - assert!( - state - .status_message() - .contains("Changes apply on the next launch.") - ); - } - - #[test] - fn environment_override_reports_the_active_backend_without_requiring_restart() { - let mut state = RendererState::new(RendererPreference::Gl, true); - state.active = Some(LinuxRendererBackend::Vulkan); - state.overridden = true; - let sequence = state.writer.next(); - assert!(!state.complete_manual_save(sequence, RendererPreference::Auto, Ok(true))); - assert_eq!(state.saved, RendererPreference::Auto); - assert!(!state.restart_required()); - assert_eq!( - state.status_message(), - "Active: Vulkan. Controlled by WGPU_BACKEND." - ); - } -} diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs index 7d6297b64..c68468119 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/cards.rs @@ -41,7 +41,7 @@ impl SettingsWindowView { let card = self.card("settings_window_general", "General", theme); let card = self.general_integration_rows(card, theme, cx); let card = self.general_date_time_rows(card, theme, cx); - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(target_os = "windows")] let card = self.graphics_renderer_rows(card, theme, cx); card } diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs index 9dcca65da..3d12de3eb 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/graphics_renderer.rs @@ -1,10 +1,6 @@ use super::*; use crate::kit::interaction::{self as controls, ControlInteractionExt as _}; -#[cfg(target_os = "linux")] -use crate::linux_renderer as renderer; -#[cfg(target_os = "windows")] -use crate::windows_renderer as renderer; -use renderer::{RendererPreference, RendererSession}; +use crate::windows_renderer::{RendererPreference, RendererSession}; impl SettingsWindowView { pub(super) fn graphics_renderer_rows( @@ -43,8 +39,7 @@ impl SettingsWindowView { return card; } let mut detail = self.detail_container("settings_graphics_renderer_options", theme); - #[cfg(target_os = "windows")] - let options = [ + for (value, id, description) in [ ( RendererPreference::Auto, "settings_graphics_renderer_auto", @@ -55,26 +50,7 @@ impl SettingsWindowView { "settings_graphics_renderer_dx11", "Use DirectX 11 directly", ), - ]; - #[cfg(target_os = "linux")] - let options = [ - ( - RendererPreference::Auto, - "settings_graphics_renderer_auto", - "Vulkan when available, otherwise OpenGL", - ), - ( - RendererPreference::Vulkan, - "settings_graphics_renderer_vulkan", - "Use Vulkan directly", - ), - ( - RendererPreference::Gl, - "settings_graphics_renderer_gl", - "Use OpenGL directly", - ), - ]; - for (value, id, description) in options { + ] { detail = detail.child( self.option_row( id, @@ -125,7 +101,7 @@ impl SettingsWindowView { let sequence = writer.next(); let write = cx.background_spawn(async move { writer.persist(sequence, || { - session::persist_ui_settings(renderer::preference_settings(value)) + session::persist_ui_settings(crate::windows_renderer::preference_settings(value)) }) }); let window_handle = window.window_handle(); diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs index b9787495d..80ab10e5e 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/mod.rs @@ -306,7 +306,7 @@ fn remote_url_policy_settings_label(policy: RemoteUrlPolicy) -> String { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SettingsSection { - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(target_os = "windows")] GraphicsRenderer, UiScale, WindowControls, @@ -341,7 +341,7 @@ impl SettingsSection { /// mapping keeps the visible page and the expanded row in sync. fn category(self) -> SettingsCategory { match self { - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(target_os = "windows")] Self::GraphicsRenderer => SettingsCategory::General, Self::UiScale | Self::WindowControls | Self::UiFont | Self::EditorFont => { SettingsCategory::Appearance @@ -547,10 +547,6 @@ impl SettingsCategory { { return true; } - #[cfg(target_os = "linux")] - if self == Self::General && "graphics renderer vulkan opengl".contains(&query) { - return true; - } self.search_haystack().contains(query.as_str()) || (matches!(self, Self::Appearance) && theme_names_match(&query)) } @@ -1621,7 +1617,7 @@ mod cards; pub(crate) mod close_guards; mod extension_host; mod extension_pages; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(target_os = "windows")] mod graphics_renderer; mod prefs; mod render; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs index b0261282b..055f633ee 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/prefs.rs @@ -28,7 +28,6 @@ impl SettingsWindowView { window_controls_mode: Some(self.window_controls_mode.key().to_string()), // Renderer changes are persisted separately; ordinary preferences must not overwrite a fallback. windows_renderer: None, - linux_renderer: None, browser_open_target: Some(self.browser_open_target.key().to_string()), ui_density: Some(self.appearance_metrics.density.key().to_string()), ui_font_size_px: Some(self.appearance_metrics.ui_font_size_px), diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs index 39e1485b6..6e0f16b39 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window/tests.rs @@ -32,8 +32,6 @@ mod appearance; mod chrome_and_layout; mod executables; mod extensions; -#[cfg(target_os = "linux")] -mod graphics_renderer; mod large_file_tools_lifecycle; mod preferences; mod restoration; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs b/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs deleted file mode 100644 index 54afbeeff..000000000 --- a/crates/gitcomet-ui-gpui/src/view/settings_window/tests/graphics_renderer.rs +++ /dev/null @@ -1,170 +0,0 @@ -use super::*; -use crate::linux_renderer::{RendererPreference, RendererSession, RendererState}; - -#[test] -fn linux_renderer_search_finds_the_general_page() { - for query in ["graphics", "graphics renderer", "vulkan", "opengl"] { - assert!(SettingsCategory::General.matches_query(query)); - assert!(!SettingsCategory::Appearance.matches_query(query)); - } - assert_eq!( - SettingsSection::GraphicsRenderer.category(), - SettingsCategory::General - ); -} - -fn isolated_session(test_name: &str, fail_writes: bool) -> Option { - if let Some(path) = std::env::var_os("GITCOMET_RENDERER_TEST_SESSION") { - return Some(path.into()); - } - let dir = tempfile::tempdir().unwrap(); - let path = if fail_writes { - dir.path().to_path_buf() - } else { - let path = dir.path().join("session.json"); - session::persist_ui_settings_to_path( - session::UiSettings { - linux_renderer: Some("auto".into()), - windows_renderer: Some("dx11".into()), - ..Default::default() - }, - &path, - ) - .unwrap(); - path - }; - let output = Command::new(std::env::current_exe().unwrap()) - .arg(format!( - "view::settings_window::tests::graphics_renderer::{test_name}" - )) - .arg("--exact") - .arg("--nocapture") - .env("GITCOMET_RENDERER_TEST_SESSION", &path) - .env("GITCOMET_SESSION_FILE", &path) - .output() - .unwrap(); - assert!( - output.status.success() - && String::from_utf8_lossy(&output.stdout) - .contains("test result: ok. 1 passed; 0 failed; 0 ignored;"), - "renderer subtest failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - None -} - -#[gpui::test] -fn linux_choices_save_before_restart_and_respect_overrides_and_focused_launches( - cx: &mut gpui::TestAppContext, -) { - let Some(path) = isolated_session( - "linux_choices_save_before_restart_and_respect_overrides_and_focused_launches", - false, - ) else { - return; - }; - let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( - RendererState::new(RendererPreference::Auto, true), - ))); - cx.update(|app| app.set_global(session.clone())); - let (settings, cx) = cx.add_window_view(SettingsWindowView::new); - cx.simulate_resize(size(px(720.0), px(1600.0))); - settings.update(cx, |settings, cx| { - settings.select_category(SettingsCategory::General, cx); - settings.set_expanded_section(Some(SettingsSection::GraphicsRenderer), cx); - assert_eq!(settings.preference_settings().linux_renderer, None); - assert_eq!(settings.preference_settings().windows_renderer, None); - }); - crate::view::test_support::redraw(cx); - let automatic = cx.debug_bounds("settings_graphics_renderer_auto").unwrap(); - let vulkan = cx - .debug_bounds("settings_graphics_renderer_vulkan") - .unwrap(); - let gl = cx.debug_bounds("settings_graphics_renderer_gl").unwrap(); - assert!(automatic.bottom() <= vulkan.top()); - assert!(vulkan.bottom() <= gl.top()); - cx.simulate_click(gl.center(), Modifiers::default()); - cx.run_until_parked(); - assert!(cx.has_pending_prompt()); - assert_eq!(session.0.borrow().saved, RendererPreference::Gl); - let saved = session::load_from_path(&path); - assert_eq!(saved.linux_renderer.as_deref(), Some("gl")); - assert_eq!(saved.windows_renderer.as_deref(), Some("dx11")); - cx.simulate_prompt_answer("Later"); - cx.run_until_parked(); - cx.update(|_, app| { - assert!(app.try_global::().is_none()); - }); - - session.0.borrow_mut().overridden = true; - crate::view::test_support::redraw(cx); - let vulkan = cx - .debug_bounds("settings_graphics_renderer_vulkan") - .unwrap(); - cx.simulate_click(vulkan.center(), Modifiers::default()); - cx.run_until_parked(); - assert!(!cx.has_pending_prompt()); - assert!(!session.0.borrow().saving); - assert_eq!(session.0.borrow().saved, RendererPreference::Gl); - assert_eq!( - session::load_from_path(&path).linux_renderer.as_deref(), - Some("gl") - ); - - { - let mut state = session.0.borrow_mut(); - state.overridden = false; - state.restart_allowed = false; - } - crate::view::test_support::redraw(cx); - let vulkan = cx - .debug_bounds("settings_graphics_renderer_vulkan") - .unwrap(); - cx.simulate_click(vulkan.center(), Modifiers::default()); - cx.run_until_parked(); - assert!(!cx.has_pending_prompt()); - assert_eq!(session.0.borrow().saved, RendererPreference::Vulkan); - assert_eq!( - session::load_from_path(&path).linux_renderer.as_deref(), - Some("vulkan") - ); -} - -#[gpui::test] -fn linux_renderer_save_failure_keeps_the_previous_selection_without_a_restart_prompt( - cx: &mut gpui::TestAppContext, -) { - let Some(path) = isolated_session( - "linux_renderer_save_failure_keeps_the_previous_selection_without_a_restart_prompt", - true, - ) else { - return; - }; - let session = RendererSession(std::rc::Rc::new(std::cell::RefCell::new( - RendererState::new(RendererPreference::Auto, true), - ))); - cx.update(|app| app.set_global(session.clone())); - let (settings, cx) = cx.add_window_view(SettingsWindowView::new); - cx.simulate_resize(size(px(720.0), px(1600.0))); - settings.update(cx, |settings, cx| { - settings.select_category(SettingsCategory::General, cx); - settings.set_expanded_section(Some(SettingsSection::GraphicsRenderer), cx); - }); - crate::view::test_support::redraw(cx); - let gl = cx.debug_bounds("settings_graphics_renderer_gl").unwrap(); - cx.simulate_click(gl.center(), Modifiers::default()); - cx.run_until_parked(); - assert!(!cx.has_pending_prompt()); - let state = session.0.borrow(); - assert!(!state.saving); - assert_eq!(state.saved, RendererPreference::Auto); - assert!(!state.restart_required()); - assert!( - state - .status_message() - .contains("Could not save graphics renderer:") - ); - assert!(path.is_dir()); - assert_eq!(std::fs::read_dir(path).unwrap().count(), 0); -} diff --git a/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs b/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs index e43e53b22..7155eea37 100644 --- a/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs +++ b/crates/gitcomet-ui-gpui/src/view/ui_persistence.rs @@ -191,7 +191,6 @@ impl GitCometView { image_smooth_filter: None, // Owned by the settings window; None preserves it. windows_renderer: None, - linux_renderer: None, window_controls_mode: None, browser_open_target: None, ui_density: Some(crate::appearance::current(cx).density.key().to_string()), diff --git a/docs/linux-renderer.md b/docs/linux-renderer.md deleted file mode 100644 index 57edb7f9d..000000000 --- a/docs/linux-renderer.md +++ /dev/null @@ -1,36 +0,0 @@ -# Linux graphics renderer - -GitComet defaults to **Automatic** on Linux, using Vulkan when available and -otherwise trying OpenGL. Choose **GitComet Settings → General → Graphics → -Graphics renderer** to select **Automatic**, **Vulkan**, or **OpenGL**. -Choosing Vulkan or OpenGL requires that backend; it does not fall back to the other. - -Changes are saved before GitComet offers **Restart now** or **Later**. Restart -uses the normal unsaved-file, terminal, Git-operation, and extension guards. -Canceling a guard keeps the new preference saved and cancels the restart. -Focused Git diff/mergetool invocations apply changes on their next launch so -Git's waiting command is not ended by a settings restart. - -The settings show the active backend separately from the saved preference. -Changing the preference does not switch the running renderer. Linux and Windows -renderer preferences are stored separately in the session file. - -For diagnostics, `WGPU_BACKEND=vulkan` or `WGPU_BACKEND=gl` overrides the saved -preference. An override disables the settings choices without changing the -saved preference. Comma-separated overrides retain the existing Vulkan-first -selection order. Unset `WGPU_BACKEND` to use the saved preference again. - -If a saved Vulkan or OpenGL choice prevents startup, close GitComet and set -`"linux_renderer": "auto"` in your `session.json`, or remove that key. -Preserve the other session data. GitComet normally uses -`$XDG_STATE_HOME/gitcomet/session.json`, falling back to -`~/.local/state/gitcomet/session.json`. If `GITCOMET_SESSION_FILE` is set, edit -that file instead. Launch without `WGPU_BACKEND` to use Automatic again. - -OpenGL can fail even when the driver supports it. On Linux, wgpu's -[EGL configuration selection](https://github.com/gfx-rs/wgpu/blob/v30.0.1/wgpu-hal/src/gles/egl.rs#L99-L163) -requires a native-renderable window configuration. Some NVIDIA EGL -configurations do not provide one, leaving no compatible surface formats and -producing `No GPU adapter found that can configure the display surface`. -Use **Automatic** or **Vulkan**; if the app cannot open, reset the saved choice -as described above. From 7fd19170535e83c2151ae4db741ba51d07a7b546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 16:58:18 +0300 Subject: [PATCH 62/67] Pin gpui-ce with wgpu 30 and precompiled shaders Pin every gpui-ce crate to 1b6f3ced: gpui-ce main (precompiled DX12 shaders, deferred smoothing shaders, disabled interactivity and the upstream sync) merged with the wgpu 30.0.1 upgrade, which no longer has a Linux renderer choice. Regenerate the license attribution from the new lock. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 174 +++++++++--------- Cargo.toml | 12 +- .../assets/open_source_licenses.tsv | 45 +++++ crates/gitcomet-ui-kit/gpui-revision | 2 +- 4 files changed, 138 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae2f86145..467554099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,9 +663,9 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec 0.9.1", ] @@ -1692,9 +1692,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -2111,7 +2111,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-kit" version = "0.14.3-1" -source = "git+https://github.com/Havunen/font-kit?rev=cc6f86949e40ef3a5e9b2063f8127cdcb72d7ac4#cc6f86949e40ef3a5e9b2063f8127cdcb72d7ac4" +source = "git+https://github.com/Havunen/font-kit?rev=78fcc88851d7b84e5631c47b99e78d44f20a01a4#78fcc88851d7b84e5631c47b99e78d44f20a01a4" dependencies = [ "bitflags 2.13.2", "byteorder", @@ -3856,30 +3856,10 @@ dependencies = [ "windows", ] -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.2", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "accesskit", "anyhow", @@ -3955,7 +3935,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "block", @@ -3981,7 +3961,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "gpui_ce_util", "indexmap", @@ -3991,7 +3971,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "proc-macro2", "quote", @@ -4001,7 +3981,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "accesskit", "accesskit_unix", @@ -4049,7 +4029,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "accesskit", "accesskit_macos", @@ -4098,7 +4078,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "heck", "proc-macro2", @@ -4109,7 +4089,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "bindgen", @@ -4123,7 +4103,7 @@ dependencies = [ [[package]] name = "gpui_ce_parley" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "fontique", @@ -4138,7 +4118,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -4152,7 +4132,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "gpui_ce_derive_refineable", ] @@ -4160,11 +4140,11 @@ dependencies = [ [[package]] name = "gpui_ce_render" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "gpui-ce", "naga 24.0.0", - "naga 29.0.4", + "naga 30.0.1", "smallvec", "wgsl-rs", "windows-core 0.100.0", @@ -4174,7 +4154,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "async-task", "backtrace", @@ -4189,7 +4169,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "schemars", "serde", @@ -4199,7 +4179,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "heapless 0.9.3", "log", @@ -4210,7 +4190,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "log", @@ -4220,7 +4200,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "console_error_panic_hook", @@ -4244,7 +4224,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "anyhow", "core-foundation", @@ -4276,7 +4256,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "accesskit", "accesskit_windows", @@ -4309,7 +4289,7 @@ dependencies = [ [[package]] name = "gpui_xim" version = "0.4.0-gpui" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "ahash", "hashbrown 0.17.1", @@ -5455,22 +5435,22 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", "codespan-reporting 0.13.1", "half", - "hashbrown 0.16.1", - "hexf-parse", + "hashbrown 0.17.1", "indexmap", "libm", "log", + "naga-types", "num-traits", "once_cell", "rustc-hash 1.1.0", @@ -5479,6 +5459,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -5959,6 +5951,7 @@ dependencies = [ "bitflags 2.13.2", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] @@ -9442,9 +9435,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", "bitflags 2.13.2", @@ -9452,10 +9445,10 @@ dependencies = [ "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "log", - "naga 29.0.4", + "naga 30.0.1", "parking_lot", "portable-atomic", "profiling", @@ -9472,21 +9465,22 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set 0.10.0", "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap", "log", - "naga 29.0.4", + "naga 30.0.1", + "naga-types", "once_cell", "parking_lot", "portable-atomic", @@ -9506,50 +9500,50 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-wasm" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", @@ -9558,17 +9552,18 @@ dependencies = [ "glow", "glutin_wgl_sys", "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "khronos-egl", "libc", "libloading", "log", - "naga 29.0.4", + "naga 30.0.1", + "naga-types", "ndk-sys", "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", @@ -9583,6 +9578,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", + "static_assertions", "thiserror 2.0.21", "wasm-bindgen", "wayland-sys", @@ -9596,36 +9592,38 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" dependencies = [ - "naga 29.0.4", + "naga 30.0.1", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "29.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ "bitflags 2.13.2", "bytemuck", "js-sys", "log", + "naga-types", "raw-window-handle", + "static_assertions", "web-sys", ] [[package]] name = "wgsl-rs" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" dependencies = [ "glam", "half", - "naga 29.0.4", + "naga 30.0.1", "paste", "snafu", "wgsl-rs-ir", @@ -9634,13 +9632,13 @@ dependencies = [ [[package]] name = "wgsl-rs-ir" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" [[package]] name = "wgsl-rs-macros" -version = "0.1.0" -source = "git+https://github.com/schell/wgsl-rs?rev=b2de16a1b81e67d76bc265c296184f882864badb#b2de16a1b81e67d76bc265c296184f882864badb" +version = "0.1.0-beta.6" +source = "git+https://github.com/schell/wgsl-rs?rev=41bb92d2c76ea08091da11a6b3b6d539c19e7828#41bb92d2c76ea08091da11a6b3b6d539c19e7828" dependencies = [ "itertools 0.14.0", "proc-macro2", @@ -10093,7 +10091,7 @@ checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "encoding_rs", ] @@ -10101,7 +10099,7 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/Havunen/gpui-ce.git?rev=43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb#43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" +source = "git+https://github.com/Havunen/gpui-ce.git?rev=1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0#1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" dependencies = [ "bitflags 2.13.2", ] diff --git a/Cargo.toml b/Cargo.toml index 78d0959c5..f1c69b1fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,12 +163,12 @@ futures = "0.3.34" gix = { version = "0.89.0", default-features = false, features = ["max-performance-safe", "comfort", "basic", "status", "blame", "sha1", "sha256"] } # Already in the tree via gix; Git-compatible quoting for attribute patterns. gix-quote = "0.10.0" -gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb", features = ["font-kit"] } -gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } -gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb", default-features = false } -gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } -gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "43d6ac9e1331201e9f9112ab37fedfe19ab1e0cb" } +gpui = { package = "gpui-ce", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0", features = ["font-kit"] } +gpui_wgpu = { package = "gpui_ce_wgpu", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" } +gpui_parley = { package = "gpui_ce_parley", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0", default-features = false } +gpui_shared_string = { package = "gpui_ce_shared_string", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" } +gpui_sum_tree = { package = "gpui_ce_sum_tree", git = "https://github.com/Havunen/gpui-ce.git", rev = "1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0" } image = "0.25.10" fast_image_resize = { version = "6.1.0", features = ["image"] } # Already in the dependency graph; merges sorted reference streams lazily. diff --git a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv index ffed441f4..7c6b32500 100644 --- a/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv +++ b/crates/gitcomet-ui-gpui/assets/open_source_licenses.tsv @@ -85,6 +85,7 @@ bit_field 0.10.3 Apache-2.0/MIT bitflags 1.3.2 MIT/Apache-2.0 bitflags 2.13.2 MIT OR Apache-2.0 bitstream-io 4.10.0 MIT/Apache-2.0 +block 0.1.6 MIT block-buffer 0.10.4 MIT OR Apache-2.0 block-buffer 0.12.1 MIT OR Apache-2.0 block-padding 0.3.3 MIT OR Apache-2.0 @@ -174,6 +175,7 @@ derive_more 2.1.1 MIT derive_more-impl 2.1.1 MIT derive_setters 0.1.9 MIT/Apache-2.0 diff 0.1.13 MIT OR Apache-2.0 +difftastic 0.71.0 MIT digest 0.10.7 MIT OR Apache-2.0 digest 0.11.3 MIT OR Apache-2.0 dirs 7.0.0 MIT OR Apache-2.0 @@ -438,6 +440,7 @@ memchr 2.8.3 Unlicense OR MIT memfd 0.6.6 MIT OR Apache-2.0 memmap2 0.9.11 MIT OR Apache-2.0 memoffset 0.9.1 MIT +mermaid-rs-renderer 0.3.1 MIT metal 0.33.0 MIT OR Apache-2.0 minimal-lexical 0.2.1 MIT/Apache-2.0 miniz_oxide 0.8.9 MIT OR Zlib OR Apache-2.0 @@ -709,53 +712,95 @@ tracing-attributes 0.1.31 MIT tracing-core 0.1.36 MIT tree-sitter 0.27.1 MIT tree-sitter-ada 0.1.0 MIT +tree-sitter-asm 0.24.0 MIT tree-sitter-bash 0.25.1 MIT tree-sitter-bicep 1.1.0 MIT +tree-sitter-build 0.1.0 MIT tree-sitter-c 0.24.2 MIT +tree-sitter-c-sharp 0.23.5 MIT +tree-sitter-caddyfile 0.1.0 MIT +tree-sitter-cil 0.1.0 MIT tree-sitter-clojure-orchard 0.2.8 CC0-1.0 tree-sitter-cmake 0.7.5 MIT +tree-sitter-coffee 0.1.0 MIT tree-sitter-commonlisp 0.4.1 MIT tree-sitter-containerfile 0.9.2 MIT +tree-sitter-cpp 0.23.4 MIT +tree-sitter-crontab 0.1.0 MIT +tree-sitter-css 0.25.0 MIT +tree-sitter-csv 1.2.0 MIT +tree-sitter-cue 0.0.1 MIT tree-sitter-dart 0.2.0 MIT tree-sitter-devicetree 0.15.0 MIT +tree-sitter-dhall 0.1.0 MIT tree-sitter-diff 0.1.0 MIT +tree-sitter-ebnf 0.1.0 MIT tree-sitter-elisp 1.7.2 MIT tree-sitter-elixir 0.3.5 Apache-2.0 tree-sitter-elm 5.9.4 MIT tree-sitter-erlang 0.21.0 MIT tree-sitter-fish 3.6.0 MIT +tree-sitter-fsharp 0.3.12 MIT tree-sitter-gitcommit 0.0.1 MIT +tree-sitter-gitignore 0.1.0 MIT tree-sitter-gleam 1.0.0 Apache-2.0 tree-sitter-go 0.25.0 MIT tree-sitter-gomod 1.1.1 MIT tree-sitter-gowork 0.0.1 MIT +tree-sitter-haskell 0.24.1 MIT tree-sitter-hcl 1.1.0 Apache-2.0 +tree-sitter-html 0.23.2 MIT tree-sitter-ini 1.4.0 Apache-2.0 +tree-sitter-janet-simple 0.0.3 CC0-1.0 tree-sitter-java 0.23.5 MIT tree-sitter-javascript 0.25.0 MIT +tree-sitter-jinja-dialects 0.1.1 MIT tree-sitter-jsdoc 0.25.0 MIT tree-sitter-json 0.24.8 MIT tree-sitter-jsonnet 0.0.1 MIT +tree-sitter-julia 0.23.1 MIT +tree-sitter-just 0.1.0 Apache-2.0 +tree-sitter-kdl 1.1.0 MIT +tree-sitter-kotlin-sg 0.4.1 MIT tree-sitter-language 0.1.9 MIT +tree-sitter-latex 0.4.0 MIT tree-sitter-llvm 1.1.0 MIT tree-sitter-lua 0.5.0 MIT tree-sitter-make 1.1.1 MIT tree-sitter-md 0.3.2 MIT tree-sitter-newick 1.1.0 CECILL-C tree-sitter-nix 0.3.0 MIT +tree-sitter-objc 3.0.2 MIT +tree-sitter-ocaml 0.26.0 MIT tree-sitter-pascal 0.10.2 MIT tree-sitter-perl 1.1.2 MIT +tree-sitter-php 0.25.1 MIT +tree-sitter-powershell 0.26.4 MIT +tree-sitter-properties 0.3.0 MIT tree-sitter-proto 0.6.0 MIT tree-sitter-python 0.25.0 MIT tree-sitter-qmljs 0.3.1 MIT tree-sitter-r 1.3.0 MIT tree-sitter-racket 0.25.0 MIT tree-sitter-regex 0.25.0 MIT +tree-sitter-ron 0.2.0 MIT +tree-sitter-ruby 0.23.1 MIT +tree-sitter-rust 0.24.2 MIT +tree-sitter-scala 0.26.2 MIT tree-sitter-scheme 0.25.0 MIT +tree-sitter-sequel 0.3.11 MIT tree-sitter-sfapex 3.0.1 MIT +tree-sitter-smali 1.0.0 MIT +tree-sitter-solidity 1.2.13 MIT +tree-sitter-spirv 0.1.0 MIT tree-sitter-svelte-ng 1.0.2 MIT +tree-sitter-swift 0.7.4 MIT tree-sitter-toml-ng 0.7.0 MIT +tree-sitter-typescript 0.23.2 MIT +tree-sitter-v 0.0.4 ISC tree-sitter-vhdl 1.4.0 MIT +tree-sitter-vue 0.1.0 MIT +tree-sitter-wat 0.1.0 Apache-2.0 WITH LLVM-exception tree-sitter-xml 0.7.0 MIT tree-sitter-yaml 0.7.2 MIT tree-sitter-zig 1.1.2 MIT diff --git a/crates/gitcomet-ui-kit/gpui-revision b/crates/gitcomet-ui-kit/gpui-revision index 5e08c88cb..e59eae38c 100644 --- a/crates/gitcomet-ui-kit/gpui-revision +++ b/crates/gitcomet-ui-kit/gpui-revision @@ -1 +1 @@ -a4ee85008357c2e8e659bf8383ccae65ab62f94f +1b6f3cedafcd97fa15e6bc5fdbb8f34cc805afc0 From 0e3f2212aa85d7e0344f1d0d5def1a6568510b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 17:08:16 +0300 Subject: [PATCH 63/67] List no submodules in the popover test repository Since #633 the view reports store errors that arrive before its window attaches. The popover tests' fake repository inherited the backend default, which fails submodule listing, so every view built on it started with an "Unsupported" error toast and repo_picker_row_action_says_so_when_the_repository_is_gone saw two toasts instead of one. Co-Authored-By: Claude Opus 5.5 --- .../src/view/panels/popover/tests/branch.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/branch.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/branch.rs index 504149077..bed1caf9d 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/branch.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/tests/branch.rs @@ -111,6 +111,15 @@ impl GitRepository for TrackingRepo { Ok(Vec::new()) } + // The view reports startup load errors, so a fake with no submodules must say so. + fn list_submodules( + &self, + cancellation: &gitcomet_core::services::CancellationToken, + ) -> Result> { + cancellation.check_cancelled()?; + Ok(Vec::new()) + } + fn current_branch( &self, cancellation: &gitcomet_core::services::CancellationToken, From b7ed6bbc86fa402de6d4ab1c64434c5ea72d6769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 17:11:44 +0300 Subject: [PATCH 64/67] Retry the watcher ancestor-replacement setup across clock ticks Moving the replacement link into place refreshes its ctime on Unix, so the stamp cached before the move equals the replacement only when both land in the same filesystem clock tick. The test asserted that precondition and failed in 22 of 200 local runs. Retry the setup until a pass establishes it, then make the same assertions: 0 of 200 failed. Co-Authored-By: Claude Opus 5.5 --- .../src/store/repo_monitor/policy.rs | 160 ++++++++++-------- 1 file changed, 85 insertions(+), 75 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index f7e8cd68c..6239df956 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -747,81 +747,91 @@ mod tests { #[test] fn ancestor_replaced_by_symlink_invalidates_cached_pass_and_next_pass_resolves_it() { - let temp = tempfile::tempdir().unwrap(); - let root = normalized(&temp.path().canonicalize().unwrap()); - let ancestor = root.join("ancestor"); - let destination = root.join("destination"); - fs::create_dir(&ancestor).unwrap(); - fs::create_dir(&destination).unwrap(); - let incoming = root.join("incoming-link"); - let fixture_kind = - if gitcomet_core::test_support::symlink::directory(&destination, &incoming) { - "directory-symlink" - } else { - #[cfg(windows)] - { - // Fixed relative names avoid shell interpolation and a - // junction exercises the reparse race without privileges. - let result = std::process::Command::new("cmd") - .current_dir(&root) - .args(["/d", "/c", "mklink", "/J", "incoming-link", "destination"]) - .output() - .unwrap(); - assert!( - result.status.success(), - "junction fixture failed: {result:?}" - ); - "directory-junction" - } - #[cfg(not(windows))] - { - unreachable!("only Windows may lack the symlink fixture privilege") - } - }; - let requested = vec![ancestor.join("first"), ancestor.join("second")]; - let mut replaced = false; - let mut stamps = Stamps::default(); - // A previously stamped input can equal the eventual current identity - // even though this pass observes an intervening ordinary directory. - // Local invalidation must override that old stamp rather than let it - // conceal the mismatch. - let replacement_stamp = stamp(&incoming); - stamps.0.insert(ancestor.clone(), replacement_stamp.clone()); - let mut incomplete = false; - expand_watch_paths_with_metadata( - requested.clone(), - &mut Vec::new(), - &mut incomplete, - &mut stamps, - |path| { - let result = fs::symlink_metadata(path); - if path == requested[0] && !replaced { - fs::rename(&ancestor, root.join("retired-ancestor")).unwrap(); - fs::rename(&incoming, &ancestor).unwrap(); - replaced = true; - } - result - }, - ); - assert!(replaced); - assert!(!incomplete); - assert_eq!(stamp(&ancestor), replacement_stamp); - assert!( - stamps.changed(), - "setup must reject an expansion that reused a replaced ancestor" - ); + // Moving the link refreshes its ctime on Unix, so the cached stamp only + // equals the replacement when the move lands in the same filesystem + // clock tick as the stamp. Retry until a pass sets that case up. + for _ in 0..50 { + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + let ancestor = root.join("ancestor"); + let destination = root.join("destination"); + fs::create_dir(&ancestor).unwrap(); + fs::create_dir(&destination).unwrap(); + let incoming = root.join("incoming-link"); + let fixture_kind = + if gitcomet_core::test_support::symlink::directory(&destination, &incoming) { + "directory-symlink" + } else { + #[cfg(windows)] + { + // Fixed relative names avoid shell interpolation and a + // junction exercises the reparse race without privileges. + let result = std::process::Command::new("cmd") + .current_dir(&root) + .args(["/d", "/c", "mklink", "/J", "incoming-link", "destination"]) + .output() + .unwrap(); + assert!( + result.status.success(), + "junction fixture failed: {result:?}" + ); + "directory-junction" + } + #[cfg(not(windows))] + { + unreachable!("only Windows may lack the symlink fixture privilege") + } + }; + let requested = vec![ancestor.join("first"), ancestor.join("second")]; + let mut replaced = false; + let mut stamps = Stamps::default(); + // A previously stamped input can equal the eventual current identity + // even though this pass observes an intervening ordinary directory. + // Local invalidation must override that old stamp rather than let it + // conceal the mismatch. + let replacement_stamp = stamp(&incoming); + stamps.0.insert(ancestor.clone(), replacement_stamp.clone()); + let mut incomplete = false; + expand_watch_paths_with_metadata( + requested.clone(), + &mut Vec::new(), + &mut incomplete, + &mut stamps, + |path| { + let result = fs::symlink_metadata(path); + if path == requested[0] && !replaced { + fs::rename(&ancestor, root.join("retired-ancestor")).unwrap(); + fs::rename(&incoming, &ancestor).unwrap(); + replaced = true; + } + result + }, + ); + assert!(replaced); + assert!(!incomplete); + if stamp(&ancestor) != replacement_stamp { + continue; + } + assert!( + stamps.changed(), + "setup must reject an expansion that reused a replaced ancestor" + ); - let mut fresh_stamps = Stamps::default(); - let mut links = Vec::new(); - let paths = expand_watch_paths(requested, &mut links, &mut incomplete, &mut fresh_stamps); - assert!(links.contains(&ancestor)); - assert!(paths.contains(&destination.join("first"))); - assert!(paths.contains(&destination.join("second"))); - assert!( - !fresh_stamps.changed(), - "an independent invocation must accept the stable replacement" - ); - assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); - println!("WATCH_PREFIX_REPLACEMENT_RACE exercised=true kind={fixture_kind}"); + let mut fresh_stamps = Stamps::default(); + let mut links = Vec::new(); + let paths = + expand_watch_paths(requested, &mut links, &mut incomplete, &mut fresh_stamps); + assert!(links.contains(&ancestor)); + assert!(paths.contains(&destination.join("first"))); + assert!(paths.contains(&destination.join("second"))); + assert!( + !fresh_stamps.changed(), + "an independent invocation must accept the stable replacement" + ); + assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); + println!("WATCH_PREFIX_REPLACEMENT_RACE exercised=true kind={fixture_kind}"); + return; + } + panic!("no pass moved the link within one filesystem clock tick"); } } From 439f7e1cb1bcbcd84e1779a2ed52afb6eb25c605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 18:02:40 +0300 Subject: [PATCH 65/67] Swap the parent in the watcher ancestor-replacement test Retrying across clock ticks never helps on macOS: APFS keeps nanosecond ctimes, so moving the link always changes its stamp and all 50 attempts failed on every macOS CI job. Build the link inside a staging directory and swap the parent instead. Moving a directory leaves its children's ctimes alone, so the cached stamp equals the final link on every filesystem. The swapped parent is invalidated too, so assert on the ancestor's own entry: it must hold the ordinary directory the pass saw. 0 of 200 local runs failed, and keeping the old stamp (`entry().or_insert`) fails the new assertion. Co-Authored-By: Claude Opus 5.5 --- .../src/store/repo_monitor/policy.rs | 174 +++++++++--------- 1 file changed, 89 insertions(+), 85 deletions(-) diff --git a/crates/gitcomet-state/src/store/repo_monitor/policy.rs b/crates/gitcomet-state/src/store/repo_monitor/policy.rs index 6239df956..8123d33c0 100644 --- a/crates/gitcomet-state/src/store/repo_monitor/policy.rs +++ b/crates/gitcomet-state/src/store/repo_monitor/policy.rs @@ -747,91 +747,95 @@ mod tests { #[test] fn ancestor_replaced_by_symlink_invalidates_cached_pass_and_next_pass_resolves_it() { - // Moving the link refreshes its ctime on Unix, so the cached stamp only - // equals the replacement when the move lands in the same filesystem - // clock tick as the stamp. Retry until a pass sets that case up. - for _ in 0..50 { - let temp = tempfile::tempdir().unwrap(); - let root = normalized(&temp.path().canonicalize().unwrap()); - let ancestor = root.join("ancestor"); - let destination = root.join("destination"); - fs::create_dir(&ancestor).unwrap(); - fs::create_dir(&destination).unwrap(); - let incoming = root.join("incoming-link"); - let fixture_kind = - if gitcomet_core::test_support::symlink::directory(&destination, &incoming) { - "directory-symlink" - } else { - #[cfg(windows)] - { - // Fixed relative names avoid shell interpolation and a - // junction exercises the reparse race without privileges. - let result = std::process::Command::new("cmd") - .current_dir(&root) - .args(["/d", "/c", "mklink", "/J", "incoming-link", "destination"]) - .output() - .unwrap(); - assert!( - result.status.success(), - "junction fixture failed: {result:?}" - ); - "directory-junction" - } - #[cfg(not(windows))] - { - unreachable!("only Windows may lack the symlink fixture privilege") - } - }; - let requested = vec![ancestor.join("first"), ancestor.join("second")]; - let mut replaced = false; - let mut stamps = Stamps::default(); - // A previously stamped input can equal the eventual current identity - // even though this pass observes an intervening ordinary directory. - // Local invalidation must override that old stamp rather than let it - // conceal the mismatch. - let replacement_stamp = stamp(&incoming); - stamps.0.insert(ancestor.clone(), replacement_stamp.clone()); - let mut incomplete = false; - expand_watch_paths_with_metadata( - requested.clone(), - &mut Vec::new(), - &mut incomplete, - &mut stamps, - |path| { - let result = fs::symlink_metadata(path); - if path == requested[0] && !replaced { - fs::rename(&ancestor, root.join("retired-ancestor")).unwrap(); - fs::rename(&incoming, &ancestor).unwrap(); - replaced = true; - } - result - }, - ); - assert!(replaced); - assert!(!incomplete); - if stamp(&ancestor) != replacement_stamp { - continue; - } - assert!( - stamps.changed(), - "setup must reject an expansion that reused a replaced ancestor" - ); + let temp = tempfile::tempdir().unwrap(); + let root = normalized(&temp.path().canonicalize().unwrap()); + // Swap the parent so the link itself never moves: moving it would + // refresh its ctime and break the equal-stamp setup below. + let parent = root.join("parent"); + let staging = root.join("staging"); + let ancestor = parent.join("ancestor"); + let destination = root.join("destination"); + fs::create_dir_all(&ancestor).unwrap(); + fs::create_dir(&staging).unwrap(); + fs::create_dir(&destination).unwrap(); + let incoming = staging.join("ancestor"); + let fixture_kind = + if gitcomet_core::test_support::symlink::directory(&destination, &incoming) { + "directory-symlink" + } else { + #[cfg(windows)] + { + // Fixed relative names avoid shell interpolation and a + // junction exercises the reparse race without privileges. + let result = std::process::Command::new("cmd") + .current_dir(&root) + .args([ + "/d", + "/c", + "mklink", + "/J", + r"staging\ancestor", + "destination", + ]) + .output() + .unwrap(); + assert!( + result.status.success(), + "junction fixture failed: {result:?}" + ); + "directory-junction" + } + #[cfg(not(windows))] + { + unreachable!("only Windows may lack the symlink fixture privilege") + } + }; + let requested = vec![ancestor.join("first"), ancestor.join("second")]; + let mut replaced = false; + let mut stamps = Stamps::default(); + // A previously stamped input can equal the eventual current identity + // even though this pass observes an intervening ordinary directory. + // Local invalidation must override that old stamp rather than let it + // conceal the mismatch. + let replacement_stamp = stamp(&incoming); + stamps.0.insert(ancestor.clone(), replacement_stamp.clone()); + let mut incomplete = false; + expand_watch_paths_with_metadata( + requested.clone(), + &mut Vec::new(), + &mut incomplete, + &mut stamps, + |path| { + let result = fs::symlink_metadata(path); + if path == requested[0] && !replaced { + fs::rename(&parent, root.join("retired-parent")).unwrap(); + fs::rename(&staging, &parent).unwrap(); + replaced = true; + } + result + }, + ); + assert!(replaced); + assert!(!incomplete); + assert_eq!(stamp(&ancestor), replacement_stamp); + // The swapped parent is invalidated as well, so check the ancestor's own entry. + assert!( + stamps.is_directory(&ancestor), + "setup must reject an expansion that reused a replaced ancestor" + ); + assert!(stamps.changed()); - let mut fresh_stamps = Stamps::default(); - let mut links = Vec::new(); - let paths = - expand_watch_paths(requested, &mut links, &mut incomplete, &mut fresh_stamps); - assert!(links.contains(&ancestor)); - assert!(paths.contains(&destination.join("first"))); - assert!(paths.contains(&destination.join("second"))); - assert!( - !fresh_stamps.changed(), - "an independent invocation must accept the stable replacement" - ); - assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); - println!("WATCH_PREFIX_REPLACEMENT_RACE exercised=true kind={fixture_kind}"); - return; - } - panic!("no pass moved the link within one filesystem clock tick"); + let mut fresh_stamps = Stamps::default(); + let mut links = Vec::new(); + let paths = expand_watch_paths(requested, &mut links, &mut incomplete, &mut fresh_stamps); + assert!(links.contains(&ancestor)); + assert!(paths.contains(&destination.join("first"))); + assert!(paths.contains(&destination.join("second"))); + assert!( + !fresh_stamps.changed(), + "an independent invocation must accept the stable replacement" + ); + assert!(fresh_stamps.0.is_empty(), "stable passes must not loop"); + println!("WATCH_PREFIX_REPLACEMENT_RACE exercised=true kind={fixture_kind}"); } } From bd602be29b24599aa3c1637cc3b639d1a411192a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 18:02:41 +0300 Subject: [PATCH 66/67] Canonicalize tempdir paths in window bootstrap tests The store canonicalizes workdirs, so on Windows (8.3 RUNNER~1 temp paths) four #633 bootstrap tests waited for a workdir that never matched and timed out, or compared unequal spellings. A symlinked TMPDIR reproduces all four on Linux; with canonical paths they pass. Co-Authored-By: Claude Opus 5.5 --- .../src/view/window_bootstrap.rs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs index f14e57105..12e58937d 100644 --- a/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs +++ b/crates/gitcomet-ui-gpui/src/view/window_bootstrap.rs @@ -367,6 +367,7 @@ impl PreparedWindowBootstrap { #[cfg(test)] mod tests { use super::*; + use gitcomet_core::path_utils::canonicalize_or_original; use gitcomet_core::process::{ GitExecutableAvailability, GitExecutablePreference, GitRuntimeState, }; @@ -638,7 +639,7 @@ mod tests { ) { let (store, events, opens) = store(false, false); let directory = tempfile::tempdir().unwrap(); - let path = directory.path().to_path_buf(); + let path = canonicalize_or_original(directory.path().to_path_buf()); let mut bootstrap = prepare( &store, restored_config(vec![path.clone()]), @@ -814,21 +815,23 @@ mod tests { let (focused_store, _focused_events, focused_opens) = store(false, false); let session_directory = tempfile::tempdir().unwrap(); let focused_directory = tempfile::tempdir().unwrap(); + let session_path = canonicalize_or_original(session_directory.path().to_path_buf()); + let focused_path = canonicalize_or_original(focused_directory.path().to_path_buf()); let mut session_bootstrap = prepare( &session_store, - restored_config(vec![session_directory.path().to_path_buf()]), + restored_config(vec![session_path.clone()]), session::UiSession::default(), cx, ); let mut focused_bootstrap = prepare( &focused_store, GitCometViewConfig { - initial_path: Some(focused_directory.path().to_path_buf()), + initial_path: Some(focused_path.clone()), view_mode: GitCometViewMode::FocusedMergetool, ..Default::default() }, session::UiSession { - open_repos: vec![session_directory.path().to_path_buf()], + open_repos: vec![session_path], ..Default::default() }, cx, @@ -865,7 +868,7 @@ mod tests { assert_eq!(focused_store.snapshot().repos.len(), 1); assert_eq!( focused_store.snapshot().repos[0].spec.workdir, - focused_directory.path(), + focused_path, "a focused tool must not restore the browser's session tabs" ); shutdown(&session_store); @@ -962,8 +965,8 @@ mod tests { let (store, events, opens) = store(true, false); let active_directory = tempfile::tempdir().unwrap(); let inactive_directory = tempfile::tempdir().unwrap(); - let path = active_directory.path().to_path_buf(); - let inactive = inactive_directory.path().to_path_buf(); + let path = canonicalize_or_original(active_directory.path().to_path_buf()); + let inactive = canonicalize_or_original(inactive_directory.path().to_path_buf()); let bootstrap = prepare( &store, restored_config(vec![path.clone(), inactive.clone()]), @@ -1118,9 +1121,9 @@ mod tests { cx.update(|app| super::super::extension_host::install_registry(registry, app)); let denied = tempfile::tempdir().unwrap(); std::fs::write(denied.path().join(DENY_MARKER), "").unwrap(); - let denied_path = denied.path().to_path_buf(); + let denied_path = canonicalize_or_original(denied.path().to_path_buf()); let allowed_directory = tempfile::tempdir().unwrap(); - let allowed = allowed_directory.path().to_path_buf(); + let allowed = canonicalize_or_original(allowed_directory.path().to_path_buf()); let (store, _events, opens) = store(false, false); let mut config = restored_config(vec![denied_path.clone(), allowed.clone()]); config.initial_path = Some(denied_path); From 354fe968f997f3533b4cfa0cd8cfff321eae9f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sampo=20Kivist=C3=B6?= Date: Wed, 21 Oct 2026 19:27:30 +0300 Subject: [PATCH 67/67] removed design doc --- docs/windows-startup-performance.md | 922 ---------------------------- 1 file changed, 922 deletions(-) delete mode 100644 docs/windows-startup-performance.md diff --git a/docs/windows-startup-performance.md b/docs/windows-startup-performance.md deleted file mode 100644 index a778b34b7..000000000 --- a/docs/windows-startup-performance.md +++ /dev/null @@ -1,922 +0,0 @@ -# Windows startup investigation - -Measured on October 20, 2026 with a Ryzen AI 9 HX 370 and Radeon 890M. -DX12 remains the default; DX11 was used for diagnostic comparisons and -compatibility checks. - -The latest completed application changes use opaque Windows surfaces, coalesce -startup resizes, defer unused filter shaders and optional-tool discovery, adopt -Git discovery before attaching Home, and reuse validated watcher ancestor -metadata. DX12 remains the default. - -The final five-pair confirmation session reached median **440.5 ms first CPU -draw**, **481.0 ms first platform submission**, and **495.8 ms repository -readiness**. All five pairs improved. Three of five candidate runs reached ready -below 500 ms; the range was **466.5–512.5 ms**. This is not a reliable sub-500 ms -guarantee: an earlier session with the same binaries regressed, and its slower -renderer-construction state was not reproduced in the successful CPU capture. -Both sessions are retained below. - -The measured candidate is GitComet -`8bd06006d90b96026611928d15663371ceb0d781`, pinned to GPUI -`a4ee85008357c2e8e659bf8383ccae65ab62f94f`. All comparisons reused OS/driver -caches; reboot-cold startup was not measured. Callback, CPU-submission, and ETW -Present times do not prove physical display scanout. - -## Fresh samply capture: remaining costs - -A new 1 kHz CPU/context-switch/graphics capture on October 20 used the same -frozen release-with-debug executable from its original path. The foreground -repository-startup check passed on hardware DX12. No compilation ran during -capture. This is one diagnostic run with warm OS/driver caches, not a replacement -for the unprofiled five-pair confirmation above. - -| Milestone, from process spawn | Fresh sampled run | Previous sampled run | -| --- | ---: | ---: | -| First recorded CPU draw complete | 561.2 ms | 526.3 ms | -| First platform submission complete | 616.1 ms | 561.0 ms | -| Repository-ready observation | 689.2 ms | 602.9 ms | -| Status task complete | 516.4 ms | 507.6 ms | - -The remaining critical path is predominantly renderer initialization and the -first two frame submissions. Required repository data is already available -before the first recorded draw starts. - -| Work | Fresh evidence | Interpretation | -| --- | --- | --- | -| DX12 context initialization | Main-thread samples span 71.2–274.6 ms of ETW process age; 84.6 ms sampled CPU | Adapter enumeration accounts for 43.4 ms CPU and device creation 27.4 ms CPU within this category. The observed interval also contains 75.7 ms off-CPU time following preemption. | -| Ordinary render pipelines | Main-thread samples span 283.7–490.4 ms; 485.1 ms compiler CPU across the main thread and two workers | Largest remaining compiler cost. The main pipeline interval has only about 2.0 ms off CPU; this is predominantly executing work, not a long worker join. | -| First platform submission | 55.0 ms instrumented elapsed time | Includes surface resize/configuration, atlas upload and render-target allocation. Its native sampled interval has 7.1 ms sampled CPU and 41.1 ms off CPU. | -| Second draw and submission | 16.1 ms drawing, then 47.5 ms submitting | Its native submission interval has 6.1 ms sampled CPU and 37.8 ms off CPU. Atlas staging-buffer and render-target allocations appear near the long gaps. No compiler CPU was sampled in either submission. | - -CPU totals are summed across threads where stated. Inclusive categories overlap -and must not be added to elapsed intervals. Sample spans are observed first/last -samples, not instrumented function entry/exit. Scheduler on-CPU markers and -sampled `threadCPUDelta` are separate measurements. - -The relevant frame-allocation call chains are -`PreparedTargets::prepare -> Device::create_texture` and -`WgpuAtlas::before_frame -> StagingBuffer::new -> create_buffer`, reaching -`gpu_allocator::MemoryBlock::new -> ID3D12Device::CreateHeap`. The longest -nearby off-CPU intervals are 10.9 ms in the first frame and 7.3/28.3 ms in the -second. Samples near these gaps include -`NtGdiDdDDIWaitForSynchronizationObjectFromCpu`; however, the switch-out events -are labelled preempted. They establish resource-call-associated scheduling -gaps, not exact driver-wait durations. The trace does not label texture extents -or prove which path/filter target was allocated. - -The first-submission interval includes an observed 18.7 ms resize/configure -span. This is a changed-size configuration: native DX12 construction starts -with a 1×1 surface, the first Present uses 2100×1350, and equal dimensions return -before configuration. It is not evidence of a redundant equal-size resize. -Investigate whether construction can use the intended size directly. -Retained path textures can grow with scene bounds; confirming why another -allocation is needed in frame two requires target labels, sizes and invalidation -reasons. GPUI does not rotate these textures through retained per-frame slots. -This capture is not a Rust heap-allocation profile. - -Repository work still consumes substantial parallel resources: - -- Status takes 322.1 ms elapsed and finishes at 516.4 ms after spawn. Its result - is present in the initial recorded view state applications, before drawing. - Initial log loading finishes at 227.0 ms. -- The watcher thread consumes 142.9 ms CPU in the first 1.1 seconds, including - 52.5 ms in watcher reload and 25.5 ms in watch-path expansion. These nested - values are not additive. -- Optional LFS/annex discovery consumes 125.0 ms CPU, with sampled work beginning - around ETW process age 624 ms, after the first successful native Present at - 615.1 ms. It no longer contributes sampled CPU before that presentation. - This does not exclude contention during the subsequent readiness interval. - -The tail after first submission is observable on one journal clock: -8.7 ms until the second draw, 16.1 ms drawing, 47.5 ms submitting, then 0.7 ms -until ready. Failed readiness predicates are not logged, so the full 73.1 ms -tail cannot be called polling waste or assumed removable. Indexed-history -state is applied immediately before readiness, but full indexing is not a -startup requirement and no late initial status/log result appears. - -Compared with the preceding sampled run, ordinary pipeline compiler CPU rose -from 383.1 to 485.1 ms, while the main pipeline sample span grew by about -41.7 ms. First/second submission durations rose from 34.7/19.2 to 55.0/47.5 ms. -Status elapsed time remained essentially unchanged at 321.9/322.1 ms. -These observations locate the slower work in this run; they do not identify -the cause of run-to-run variation. The two runs used different executable -launch paths and opened different repository revisions. - -The next concrete investigations are render-target/atlas allocation and -surface configuration during the first two frames, followed by per-pipeline -release timings to reduce the remaining shader work. Status and watcher work -remain relevant to CPU contention and to establishing reliable sub-500 ms -readiness once rendering improves. No additive speedup is claimed from these -overlapping costs. - -Evidence is retained under -`target/startup-investigation/samply-remaining-20261020/`: `startup.json.gz`, -matching symbols, kernel/user ETLs, `recording.json`, `process-analysis.json`, -`renderer-findings.txt`, `renderer-phase-evidence.json`, `renderer-submission-evidence.json`, -`remaining-readiness-findings.{txt,json}`, and `status-thread-evidence.json`. -`pre-present-analysis.json` covers native process age 0–615.0769 ms; -`post-present-to1100-analysis.json` covers 615.0769–1100 ms. They contain -1290.6 and 195.7 ms aggregate app CPU respectively. All 505.1 ms sampled compiler -CPU, including approximately 20 ms of internal device initialization, precedes -the first native Present. - -The capture run is `ed9bbc81-abd2-4893-8ca1-093fb9943733`, PID 14888. -The executable remains source `8bd06006` / GPUI `a4ee8500`, SHA256 -`d62aa0087d9e9e6e3925b3d35a46ab2c0898e41dd2c0a2e4b496d62aace20584`. -The measured repository checkout is `c4b00cb7`; this is not the executable's -source revision. The binary includes all runtime optimizations but predates -the diagnostic-only log batching correction. ETW process-age, app-stage and -spawn-aligned journal clocks are kept separate; profile epoch metadata was -not used to align ETW to readiness. No display scanout or reboot-cold latency -was measured. - -## Opaque surfaces, deferred work, and Home adoption - -The implementation consists of these committed changes: - -- `afcb771a`: opaque Windows main and Settings windows. The user inspected and - accepted the appearance. The explicit background diagnostic override remains. -- `c6f19db7`: pin GPUI to checked startup-resize coalescing and demand-driven - filter pipelines. Initial configuration/fallback remains checked, runtime - resizes remain immediate, and pending dimensions survive renderer recovery. - Ordinary direct frames and retained texture copies do not create the filter - group. Actual blur, opacity groups, or a retained shader blit create it on - first use. Unsupported external-surface integrations omit their pipeline. -- `ebcf9d4e`: empty Home shares the completed background Git-probe handoff, with - the same stale-result and exactly-once attachment guards as repository - bootstrap. Its readiness predicate is unchanged. -- `71db65d2`: automatic LFS/annex discovery starts after the initial platform - presentation attempt. This is frame-loop ordering, not a GPU/display fence. - Settings rechecks, runtime invalidation, and descendant cancellation remain. -- `5a48946a`: watcher expansion reuses successful ordinary ancestor metadata - within one expansion and revalidates reused identities. Terminal paths, - missing/error results, symlinks, and Windows reparse points are not cached. - Stable cache entries are discarded; native watch coverage is not deferred. -- `8bd06006`: the new watcher replacement-race test uses a directory junction - when Windows does not grant symlink creation privileges, retaining all race - and invalidation assertions. - -GPUI commits `5fbcd42c`, `ccbedc27`, and `a4ee8500` are pushed to -`lazy_load_shaders` in the GPUI worktree. The GitComet branch has not been pushed. - -## Repository measurements: both sessions retained - -Each session alternates five pairs of the previous frozen `7ebb3e91` / -`164485d7` binary and the new `8bd06006` / `a4ee8500` binary. Both arms use -DX12/FXC, identical release-with-debug settings, fresh app profiles, and the -desktop startup scenario. All twenty runs are valid and use early repository -bootstrap. No compilation or sampling profiler ran during measurement. - -| Median, five runs per arm | Earlier baseline | Earlier candidate | Confirmation baseline | Confirmation candidate | -| --- | ---: | ---: | ---: | ---: | -| Spawn to first CPU draw | 516.0 ms | 644.9 ms | 481.0 ms | 440.5 ms | -| Spawn to first submission | 596.3 ms | 705.9 ms | 561.2 ms | 481.0 ms | -| First submission duration | 78.8 ms | 47.5 ms | 70.6 ms | 32.7 ms | -| Spawn to repository ready | 655.3 ms | 707.2 ms | 566.7 ms | 495.8 ms | -| Candidate ready range | — | 557.9–726.6 ms | — | 466.5–512.5 ms | - -The earlier median paired ready change was **+67.2 ms**. In confirmation it was -**−64.9 ms**, with every pair improving by 54.4–99.8 ms. Three confirmation -candidates were ready below 500 ms and four submitted below 500 ms. Independent -medians and median paired changes are different statistics. - -Separate three-pair stage diagnostics reproduced the earlier slowdown before -renderer construction: open-window-to-renderer increased from 334.3 to -558.9 ms, while renderer-to-view initialization fell from 70.3 to 27.5 ms and -submission duration fell from 78.3 to 39.0 ms. These intervals include native -window/driver/pipeline work and do not identify a specific cause. - -A subsequent three-pair control launched the *same candidate executable* from -its frozen path and the profiler's copied path. Both were fast: ready medians -499.7 and 492.8 ms. This does not establish a fixed-path effect. The successful -ETW capture and later five-pair confirmation also did not reproduce the slow -state. Temperature, frequency, driver-cache state, and competing workload were -not recorded sufficiently to assign the earlier regression to any of them. -The results are neither pooled nor selectively discarded. - -Repository readiness requires open/status/history readiness and a laid-out -history viewport; the harness polls at 50 ms intervals. It does not require the -entire indexed history in the startup scenario. It is a usable-content -measurement with polling delay, not a timestamp for the exact last worker. -Status is the last required data publication in all five confirmation runs. -In pairs 1/4/5 it arrives after the first CPU draw and reaches the view after -first submission. In pairs 2/3, which miss 500 ms, status is already available -at the first state application. Both content readiness and rendering matter; -shorter status alone would not resolve those two misses. - -## Home and diagnostic logging overhead - -The original three-pair Home diagnostics retained one severe candidate outlier: -baseline Home-ready markers were 588.4/574.8/558.0 ms and candidates were -926.8/524.9/486.1 ms. A later three-pair confirmation improved all pairs, -641.8/586.4/612.4 → 544.2/519.3/581.2 ms. These are app-trace times, not -spawn-to-ready times. - -Further investigation found that the callback gap after `first_interactive` -does not necessarily represent another frame. When Home is already ready, -`first_interactive` and `home_ready` run sequentially in the same callback. -Synchronous diagnostic output between them can inflate the interval. -The real serde_json emission experiment measured 59 writes per stderr payload -and 44 per trace record. Under PowerShell redirected stderr, a callback gap -reached 29.7 ms without GPUI; formatting each complete record before writing -reduced the experiment's median from 3.406 to 0.079 ms. - -The diagnostic code now formats each complete JSON line before `write_all`, -while preserving immediate record visibility, flushing, escaping, and recorded -timestamps. Startup milestones also enter the UI journal before synchronous -output, so future captures can use its existing process-spawn clock anchor. -Readiness conditions are unchanged. The application timings in this document -use the frozen `8bd06006` binary, before this diagnostic-only correction; -the emission experiment is separate evidence, not a rebuilt-app speedup. -The correction passed all three startup-trace tests and all eight startup-probe -tests in the cached `ci-test` build. - -An additional frozen-binary comparison used the existing Python harness's -direct stderr file and seeded empty profiles. Baseline Home-ready markers were -433.4/442.1/456.2 ms; candidates were **343.6/364.8/368.7 ms**. The candidate -interactive-to-Home interval was only 0.189–0.207 ms. This supports the early -Home handoff and identifies diagnostic I/O as a source of inflated callback -gaps. The Python profiles also disable background update checks, seed survey -preferences, and set window geometry, so the entire PowerShell-versus-Python -latency difference must not be attributed to stderr alone. - -The earlier repository benchmark did not enable these startup-stage markers. -The logging finding therefore does **not** explain away its regression. - -## Fresh trace and watcher verification - -The successful `samply-lean-startup-final` capture used the frozen candidate, -matching PDB, foreground DX12, 1 kHz CPU sampling, scheduling events, and graphics -events. Two preceding elevation-helper failures recorded no valid trace and -are excluded. The fixed analysis window is process age 0–1100 ms. - -| Inclusive sampled CPU | Previous capture | New capture | -| --- | ---: | ---: | -| Native shader compiler, all app threads | 447.2 ms | 402.1 ms | -| Main-thread compiler | 169.8 ms | 164.6 ms | -| Watcher thread | 127.6 ms | 100.1 ms | -| Watcher input reload | 74.9 ms | 34.5 ms | - -These nested categories overlap and are not additive elapsed savings. Ordinary -pipeline creation accounts for 383.1 ms of the new compiler CPU across three -participants; device-internal compilation and earlier context work account for -the remainder. Both pipeline workers finish before the main compiler tail. -The trace has no long new main-thread join delay and no positive samples of -the separate DX11 device constructor. - -A separate bounded-worker experiment compared 3/4/6/8 participants across four -alternating rounds, each with a fresh hardware DX12 device and the same eight -ordinary pipelines. With release-equivalent shader flags (no DEBUG/VALIDATION; -VALIDATION_INDIRECT_CALL retained), pipeline-construction medians were -195.7/195.2/199.5/198.4 ms. The first three-worker run was 258.5 ms and is retained. -The quad pipeline finished last in all sixteen runs. Increasing worker count -provided no convincing benefit, so the production limit stays at three. -The test host code was still a debug build and spent about 56 ms parsing/setup, -so these are relative diagnostic results, not release application latencies or -proof of the release trace's longest pipeline label. The temporary experiment -patch and logs are archived locally; no additional GPUI change was shipped. - -The new trace reaches first CPU draw at 526.3 ms, first submission at 561.0 ms, -and scenario readiness at 602.9 ms after spawn. Status finishes at 507.6 ms, -13.4 ms before the first draw starts and 53.4 ms before submission. Overlapping -the roughly 115 ms gitlink child with the base status walk could shorten status, -but no end-to-end saving is established while renderer/viewport work finishes -later. First successful ETW Present is at 556.2561 ms process age with status -zero. Automatic LFS/annex probes start after that first Present. - -Separate watcher diagnostics show one setup attempt and one policy revalidation -in every run, with no retry/rebuild loop. The sum of setup phase wall times -falls from median 233.6 to 192.3 ms across three pairs. Input expansion improves -while directory discovery is slower; these concurrent wall intervals include -scheduling and do not establish per-phase CPU savings. - -The application validation reports 402 passes, with three explicit ignored -fixtures. Existing unrelated symlink-only tests may self-skip without Windows -privileges; the new replacement race explicitly executed via a directory -junction. GPUI validation passed 63 Windows tests, 66 WGPU default tests plus -five integration tests, and 67 surface-enabled tests plus five integration -tests. Default strict Clippy passed; the surface-enabled run narrowly allowed -two verified pre-existing warnings in unchanged code. DX12 history navigation -and Settings, and explicit DX11 Settings, passed on the frozen application. - -## Historical handoff and DX11 deferral result - -The preceding combined startup changes reached median **540.6 ms first CPU draw**, -**646.6 ms first platform submission**, and **658.8 ms repository readiness** -over five runs. Readiness improved in every pair, but first-frame submission -became slower in four of five pairs. The 500 ms target has not been achieved. -These runs reused OS/driver caches; reboot-cold startup was not measured. - -The measured source integration is GitComet -`7ebb3e912d931944c4b26e21f1ed62a4459b4002`, including app bootstrap handoff -`c55d94a43d87a1373a49a4c0402cc8c4ce120a91` and GPUI -`164485d7d856c0e57607501480f255d027b92709`. The new GPUI revision defers the -separate DX11 support device while retaining DX12 rendering. Both changes are -committed, tested, and measured together. A fresh CPU/graphics trace confirms -early repository dispatch and finds no positive CPU samples from DX11 device -creation. Renderer initialization and first presentation gate readiness in -that capture. - -## Combined handoff and DX11 deferral measurements - -Five alternating pairs compared the frozen GPUI `18e1b2f` baseline with the -combined Git-probe handoff and GPUI `164485d7` candidate. Both used normal -transparent windows, DX12/FXC, the same `release-with-debug` profile, and the -visible-desktop startup scenario. No compilation or profiling ran during these -measurements. - -| Median launch latency, five runs per arm | Exact-pin baseline | Combined candidate | -| --- | ---: | ---: | -| First CPU draw complete | 460.3 ms | 540.6 ms | -| First platform submission complete | 553.9 ms | 646.6 ms | -| Repository ready | 897.1 ms | 658.8 ms | -| Repository-ready range | 821.8–923.3 ms | 635.1–730.2 ms | - -The candidate loaded repositories early in all five runs; the baseline loaded -early once and deferred four times. Ready improved in every pair by a median -**244.5 ms** (median paired ratio 0.729), while first submission regressed in -four pairs; the median paired change across all five was **+74.4 ms**. -The first-submit duration itself -decreased from a median 92.8 to 85.3 ms: the launch-to-submit regression occurs -before or around starting that submission, rather than a longer submission -duration. The first frame can contain a different amount of loaded repository -content. The fresh trace below shows longer renderer initialization overlapping -repository workers, but does not establish contention as the cause. - -This is a readiness/first-frame tradeoff, not a uniform startup improvement. -These measurements evaluate both changes together and a changed bootstrap-path -mix; they do not isolate the DX11 deferral's elapsed saving. The single pair -where both loaded early also improved ready by 91.6 ms, which alone cannot -establish an independent cause. Compare against the baseline rerun in this -session, not the earlier session's 831.9 ms ready median. - -Median sampled peak working set was 412.4 → 342.6 MiB, and sampled peak private -bytes were 656.1 → 523.3 MiB. Both were lower in all five pairs, but bootstrap -timing and lifetime overlap changed. These are short-run process samples -approximately every 250 ms, not exact native peaks, allocation stacks, retained -heap, steady-state memory, or GPU memory. No general memory reduction is -established. - -DX12 history-jump and Settings checks passed on the frozen candidate. Explicit -DX11 Settings also passed, confirming that native DX11 remains available. -Source behavior and regression tests establish that DX12 selection does not -initialize the separate DX11 support device; the fresh capture has no positive -CPU samples from its constructor or D3D11 creation API. Submission completion -remains distinct from physical display scanout, and none of these candidate -runs achieved 500 ms loaded startup. - -## Separate stage and empty-Home diagnostics - -Three alternating pairs per scenario used the same frozen binaries with stage -tracing and no sampling profiler. These diagnostics are separate from the -primary five-pair benchmark and must not be pooled with it. Home runs used new -empty application profiles. - -| Scenario and stage metric, median | Baseline | Combined candidate | -| --- | ---: | ---: | -| Repository: native application construction duration | 64.9 ms | 11.6 ms | -| Repository: open-window start to renderer-ready interval | 257.0 ms | 335.6 ms | -| Repository: first-paint callback | 408.3 ms | 446.7 ms | -| Repository: first-interactive callback | 427.8 ms | 466.2 ms | -| Repository: active-repository model ready | 796.2 ms | 455.3 ms | -| Empty Home: native application construction duration | 113.0 ms | 14.3 ms | -| Empty Home: open-window start to renderer-ready interval | 272.8 ms | 338.8 ms | -| Empty Home: first-paint callback | 474.2 ms | 451.9 ms | -| Empty Home: first-interactive callback | 546.5 ms | 522.6 ms | -| Empty Home: Home-ready callback | 576.5 ms | 625.1 ms | - -Callback/model milestones use the app trace's own origin, not process-spawn -time. First paint is a prepaint callback, and first interactive is a subsequent -frame callback that can still show Git checking. Model readiness excludes the -harness's viewport-geometry and polling requirements. None is display scanout. -The separate Home parent observer also adds launch work and 100 ms polling; -its timings cannot be substituted for these stage values. - -The worker handoff occurred at a median 94.2 ms, before view initialization in -all three repository runs. Repository model readiness improved, while first -paint and the interactive callback regressed. Empty Home painted/interacted -earlier, but **Home-ready regressed in all three pairs**, by 15.7, 38.2, and -73.3 ms (median paired regression 38.2 ms). Its interactive-to-Home-ready -interval grew from a median 29.0 to 105.8 ms. No repository handoff runs on -empty Home. The markers locate increased elapsed time; they do not establish -a shader, driver, scheduling, or repaint cause, or an overall Home improvement. - -## Fresh combined CPU and presentation trace - -The successful `samply-deferred-dx11-retry` capture used the frozen candidate, -hardware DX12, a visible foreground window, and 1 kHz sampling with graphics -events. The original `samply-deferred-dx11` attempt is invalid and excluded. -Both this capture and the clean pinned baseline were analyzed over the first -1100 ms of process age with matching symbols. - -The probe completed at app-trace age 116.929 ms and the worker dispatched -bootstrap at **117.058 ms**, before renderer completion at **454.763 ms** and -view initialization at **538.428 ms**. Initial bootstrap preparation still -records deferred-for-Git, but the subsequent worker dispatch makes this an -early-loading run. Later UI adoption is not the start of repository loading. - -| Inclusive sampled CPU | Clean pinned baseline | Combined capture | -| --- | ---: | ---: | -| Separate DX11 device constructor | 33.732 ms | No positive samples | -| Main DX12 context/surface initialization | 64.225 ms | 101.730 ms | -| Native shader compiler, all app threads | 368.236 ms | 447.192 ms | -| Warm first-present pipeline rebuild | 13.351 ms | 17.542 ms | - -No positive samples show `DirectXDevices::*` or `D3D11CreateDevice` in the new -capture's analysis window. Sampling cannot exclude an arbitrarily brief -execution. DX12 setup starts earlier but finishes later; adapter/device initialization and ordinary -pipeline compilation remain substantial. Repository workers and watcher setup -now overlap them. Inclusive categories overlap, and one diagnostic capture per -source cannot isolate cache, driver, scheduling, clock-frequency, or source -effects. The trace does not prove that contention caused the increased time, -or that device-creation CPU moved one-for-one into DX12. - -Status took 320.9 ms and completed at 542.6 ms after spawn, before the first -CPU draw at 607.5 ms. Log loading was also complete. First submission finished -at 695.0 ms and scenario ready followed at 697.1 ms. Repository status is -therefore no longer the final readiness dependency in this capture. These are -diagnostic timings, not additional warm-benchmark pairs. - -The first successful ETW `Present/Info` was at **691.1924 ms process age** with -`ReturnStatus=0`. This confirms a present event, not GPU completion or display -scanout. ETL process age, app-trace age, and journal/process-spawn clocks remain -distinct; their raw timestamps were not directly subtracted. - -Startup size changes and the first transparent frame still configure the -surface. The subsequent warm pipeline rebuild contains no sampled FXC work; -its 17.542 ms CPU is distinct from the 447.192 ms initial compiler-inclusive -CPU across threads. Watcher setup consumed 127.6 ms sampled -CPU, overlapping shader workers. Optional LFS/annex discovery consumed -128.6 ms sampled CPU around first presentation and beyond ready. The gitlink -subprocess still lasted roughly 101.5 ms, now hidden behind renderer startup. -These concurrent costs are not additive elapsed savings. - -## Clean upstream-pin baseline - -GitComet `1da63f84a8329efed88b90701c0a6a5ac336d8cb` replaced the vendored -renderer with GPUI `18e1b2f65899ccbc36304fbb8f9ee703ea86adc4`, which contains -the lazy-smoothing and parallel ordinary-pipeline changes. Five alternating -pairs compared this pin with the frozen previous parallel-renderer build. -Both used `release-with-debug`, DX12/FXC, optimization level 3, fat LTO, one -codegen unit, and `x86-64-v3`. The visible client area was 2100×1350 at 144 DPI, -using Radeon driver `32.0.13058.8001`. No compilation or profiling ran during -these latency measurements. - -| Median launch latency, five runs per arm | Previous parallel build | Exact upstream pin | -| --- | ---: | ---: | -| First CPU draw complete | 458.5 ms | 455.4 ms | -| First platform submission complete | 540.2 ms | 538.0 ms | -| Repository ready | 828.2 ms | 831.9 ms | - -The previous build loaded repositories early once and deferred four times; -the pinned build deferred in all five runs. Its ready range was -814.7–861.5 ms, with no result below 500 ms. The four deferred/deferred pairs -had median paired changes of +7.6 ms draw, +12.7 ms submission, and −14.8 ms -ready. This small sample establishes comparable practical latency; it does not -establish a pin-related improvement or regression. - -First CPU draw ends scene construction before platform drawing. First-submit -completion includes CPU/platform submission work; neither metric establishes -GPU completion or physical display scanout. Repository ready is the startup -scenario's usable-repository condition, observed on a 50 ms polling interval. -These three milestones must remain separate when assessing the 500 ms target. - -Earlier pinned measurements and the first pinned CPU capture were confounded -by temporary linked worktrees created for this investigation. GitComet scanned -those additional worktrees, whose generated parser files triggered substantial -normalization work against their indexes. After preserving and removing only -the experimental worktrees, we repeated the measurements and capture. The -clean results above supersede the earlier pinned 635.6 ms draw / 765.0 ms ready -medians; differences between those sessions are not a controlled optimization -gain. - -Two diagnostic comparisons then used the exact pinned executable in both arms, -with five alternating pairs each: - -| Experiment | Baseline draw / submit / ready | Candidate draw / submit / ready | Median paired submit change | -| --- | ---: | ---: | ---: | -| FXC → staged SDK DXC | 442.4 / 517.0 / 817.3 ms | 444.8 / 521.2 / 799.7 ms | −5.5 ms | -| Transparent → opaque background | 449.4 / 529.2 / 843.5 ms | 456.4 / 480.6 / 837.6 ms | −47.4 ms | - -Staged DXC did not establish a compelling submission improvement under the -parallel renderer. Submission-duration medians were 74.1 ms with FXC and -74.2 ms with DXC. All FXC runs deferred; two DXC runs loaded early, confounding -the aggregate ready comparison. The three deferred/deferred pairs favored DXC -by medians of 11.1 ms submission and 17.6 ms ready. Production packaging still -does not bundle the SDK DLLs. - -Opaque mode reduced submission duration from a median 86.5 to 21.9 ms. All five -pairs improved, with a median paired duration saving of 61.1 ms. -Launch-to-submit improved in four of five pairs by a median 47.4 ms. -The four deferred/deferred pairs saved -53.9 ms submission but regressed ready by 12.1 ms. Opaque mode can change the -desktop-visible pixels around GitComet's inset rounded frame. The user later -inspected this mode, saw no unwanted difference, and accepted it as the default -in `afcb771a`. This avoids starting opaque and then reconfiguring/rebuilding for -transparency. The table above records the earlier diagnostic, before that -product decision. - -## Completed Git-probe handoff - -The clean 1 kHz capture deferred bootstrap at app-entry age **89.2195 ms**. -The background Git probe finished at **95.9663 ms**, only **6.7468 ms** later, -but runtime adoption began at **434.0131 ms**. The completed result waited -338.0468 ms while native window/renderer initialization ran. This identifies -an overlap opportunity, not a guaranteed additive end-to-end saving. - -GitComet commit `c55d94a43d87a1373a49a4c0402cc8c4ce120a91` lets the prepared -bootstrap adopt the completed probe and dispatch loading before view attachment. -Ownership transfers exactly once; stale or late completion cannot duplicate -recovery. Entry gates, focused tools, missing-Git recovery, multiwindow behavior, -and store release when a prepared window is dropped remain covered. - -Validation against GPUI `18e1b2f` passed **168 tests**: bootstrap 12, app 67, -notifications 32, window lifecycle 53, runtime probe 2, and GPUI version contract -2. Formatting and diff checks passed. The retained-diagnostic fixture checks -acknowledgment of its specific seeded diagnostic and retains both exactly-once -toast assertions, allowing unrelated actor-handle errors from the synthetic -repository. Its release measurement is the combined comparison above; -the handoff was not benchmarked independently. - -## Deferred DX11 support-device creation - -GPUI `164485d7d856c0e57607501480f255d027b92709`, pushed to -`lazy_load_shaders`, creates shared DX11 devices only when DX11 is selected -or actual renderer fallback needs them. DX12 capability probing uses a DXGI -factory without creating a D3D11 device. Software DirectWrite glyph -initialization remains independent of renderer devices. - -The windowed redraw/vsync loop runs with an empty DX11 device slot and observes -devices initialized later. Recovery takes an unlocked device snapshot and -publishes platform replacement before notifying windows. Consuming invalidation -before taking that snapshot preserves invalidation from devices created during -the tick. Initial alpha, surface resizing, shader behavior, and public APIs -remain unchanged. - -Validation passed **58 Windows library tests**, including nine new regressions -covering unused DX11 devices on DX12 and forced-DX12 failure, retryable/shared -fallback creation, native device reuse, windowed/headless startup, redraw -without DX11, late-device recovery, recovery failure, and late invalidation. -The existing native DX11 pixel, software/headless glyph, and native color-glyph -recovery tests also passed. The final extended windowed/headless smoke test -passed separately. - -Strict Clippy passed before the final log wording and smoke-test extension. -Both DX11-only configurations compiled: the default configuration with -test-support and tests, and the minimal production library with -`--no-default-features`. Formatting and diff checks passed on the final files. -Recovery control flow is covered by deterministic tests; physical GPU -removal/reset was not forced. The combined release comparison above does not -isolate this change's saving; the baseline's 33.7 ms device-creation CPU does -not establish an equal wall-time improvement. - -## Historical baseline and bottleneck - -GitComet baseline: `848514d8b4b9e3d11e37565ebc6399ded0a707fd`. -GPUI baseline: `0665fb07d59cf7af185e58f44b9f890590abe204`. -Lazy-smoothing GPUI change: `9362a02d98324c6d91a1bec58b90ab22ab667252`. - -The original samply capture attributed 2,264 ms of sampled main-thread CPU -to `D3DCompiler_47.dll`: 85.6% of main-thread CPU sampled during the first -three seconds. Four optional corner-smoothing pipelines accounted for 1,878 ms: - -| Pipeline | Inclusive sampled compiler CPU | -| --- | ---: | -| Smoothed quads | 789 ms | -| Smoothed shadows | 687 ms | -| Smoothed polychrome sprites | 213 ms | -| Smoothed blur composite | 188 ms | - -GitComet does not request positive corner smoothing. Its controls use ordinary -circular rounded corners; direct image painting passes zero smoothing. This -optional GPUI feature produces continuous corners similar to iOS/Figma, and is -independent of normal antialiasing, text smoothing, and image filtering. - -The GPUI change creates these four pipelines on first use. It retains their -original shaders and compiles before encoding the first frame that needs them. -It supports reuse, resource rebuilds, failure rollback, and retries. A future -feature requesting smoothing can still incur a one-time compilation delay. - -The following earlier experiments used the renderer then vendored in -`vendor/gpui-wgpu`. Their within-session comparisons remain useful, but their -absolute timings and bootstrap-path mixtures must not be pooled with the clean -upstream-pin baseline above. - -## Historical smoothing measurements - -Five alternating baseline/candidate pairs used the same `release-with-debug` -profile, DX12/FXC, real GitComet repository, isolated application profiles, and -visible desktop windows. No build ran during these measurements. - -| Milestone | Eager smoothing | Lazy smoothing | -| --- | ---: | ---: | -| Launch to first draw, median | 2,361.6 ms | 656.9 ms | -| Launch to repository ready, median | 2,802.4 ms | 1,100.6 ms | -| Repository-ready range | 2,782.9–2,831.1 ms | 1,067.5–1,117.3 ms | -| Sampled peak working set, median | 855.9 MiB | 784.2 MiB | - -The first observed candidate launch was slower: first draw 836.7 ms and -repository ready 1,280.1 ms. These are not reboot-cold measurements. - -With the actual saved application profile, the parent launcher observed the -first interactive marker by 949.5 ms. App stage tracing recorded first paint -at 665.0 ms, first interactive at 837.3 ms, and repository ready at 1,104.1 ms. -App tracing starts after process creation; the parent measurement also includes -launch and polling overhead. Subsecond interaction was observed on this machine, -but strict subsecond fully loaded repository startup was not achieved. - -The candidate's new 1 kHz samply capture attributed about 360 ms of compiler CPU -to ordinary pipelines and none to the four smoothed initialization callsites. -The original capture used 8 kHz, so the sampled reduction is approximate. -Unprofiled paired measurements establish the latency improvement. - -Additional diagnostic comparisons: - -| Experiment | First draw, median | Repository ready, median | -| --- | ---: | ---: | -| Existing release, eager DX12/FXC, 3 pairs | 2,529.6 ms | 2,875.9 ms | -| Existing release, native DX11, same 3 pairs | 178.7 ms | 530.5 ms | -| Existing release, eager DX12/FXC, separate 3 pairs | 2,616.4 ms | 2,999.7 ms | -| Same release executable, SDK DXC | 1,453.6 ms | 1,840.2 ms | -| Lazy smoothing, FXC, separate 5 pairs | 661.0 ms | 1,110.5 ms | -| Lazy smoothing, SDK DXC, same 5 pairs | 626.8 ms | 1,052.5 ms | - -DXC was Windows SDK 1.8.2502.11, staged next to isolated executables; loaded -modules confirmed the DLLs used. Once smoothing was deferred, DXC added only a -small improvement in this earlier experiment. The current parallel-renderer -comparison is reported above. - -## Historical repository bootstrap overlap - -The next change creates the `AppStore` and prepares repository bootstrap before -`cx.open_window`. Repository workers can then load status and history while -the native window initializes its DX12 renderer. The previous saved-profile -trace had Git available by 96.3 ms, but created the store at 658.5 ms. - -Preparation applies settings and entry gates before dispatching repository -requests. The view consumes one prepared bootstrap decision and attaches to -the latest store snapshot, including retained notifications and diagnostics. -Git-runtime recovery, focused tools, empty workspaces, saved workspace layout, -and failed-open handling retain their existing behavior. - -Six new bootstrap tests pass. They cover loading before a window exists, -single dispatch and stable repository identity, entry gates, focused/empty -workspaces, failures before attachment, Git recovery, and feedback consumption. -Another 152 existing app, notification, and window-lifecycle tests pass. - -Five alternating pairs compared the frozen lazy-only build with this bootstrap -candidate, using the same release profile and DX12/FXC: - -| Milestone | Lazy only | Early bootstrap | -| --- | ---: | ---: | -| Launch to first draw, median | 708.2 ms | 868.6 ms | -| Launch to repository ready, median | 1,183.4 ms | 1,015.1 ms | -| Repository-ready range | 1,165.8–1,242.6 ms | 983.2–1,150.6 ms | - -This is a tradeoff, not a uniform improvement. Three candidate runs overlapped -loading, while two waited for the Git runtime probe and restored only after -renderer initialization. First-frame CPU drawing was not slower; the delay -occurred before window attachment. Contention between renderer initialization -and repository workers is a possible explanation, not a proven attribution. -The first observed bootstrap launch reached harness readiness at 1,350.2 ms. -Neither these warm-cache pairs nor that first observation establish reboot-cold -subsecond startup. Dependency source identities and build paths also changed -when integrating the identical renderer source, potentially affecting layout. - -With the actual saved profile, the parent observed interaction at 859.8 ms; -app tracing recorded repository readiness at 1,229.6 ms. That run took the -deferred path: bootstrap preparation was at 102.7 ms and the Git probe finished -at 107.5 ms, but its UI handoff waited until window initialization completed. - -A fresh 1 kHz samply capture of an early-bootstrap run attributed 550.5 ms of -sampled compiler CPU to ordinary shaders (quads 188.1 ms, shadows 161.1 ms). -There were still no eager smoothing pipeline calls. This is diagnostic evidence -for the next renderer experiment, not a controlled attribution of contention. - -## Historical parallel ordinary pipeline comparison - -The renderer now has a bounded parallel initialization path for Windows DX12. -It uses at most three compilation lanes, including the caller, and respects -available parallelism. All ordinary pipelines are created before rendering. -Other backends and warm runtime rebuilds keep serial initialization. - -Each worker captures its own wgpu errors. All workers join before an error or -panic propagates, partial pipeline sets are discarded on failure, and pipelines -sharing a vertex stage remain in the same batch to avoid duplicate cache misses. -Two new DX12 tests pass: serial/parallel pixel equality over repeated frames, -and worker validation failure followed by a clean retry without error leakage. -The four existing smoothing regressions also pass on this renderer. - -Eight alternating pairs across two sessions (five plus three) compared the -frozen bootstrap build with the parallel candidate using DX12/FXC and the same -`release-with-debug` profile. No compilation ran during measurements. - -| Milestone | Bootstrap / serial pipelines | Bootstrap / parallel pipelines | -| --- | ---: | ---: | -| Launch to first draw, median | 801.6 ms | 671.7 ms | -| Launch to repository ready, median | 1,100.2 ms | 847.8 ms | -| Repository-ready range | 1,009.1–1,267.5 ms | 807.0–1,014.7 ms | - -The candidate reached repository readiness faster in every pair. Median paired -saving was 251.1 ms, with a median paired ratio of 0.776 (22.4% lower latency). -Two pairs had small first-draw regressions of 14.5 and 23.8 ms despite faster -readiness. Bootstrap timing varied: the serial baseline overlapped loading in -four of eight runs, and the candidate in seven of eight. These are practical -end-to-end results with mixed bootstrap paths, not a shader-only microbenchmark. -The candidate's one deferred run was also its one result above one second. -The four pairs in which both variants overlapped loading still improved -readiness in every pair, with a median paired saving of 187.4 ms. - -Memory results depend on that path mix. Across all eight runs, median sampled -peak working set was 675.1 MiB for serial creation and 669.1 MiB for parallel -creation; median sampled peak private bytes were 838.1 and 864.5 MiB. -In the four comparable early-loading pairs, parallel creation increased sampled -peak working set by a median 150.3 MiB and private bytes by 209.8 MiB. -These are maxima of current-process samples approximately every 260 ms over the -short run. They are not exact native peak counters, allocation stacks, retained -heap, steady-state memory, or GPU memory. No general memory reduction is claimed. - -The first observed parallel-candidate launch reached first draw at 837.6 ms and -repository readiness at 1,019.6 ms. With the actual saved profile, app tracing -recorded first paint at 623.3 ms, repository ready at 709.8 ms, and interaction at -755.3 ms; the parent observed interaction at 879.2 ms. An empty profile reached -ready Home at 539.2 ms in app tracing, observed at 630.6 ms by the parent. -History navigation and opening Settings passed live checks on that binary. - -Parallel compilation moves work onto a bounded set of threads; these results -do not establish a reduction in total shader compiler CPU. The exact pre-format -compiled/test sources and their relationship to the rustfmt-formatted working -source are retained in the local provenance artifacts. - -## Remaining work - -The resize, opaque-background, optional-tool, watcher, and early-Home changes -described above are implemented and tested. The remaining priorities are: - -- Establish more margin below 500 ms and understand the earlier slower renderer - state. The later median alone does not establish consistent startup. -- Attribute individual pipelines in a release application capture before - changing shader code. The bounded 3/4/6/8-worker experiment is complete and - does not justify changing the current limit. -- Revisit status overlap when it delays first displayed content. It arrives - after the first CPU draw in three confirmation runs, although it is already - ready before rendering in both runs that miss 500 ms. -- Keep remaining shader deferral tied to actual scene requirements. Home uses - a colored image; repository tabs and frame corners use paths. Shadows can be - required by light-theme controls and focus rings. -- Treat diagnostic logging overhead separately from application work, and keep - Home callback times separate from spawn-to-submit and repository-ready times. - -The final confirmation median is below 500 ms, but its slowest candidate is -12.5 ms above it. The earlier session still required more than 200 ms of -improvement. These are observed gaps, not predicted additive savings. -Public `wgpu::PipelineCache` in version -29.0.4 supports Vulkan only, so it does not provide persistent DX12 shader -caching. More shader deferral requires evidence that the initial frame does -not need those pipelines. - -## Validation and reproduction - -Four smoothing regression tests passed on the earlier renderer candidate: -first-use pixel equivalence, backdrop/isolated-filter planning, creation rollback, and DX12 -retry behavior. Three GPU tests explicitly selected the Radeon 890M/DX12; -the fourth tests planning. History navigation and opening Settings also passed -live UI smoke checks. - -Use `scripts/profiling/live-ui.py measure` for alternating startup measurements. -The startup scenario disables updates, network tag fetching, and signature -checks through its isolated profile. The first-submit metric is derived from -the journal-start-to-spawn offset plus the first journal `submit.at_ms`. -Do not subtract timestamps across the distinct app-entry, UI probe, external -spawn, and ETL process-age clock origins. Memory is sampled every 250 ms and can -miss short peaks; it is not an allocation stack trace or exact retained Rust heap. - -The PowerShell helpers provide: - -- `scripts/record-gitcomet-startup.ps1`: samply capture, frozen executable/PDB, - scheduling/graphics events, and retained ETLs. Default 8 kHz is intentionally - expensive; use `-Rate 1000` for a lighter diagnostic capture. -- `scripts/capture-gitcomet-startup.ps1`: startup stage tracing, with optional - empty profile and graceful automatic exit. -- `scripts/view-gitcomet-startup.ps1`: matching symbols and Windows symbol server. -- `scripts/build-gitcomet-allocations.ps1` and - `scripts/capture-gitcomet-allocations.ps1`: separate instrumented build and - milestone allocation totals. These exclude some native compiler/driver - allocations and do not provide allocation call stacks. - -For sampled CPU analysis, use `threadCPUDelta`. Synthetic off-CPU sample -weights must not be counted as CPU. Keep profiling runs separate from ordinary -latency measurements and avoid compilation during either. - -Large local artifacts remain under ignored `target/startup-investigation/`: -`repo-lazy-pairs/session.json`, `experiment-summary.json`, the frozen -`baseline-frozen/` and `lazy-runtime/` binaries/PDBs, and -`samply-lazy-candidate/` with the matching symbols, ETL, and analysis. -The bootstrap experiment adds `bootstrap-runtime/`, `repo-bootstrap-pairs/`, -`bootstrap-stage-real-profile/`, and `samply-bootstrap-candidate/`. -The historical parallel experiment adds `parallel-runtime/`, `repo-parallel-pairs/`, -`repo-parallel-confirmation/`, `parallel-comparison-summary.json`, the -`parallel-stage-*` traces, and `parallel-compiled-source/`. -Their availability is local; this document records the durable measurements. - -The latest implementation is frozen in `lean-startup-runtime/` with source -revision `8bd06006`, clean patch, source hashes, and successful build log -`lean-startup-release-build.log`. Executable SHA256: -`d62aa0087d9e9e6e3925b3d35a46ab2c0898e41dd2c0a2e4b496d62aace20584`. -PDB SHA256: -`9c0f0a8165364dfb03b6cbc20536e704b87bb0164bc8cb447bc1025bfc0cb7e9`. -Its evidence includes: - -- `lean-startup-comparison.json` and `repo-lean-startup-pairs/session.json`, - session `b72848e8-ca7a-453f-98ff-fd6965ab6779`: earlier regression. -- `lean-confirmation-comparison.json` and `repo-lean-confirmation/session.json`, - session `a4beaa49-9fb9-4ca5-8f72-e65bdf7f92f3`: final five-pair confirmation. -- `lean-confirmation-readiness-findings.txt` and - `lean-confirmation-readiness-evidence.json`: per-run data/publication/frame - dependencies and threshold counts. -- `lean-stage-home-comparison.json`, `lean-stage-home-findings.txt`, - `lean-confirm-home-*`, and `lean-home-file-*`: separate stage/Home diagnostics. -- `lean-path-control-comparison.json` and `lean-dx11-control-comparison.json`: - separate path and renderer controls. -- `lean-watcher-comparison.json` and `lean-watcher-findings.txt`: setup phases, - attempts, and reload coverage. -- `samply-lean-startup-final/`: valid capture, matching symbols, ETLs, - foreground evidence, CPU analysis, renderer findings, and - `status-readiness-decision.txt`. The adjacent stage trace is - `samply-lean-startup-final-stage.jsonl`. -- `gpui-final-startup-validation.txt` and `lean-startup-app-validation.txt`: - test commands, complete logs, source hashes, fixture corrections and limits. -- `lean-startup-history-jump/`, `lean-startup-settings/`, and - `lean-startup-explicit-dx11/`: successful frozen-binary live checks. -- `probe-emission/run-20261020-144202-187/summary.json`: isolated serde_json - write-count and redirection experiment. -- `probe-emission/validation-20261020-144838/`: diagnostic correction's successful - build, eleven focused test results, and source/executable hashes. -- `gpui-worker-count-release-flags.log`: bounded-worker control using shipping - native shader compiler flags. The earlier `gpui-worker-count-diagnostic.log` - uses debug flags and is retained separately. - -The clean pin and runtime comparisons are `pinned-final-comparison.json`, -`pinned-dxc-comparison.json`, and `pinned-opaque-comparison.json`, backed by -`repo-pinned-final-pairs/session.json`, `repo-pinned-dxc-pairs/session.json`, -and `repo-pinned-opaque-pairs/session.json`. The clean diagnostic is -`samply-pinned-final/startup.json.gz`, its retained kernel/user ETLs, -`recording.json`, `process-analysis.json`, `process-analysis.symbols.json`, -`repository-startup-findings.txt`, and `live-ui/frames.jsonl`, with adjacent -`samply-pinned-final-stage.jsonl`. Source interpretation is preserved in -`pinned-renderer-opportunities.md`. - -The previous executable SHA256 is -`fd0c7e7592aceb7d405d58d2459f539dfb7fa6df8a7df7ab5daceb5f45979b90`; -`parallel-runtime/provenance.json` maps its preserved sources to integrated -GitComet `b37e714ecf8218a06cd299cb33c5e8a32fb696a5`. The exact-pin executable -SHA256 is `c142745a43484a986df94b092b54bd3ab2ecb045758bd25842a9e4afd40b819d` -and PDB SHA256 is -`b03359b130dbfe55bc967531cbaad6a6378465ad881e8fe2848f4dc34f908d6c`. -`pinned-gpui-runtime/provenance.json` records its `1da63f84` / `18e1b2f` -source pair. DXC experiment DLL versions and hashes are in -`pinned-dxc-runtime/provenance.json`. Session checkout metadata alone does not -identify a separately frozen executable's sources. - -The handoff's 168-test results, source hashes, executable hashes, and build/test -logs are under `app-bootstrap-handoff-preserved/`, particularly -`root-validation-summary.json`, `root-validated-source-sha256.json`, and -`root-validated-test-binaries-sha256.json`. Its validation summary records the -pre-commit base `1da63f84`; the source manifest identifies the changes later -committed as `c55d94a4`. - -`gpui-lazy-dx11-validation.txt` records the renderer validation performed before -commit `164485d7`, with final smoke-test and minimal-configuration raw output in -`gpui-lazy-dx11-headless.log` and `gpui-lazy-dx11-minimal-check.log`. - -The combined measurement is `deferred-dx11-comparison.json`, backed by -`repo-deferred-dx11-pairs/session.json` (session -`29550899-a4d9-4ca3-a73e-380f031cf1d6`). Frozen source/build provenance and -artifacts are in `deferred-dx11-runtime/`. Candidate executable SHA256 is -`9f7ebd94a7d9c38c69fe653f5096d95985e61b47757dd6613c394247cd234706`; -PDB SHA256 is -`0312e433a5f8c4e073581761fc8aa886052a3ba6532e961793c06ddb531ae127`. -The provenance identifies GitComet `7ebb3e91`, GPUI `164485d7`, and the included -handoff source hashes. Valid live-check summaries are -`deferred-dx11-history-jump/summary.json`, -`deferred-dx11-settings/summary.json`, and -`deferred-dx11-explicit-dx11/summary.json`, all with that executable hash. -The successful fresh diagnostic is `samply-deferred-dx11-retry/`, including -`startup.json.gz`, retained ETLs, `recording.json`, `foreground-summary.json`, -`startup-findings.txt`, `graphics-marker-evidence.json`, and -`main-initialization-evidence.json`. Its derived comparisons are -`deferred-dx11-retry-capture-comparison.json` and -`deferred-dx11-renderer-phase-comparison.json`. The failed -`samply-deferred-dx11/` capture is excluded. Separate three-pair stage/Home -results and clock definitions are in `deferred-stage-home-comparison.json` -and `deferred-stage-home-comparison.txt`. - -## Historical upstream precompilation - -Official GPUI-CE revision `9a82fba4e5f1f9194a5489b7a417d164083dd67a` -precompiles **DX11** shaders in -[gpui_render/build.rs](https://github.com/gpui-ce/gpui-ce/blob/9a82fba4e5f1f9194a5489b7a417d164083dd67a/crates/gpui_render/build.rs). -Its [WGPU pipeline implementation](https://github.com/gpui-ce/gpui-ce/blob/9a82fba4e5f1f9194a5489b7a417d164083dd67a/crates/gpui_wgpu/src/wgpu_renderer/pipelines.rs) -still creates pipelines from WGSL at runtime, including all four smoothing -variants eagerly. DX11 precompilation does not replace the lazy DX12 change.