Skip to main content

gpui_query/hook/
query_hooks.rs

1//! Query hook functions — `use_query`, `use_query_unsignalled`, `use_query_manual`,
2//! `fetch_query`, and `fetch_query_with_signal`.
3//!
4//! # Task lifecycle: deliberate detach-by-design (Audit Finding #6)
5//!
6//! Audit fix #6 (storing the spawned fetch task so a replacement fetch or
7//! entity drop aborts it) is applied to **mutations and infinite queries**.
8//! The **plain-query** spawn sites in this module — inside `use_query`,
9//! `use_query_unsignalled`, `fetch_query`, and `fetch_query_with_signal` —
10//! intentionally call `task.detach()` instead. This is not an unfinished fix:
11//!
12//! - Query fetches already prevent stale writes through the cooperative
13//!   `QuerySignal` plus the `is_current_request` / `accept_current_request`
14//!   two-phase guard in the retry loop. A superseded fetcher still observes
15//!   its cancelled signal, which tests enforce.
16//! - Hard-aborting a query task on replacement would break that cooperative
17//!   contract. The detached task self-terminates once the owning entity is
18//!   dropped (the `weak.upgrade()` checks return `None`), so it cannot leak
19//!   writes after unmount.
20//!
21//! Each site repeats a short form of this rationale next to its `detach()`.
22
23use gpui::{BorrowAppContext as _, Context, Entity, Subscription};
24
25use crate::client::{QueryClient, QueryObserver};
26use crate::core::{Fetched, QueryFetchMode, QueryKey, QueryResource, QuerySignal, QueryStatus};
27
28use super::current_time_ms;
29use super::fetch_retry::{begin_request_on_entity, fetch_signal_with_retry, fetch_with_retry};
30
31/// Subscribe to a query resource and automatically re-render when it changes.
32///
33/// This is the **primary** `use_query` hook following the v2 "Signal-always"
34/// design: the fetcher receives a [`QuerySignal`] for cooperative cancellation.
35///
36/// Call this in your component's constructor (not in `render`). It:
37///
38/// 1. Gets or creates a [`QueryResource`] entity from the global [`QueryClient`]
39/// 2. Sets up a [`QueryObserver`] so your component re-renders on state changes
40/// 3. Propagates the user's retry policy to the resource entity (audit fix #16)
41/// 4. Calls `begin_request` to set status to Loading and obtain a `RequestId`
42/// 5. Spawns an async fetch with retry logic, using the stored `RequestId`
43///
44/// # Returns
45///
46/// A tuple of `(Entity<QueryResource<T, E>>, Subscription)`:
47/// - Store the entity to read state during render
48/// - Store the subscription to keep the observation alive
49///
50/// # Unmount Behavior (Audit Finding #6)
51///
52/// If the component unmounts while a fetch is in-flight, the fetch result is
53/// silently discarded. No callback fires. This is intentional for cache-layer
54/// correctness. Callers who need completion guarantees should use
55/// `fetch_query_with_signal` directly with their own completion handling.
56pub fn use_query<T, E, C, F, Fut>(
57    options: impl Into<crate::hook::QueryOptions>,
58    fetcher: F,
59    cx: &mut Context<C>,
60) -> (Entity<QueryResource<T, E>>, Subscription)
61where
62    T: Clone + Send + Sync + 'static,
63    E: Clone + Send + Sync + std::fmt::Debug + 'static,
64    C: 'static,
65    F: Fn(QuerySignal) -> Fut + Send + 'static,
66    Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
67{
68    // Audit H7: destructure opts so retry_policy can be moved (not cloned)
69    // into the entity store; it has no later use. key is still cloned once for
70    // use_query_manual (audit #61 moves the original into begin_request below).
71    let crate::hook::QueryOptions {
72        key,
73        cache_policy,
74        request_policy,
75        retry_policy,
76        force_fetch,
77        ..
78    } = options.into();
79    let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx);
80
81    // Audit fix #16: Propagate the user's retry policy to the resource entity.
82    // Without this, the resource defaults to RetryPolicy::no_retries() and
83    // the user's QueryOptions::retry_policy() builder is a dead API.
84    entity.update(cx, |r, _| r.set_retry_policy(retry_policy));
85
86    // Start fetch if resource is idle
87    let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle);
88    if should_fetch {
89        let fetch_mode = if force_fetch {
90            QueryFetchMode::Force
91        } else {
92            QueryFetchMode::Normal
93        };
94        // Audit fix #3: begin_request_on_entity returns Option<RequestId>.
95        // If CacheHit or IgnoredWhileLoading, skip spawning the fetch task.
96        // Audit fix #2: Thread the key through to avoid re-reading from entity.
97        // Audit fix #61: opts.key was cloned once above for use_query_manual
98        // and is no longer needed after this call, so move it instead of
99        // cloning again (removes a redundant second clone of the key).
100        if let (Some(request_id), signal) =
101            begin_request_on_entity(&entity, cx, fetch_mode, Some(key))
102        {
103            // Audit H3: `signal` comes straight from begin_request_on_entity
104            // (read in the same entity.update as the begin) instead of via a
105            // separate entity.read_with pass. unwrap_or_else covers the
106            // pathological case where begin created no signal.
107            let signal = signal.unwrap_or_else(QuerySignal::new);
108            let weak = entity.downgrade();
109            let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone());
110            // Audit fix #6: store the spawned task on the resource so a
111            // replacement fetch (or entity drop on unmount) aborts the prior
112            // in-flight task instead of leaving it detached and running.
113            let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
114                fetch_signal_with_retry(fetcher, signal, request_id, &retry_policy, &weak, cx)
115                    .await;
116            });
117            // Audit #6 NOT applied to queries: query fetches already prevent
118            // stale writes via the signal + `is_current_request` cooperative
119            // check in run_query_retry_loop, and tests enforce that a
120            // superseded fetcher still observes its cancelled signal. Hard-
121            // aborting on replacement would break that contract, so the task
122            // is detached (it self-terminates when the entity is dropped).
123            task.detach();
124        }
125    }
126
127    (entity, subscription)
128}
129
130/// Like [`use_query`], but the fetcher returns [`Fetched<T>`] so a server-derived
131/// [`CachePolicy`] can override the caller's per-query policy on success
132/// ("server wins").
133///
134/// Mirrors [`use_query`] exactly — same options-first signature, same
135/// [`QuerySignal`]-accepting fetcher, same
136/// `(Entity<QueryResource<T, E>>, Subscription)` return — except the fetcher
137/// returns `Result<Fetched<T>, E>`. [`Fetched::new`] keeps the caller's policy;
138/// [`Fetched::with_policy`] overrides it with the server's (e.g. parsed from
139/// `Cache-Control`) once the fetch resolves. The existing `Result<T, E>`
140/// [`use_query`] is unchanged.
141///
142/// # Server wins
143///
144/// The resource's `CachePolicy` is established at `begin_request` time from
145/// [`QueryOptions`] (the caller's policy). When a fetcher returns
146/// [`Fetched::with_policy`], that server policy replaces the resource's stored
147/// policy immediately after `complete_success`, so subsequent freshness / SWR
148/// checks use the server's TTL. `None` (via [`Fetched::new`]) leaves the caller's
149/// policy in place.
150pub fn use_query_with_policy<T, E, C, F, Fut>(
151    options: impl Into<crate::hook::QueryOptions>,
152    fetcher: F,
153    cx: &mut Context<C>,
154) -> (Entity<QueryResource<T, E>>, Subscription)
155where
156    T: Clone + Send + Sync + 'static,
157    E: Clone + Send + Sync + std::fmt::Debug + 'static,
158    C: 'static,
159    F: Fn(QuerySignal) -> Fut + Send + 'static,
160    Fut: std::future::Future<Output = Result<Fetched<T>, E>> + Send + 'static,
161{
162    let crate::hook::QueryOptions {
163        key,
164        cache_policy,
165        request_policy,
166        retry_policy,
167        force_fetch,
168        ..
169    } = options.into();
170    let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx);
171
172    // Audit fix #16 (mirrors `use_query`): propagate the user's retry policy.
173    entity.update(cx, |r, _| r.set_retry_policy(retry_policy));
174
175    // Start fetch if resource is idle
176    let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle);
177    if should_fetch {
178        let fetch_mode = if force_fetch {
179            QueryFetchMode::Force
180        } else {
181            QueryFetchMode::Normal
182        };
183        if let (Some(request_id), signal) =
184            begin_request_on_entity(&entity, cx, fetch_mode, Some(key))
185        {
186            let signal = signal.unwrap_or_else(QuerySignal::new);
187            let weak = entity.downgrade();
188            let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone());
189            // Same deliberate detach as `use_query` (audit #6 NOT applied to
190            // queries): cooperative signal cancellation + `accept_current_request`
191            // prevent stale writes, and the task self-terminates on entity drop.
192            let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
193                fetch_signal_with_retry(fetcher, signal, request_id, &retry_policy, &weak, cx)
194                    .await;
195            });
196            task.detach();
197        }
198    }
199
200    (entity, subscription)
201}
202
203/// Like [`use_query`] but the fetcher receives no signal argument.
204///
205/// This exists for backward compatibility. Prefer [`use_query`] (the
206/// signal-accepting version) which aligns with the v2 "Signal-always" design.
207pub fn use_query_unsignalled<T, E, C, F, Fut>(
208    key: QueryKey,
209    cache_policy: crate::core::CachePolicy,
210    request_policy: crate::core::RequestPolicy,
211    fetcher: F,
212    cx: &mut Context<C>,
213) -> (Entity<QueryResource<T, E>>, Subscription)
214where
215    T: Clone + Send + Sync + 'static,
216    E: Clone + Send + Sync + std::fmt::Debug + 'static,
217    C: 'static,
218    F: Fn() -> Fut + Send + 'static,
219    Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
220{
221    let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx);
222
223    // Start fetch if resource is idle
224    let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle);
225    if should_fetch {
226        // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId.
227        // Audit fix #2: Thread the key through to avoid re-reading from entity.
228        if let (Some(request_id), _signal) =
229            begin_request_on_entity(&entity, cx, QueryFetchMode::Normal, Some(key))
230        {
231            let weak = entity.downgrade();
232            let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone());
233            // Audit fix #6: store the task so replacement/unmount aborts it.
234            let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
235                fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await;
236            });
237            // Audit #6 NOT applied to queries: query fetches already prevent
238            // stale writes via the signal + `is_current_request` cooperative
239            // check in run_query_retry_loop, and tests enforce that a
240            // superseded fetcher still observes its cancelled signal. Hard-
241            // aborting on replacement would break that contract, so the task
242            // is detached (it self-terminates when the entity is dropped).
243            task.detach();
244        }
245    }
246
247    (entity, subscription)
248}
249
250/// Convenience wrapper around [`use_query_manual`] that builds the entity and
251/// observation from a [`QueryOptions`] value instead of raw policy parameters.
252///
253/// Audit fix #79: `use_query_manual` historically required a raw
254/// `(key, cache_policy, request_policy)` triple. This overload accepts anything
255/// convertible into [`QueryOptions`] (a string, a [`QueryKey`], a full
256/// `QueryOptions` builder, or the legacy `(QueryKey, CachePolicy, RequestPolicy)`
257/// tuple) so callers do not have to spell out the policies by hand. Only the
258/// `key`, `cache_policy`, and `request_policy` fields are consumed; the
259/// remaining options (retry policy, `force_fetch`, etc.) are ignored at this
260/// layer — use [`use_query`] to honor them. The existing
261/// [`use_query_manual`] signature is unchanged.
262pub fn use_query_manual_opts<T, E, C>(
263    options: impl Into<crate::hook::QueryOptions>,
264    cx: &mut Context<C>,
265) -> (Entity<QueryResource<T, E>>, Subscription)
266where
267    T: Clone + Send + Sync + 'static,
268    E: Clone + Send + Sync + 'static,
269    C: 'static,
270{
271    let opts = options.into();
272    use_query_manual(opts.key, opts.cache_policy, opts.request_policy, cx)
273}
274
275/// Convenience wrapper around [`use_query_unsignalled`] that accepts an
276/// `impl Into<QueryOptions>` instead of the raw `(key, cache_policy,
277/// request_policy)` triple.
278///
279/// Audit fix #79: mirrors [`use_query_manual_opts`]. Only the `key`,
280/// `cache_policy`, and `request_policy` fields of [`QueryOptions`] are read;
281/// the remaining options are not consumed at this layer. The existing
282/// [`use_query_unsignalled`] signature is unchanged.
283pub fn use_query_unsignalled_opts<T, E, C, F, Fut>(
284    options: impl Into<crate::hook::QueryOptions>,
285    fetcher: F,
286    cx: &mut Context<C>,
287) -> (Entity<QueryResource<T, E>>, Subscription)
288where
289    T: Clone + Send + Sync + 'static,
290    E: Clone + Send + Sync + std::fmt::Debug + 'static,
291    C: 'static,
292    F: Fn() -> Fut + Send + 'static,
293    Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
294{
295    let opts = options.into();
296    use_query_unsignalled(
297        opts.key,
298        opts.cache_policy,
299        opts.request_policy,
300        fetcher,
301        cx,
302    )
303}
304
305/// Lower-level hook that sets up the entity and observation without starting a fetch.
306///
307/// Use this when you need full control over when and how fetching happens.
308///
309/// Uses v2's [`QueryObserver`] which returns `Option<Subscription>` instead of
310/// panicking when the entity has been dropped.
311///
312/// # Panics (debug builds only)
313///
314/// In debug builds, panics if no [`QueryClient`] has been set via
315/// `cx.set_global::<QueryClient>()`. In release builds, falls back to a
316/// standalone entity (no shared caching, no GC) so that tests and demos
317/// continue to work.
318pub fn use_query_manual<T, E, C>(
319    key: QueryKey,
320    cache_policy: crate::core::CachePolicy,
321    request_policy: crate::core::RequestPolicy,
322    cx: &mut Context<C>,
323) -> (Entity<QueryResource<T, E>>, Subscription)
324where
325    T: Clone + Send + Sync + 'static,
326    E: Clone + Send + Sync + 'static,
327    C: 'static,
328{
329    let entity = if cx.has_global::<QueryClient>() {
330        cx.update_global::<QueryClient, _>(|client, cx| {
331            client.resource_with_policies::<T, E>(key, cache_policy, request_policy, cx)
332        })
333    } else {
334        // Panic in debug builds when QueryClient is not initialized.
335        // The silent fallback is appropriate for tests but dangerous for production.
336        #[cfg(debug_assertions)]
337        {
338            eprintln!(
339                "use_query_manual: no QueryClient set via cx.set_global(). \
340                 Falling back to standalone entity (no shared caching, no GC). \
341                 Call cx.set_global(QueryClient::new()) in your app setup."
342            );
343            panic!(
344                "use_query_manual: QueryClient is not initialized. \
345                 Call cx.set_global(QueryClient::new()) before using query hooks."
346            );
347        }
348        #[cfg(not(debug_assertions))]
349        {
350            // Audit fix #5: Warning eprintln removed from release builds.
351            // In release builds, silently fall back without leaking to stderr.
352            cx.new(|_| QueryResource::new(key, cache_policy, request_policy))
353        }
354    };
355
356    // Audit fix #12: Use match instead of expect() to avoid production panics.
357    // In debug builds, the entity was just created so observe() should succeed.
358    // In release builds, if GPUI internals change unexpectedly, fall back
359    // gracefully rather than panicking.
360    let observer = QueryObserver::new(&entity);
361    let Some(subscription) = observer.observe(cx) else {
362        #[cfg(debug_assertions)]
363        panic!(
364            "QueryObserver::observe failed: entity was just created and cannot be dropped. \
365             This indicates a GPUI internal regression."
366        );
367        #[cfg(not(debug_assertions))]
368        {
369            // Audit fix #5: Warning eprintln removed from release builds.
370            // Return a no-op subscription so the caller can continue.
371            // This prevents a production panic from a GPUI internal issue.
372            return (entity, Subscription::new(|| {}));
373        }
374    };
375
376    (entity, subscription)
377}
378
379/// Initiate a fetch on an existing query entity.
380///
381/// Call this when you want to refetch (e.g., on button click or timer).
382/// Respects the resource's retry policy on failure.
383///
384/// Calls `begin_request` to obtain a fresh `RequestId` and transition the
385/// resource to Loading before spawning the fetch task.
386///
387/// Audit fix #3: If `begin_request` returns `None` (cache hit or ignored),
388/// no async fetch task is spawned, avoiding wasted resources.
389pub fn fetch_query<T, E, C, F, Fut>(
390    entity: &Entity<QueryResource<T, E>>,
391    fetcher: F,
392    cx: &mut Context<C>,
393) where
394    T: Clone + Send + Sync + 'static,
395    E: Clone + Send + Sync + std::fmt::Debug + 'static,
396    C: 'static,
397    F: Fn() -> Fut + Send + 'static,
398    Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
399{
400    // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId.
401    let (Some(request_id), _signal) =
402        begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None)
403    else {
404        return;
405    };
406    let weak = entity.downgrade();
407    let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone());
408    // Audit fix #6 (deliberate detach): plain-query fetches are NOT stored on
409    // the resource. Cooperative signal cancellation + `accept_current_request`
410    // already prevent stale writes (see the module-level docs), so the task is
411    // detached and self-terminates once the entity is dropped.
412    let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
413        fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await;
414    });
415    task.detach();
416}
417
418/// Like [`fetch_query`], but the fetcher returns [`Fetched<T>`] so a server-derived
419/// [`CachePolicy`] can override the resource's policy on success ("server wins").
420///
421/// See [`use_query_with_policy`] for the server-wins semantics. Respects the
422/// resource's retry policy on failure.
423pub fn fetch_query_with_policy<T, E, C, F, Fut>(
424    entity: &Entity<QueryResource<T, E>>,
425    fetcher: F,
426    cx: &mut Context<C>,
427) where
428    T: Clone + Send + Sync + 'static,
429    E: Clone + Send + Sync + std::fmt::Debug + 'static,
430    C: 'static,
431    F: Fn() -> Fut + Send + 'static,
432    Fut: std::future::Future<Output = Result<Fetched<T>, E>> + Send + 'static,
433{
434    // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId.
435    let (Some(request_id), _signal) =
436        begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None)
437    else {
438        return;
439    };
440    let weak = entity.downgrade();
441    let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone());
442    // Same deliberate detach as `fetch_query` (audit #6 NOT applied to queries).
443    let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
444        fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await;
445    });
446    task.detach();
447}
448
449/// Like [`fetch_query`], but the fetcher receives a [`QuerySignal`] that it can
450/// check periodically for cooperative cancellation.
451///
452/// The fetcher signature is `FnOnce(QuerySignal) -> Fut`. Since `FnOnce` closures
453/// are consumed on the first call, retries are not possible.
454///
455/// Calls `begin_request` to obtain a fresh `RequestId` and reads the signal
456/// *after* `begin_request` creates it (v2 fix for stale signal).
457///
458/// Audit fix #3: If `begin_request` returns `None`, no async fetch task is spawned.
459///
460/// # Signal Cancellation (Audit Finding #8)
461///
462/// The `accept_current_request` guard is the authoritative protection against
463/// stale writes. A previous `signal.is_cancelled()` check after the fetcher
464/// returned was removed -- it was a best-effort optimization with a TOCTOU
465/// window that provided no guarantees. The two-phase protocol (accept + complete)
466/// correctly handles all cases where a newer request supersedes the current one.
467pub fn fetch_query_with_signal<T, E, C, F, Fut>(
468    entity: &Entity<QueryResource<T, E>>,
469    fetcher: F,
470    cx: &mut Context<C>,
471) where
472    T: Clone + Send + Sync + 'static,
473    E: Clone + Send + Sync + std::fmt::Debug + 'static,
474    C: 'static,
475    F: FnOnce(QuerySignal) -> Fut + Send + 'static,
476    Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
477{
478    // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId.
479    let (Some(request_id), signal) =
480        begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None)
481    else {
482        return;
483    };
484    // Audit H3: `signal` is the one begin_request just created, read in the
485    // same entity.update as the begin (no separate read pass).
486    let signal = signal.unwrap_or_else(QuerySignal::new);
487    let weak = entity.downgrade();
488
489    // FnOnce fetchers can only be called once, so retries are not possible.
490    // Audit fix #6 (deliberate detach): plain-query fetches are NOT stored on
491    // the resource. The `accept_current_request` guard below is the
492    // authoritative protection against stale writes (see the module-level
493    // docs), so the task is detached and self-terminates once the entity is
494    // dropped.
495    let task: gpui::Task<()> = cx.spawn(async move |_this, cx| {
496        let result = fetcher(signal).await;
497
498        let now_ms = current_time_ms();
499        let Some(entity) = weak.upgrade() else { return };
500
501        // Audit fix #7/#13: Only call cx.notify() when the result is actually
502        // accepted. When accept_current_request returns None, no state change
503        // occurred and no re-render is needed.
504        //
505        // Audit fix #8: Removed the signal.is_cancelled() check. The
506        // accept_current_request guard is the authoritative protection.
507        let _ = entity.update(cx, |resource, cx| {
508            if let Some(guard) = resource.accept_current_request(request_id) {
509                match result {
510                    Ok(data) => {
511                        resource.complete_success(guard, data, now_ms);
512                    }
513                    Err(error) => {
514                        resource.complete_failure(guard, error, now_ms);
515                    }
516                }
517                cx.notify();
518            } else {
519                #[cfg(debug_assertions)]
520                eprintln!(
521                    "DEBUG: fetch_query_with_signal: request {} no longer active, result discarded",
522                    request_id.label()
523                );
524            }
525        });
526    });
527    task.detach();
528}