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}