From ec9596228cd9b9bc25ce4952ad055bfd2db14743 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 10:55:42 +0200 Subject: [PATCH 001/111] fix(web): stop horizontal overflow on mobile landing sections Three landing grids used `grid lg:grid-cols-[...]` with no base `grid-cols-1`, so the implicit column sized to max-content and wide children (a long tab label, a long code line, the event-log panel) inflated the page to ~622px on mobile, forcing horizontal scroll. - HookTabs: add base grid-cols-1; make the tab nav scroll (min-w-0 max-w-full overflow-x-auto) and keep buttons from compressing (shrink-0 whitespace-nowrap). The nav is a flex item of Basecoat's .tabs, so min-w-0 is required for overflow-x-auto to engage. - AnnotatedFigure: add base grid-cols-1 so the
 scrolls instead of inflating its card to 529px.
- CacheDeck: add base grid-cols-1 so the event-log panel fits.
- architecture-section: `truncate` -> `sm:truncate` so the layer API list wraps on mobile instead of ellipsizing the content away.

Desktop (lg) layouts are unchanged. Verified at 390/360/320px: document scrollWidth now equals the viewport (no horizontal scroll), and `bun run build` passes.
---
 web/src/components/landing/architecture-section.astro | 2 +-
 web/src/components/landing/v1/HookTabs.astro          | 6 +++---
 web/src/components/landing/v2/CacheDeck.astro         | 2 +-
 web/src/components/landing/v3/AnnotatedFigure.astro   | 2 +-
 4 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/web/src/components/landing/architecture-section.astro b/web/src/components/landing/architecture-section.astro
index 029d0b4..229ca7f 100644
--- a/web/src/components/landing/architecture-section.astro
+++ b/web/src/components/landing/architecture-section.astro
@@ -49,7 +49,7 @@ const archLayers = [
             
             

{layer.label}

-

{layer.items}

+

{layer.items}

diff --git a/web/src/components/landing/v1/HookTabs.astro b/web/src/components/landing/v1/HookTabs.astro index 08e4de5..0bd1849 100644 --- a/web/src/components/landing/v1/HookTabs.astro +++ b/web/src/components/landing/v1/HookTabs.astro @@ -58,7 +58,7 @@ mutate_with_callbacks(
-
+

// surface area @@ -81,7 +81,7 @@ mutate_with_callbacks(

-
-
+
{rows.map((row) => ( diff --git a/web/src/components/landing/v3/AnnotatedFigure.astro b/web/src/components/landing/v3/AnnotatedFigure.astro index 9c3b28c..fcd6be1 100644 --- a/web/src/components/landing/v3/AnnotatedFigure.astro +++ b/web/src/components/landing/v3/AnnotatedFigure.astro @@ -84,7 +84,7 @@ const figInner = FIG_LINES.map((line, i) => { Every line, accounted for -
+
src/views/users.rs From 5a44e8506472911f34b0d15423aa33ea4013cfb4 Mon Sep 17 00:00:00 2001 From: hmziqagent Date: Tue, 28 Jul 2026 10:58:22 +0200 Subject: [PATCH 002/111] ci: add Cloudflare Pages preview deploys on PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/web-preview.yml: on every PR push touching web/** (or shared/**), build web/ and publish an isolated Cloudflare Pages PREVIEW via `wrangler pages deploy dist/client --project-name=gpui-query --branch=pr-`, then post/update a sticky PR comment with the stable alias (pr-.gpui-query.pages.dev) and the unique deployment URL. Production (master) is untouched — it still deploys only via deploy.yml. Fork PRs are skipped (no access to the Cloudflare secrets). Reuses the existing CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. Validated with actionlint (clean). Note: a pull_request workflow only fires once the file is on the base branch, so this must merge to master before subsequent web PRs get preview URLs. --- .github/workflows/web-preview.yml | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/web-preview.yml diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml new file mode 100644 index 0000000..86144d9 --- /dev/null +++ b/.github/workflows/web-preview.yml @@ -0,0 +1,104 @@ +name: Web Preview + +# Builds web/ on every PR push and publishes an isolated Cloudflare Pages +# PREVIEW deployment (branch=pr-), then posts the URL back to the PR. +# Production (master) is untouched — it still deploys only via deploy.yml. +# Deploying a non-production branch is what makes wrangler create a Pages +# preview (served at pr-.gpui-query.pages.dev + a unique URL). + +on: + pull_request: + paths: + - "web/**" + - "shared/**" + - ".github/workflows/web-preview.yml" + +# Fork PRs can't see the CLOUDFLARE_* secrets, so their deploy step would +# fail — skip them outright (they still get the PR Checks build dry-run). +permissions: + contents: read + pull-requests: write # post the preview URL comment + +concurrency: + group: web-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install web dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + env: + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true" + + - name: Deploy preview to Cloudflare Pages + id: deploy + run: | + set -euo pipefail + npx wrangler pages deploy dist/client \ + --project-name=gpui-query \ + --branch="pr-${{ github.event.pull_request.number }}" \ + | tee deploy.log + # wrangler prints the unique deployment URL (a *.pages.dev link). + url=$(grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev' deploy.log | head -n1 || true) + echo "deployment_url=${url}" >> "$GITHUB_OUTPUT" + echo "alias_url=https://pr-${{ github.event.pull_request.number }}.gpui-query.pages.dev" >> "$GITHUB_OUTPUT" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Comment preview URL on PR + uses: actions/github-script@v7 + with: + script: | + const alias = `${{ steps.deploy.outputs.alias_url }}`; + const deployUrl = `${{ steps.deploy.outputs.deployment_url }}` || alias; + const sha = context.sha.slice(0, 7); + const marker = ""; + const body = `${marker} + ## ☁️ Web preview deployed + + - **Stable URL (updates each push):** ${alias} + - **This deployment:** ${deployUrl} + - Built from \`${sha}\` · production (\`master\`) is unaffected. + + _Cloudflare Pages preview — created by the \`Web Preview\` workflow._`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } From 24a566b5d450694167798fdd132d6d214c32071f Mon Sep 17 00:00:00 2001 From: hmziqrs Date: Wed, 29 Jul 2026 02:34:25 +0500 Subject: [PATCH 003/111] feat: add claude code skills and expand page alternates --- .claude/skills/gpui-query/SKILL.md | 152 ----- AGENTS.md | 2 + CLAUDE.md | 2 +- README.md | 23 + skills/gpui-query-extensions/SKILL.md | 545 ++++++++++++++++++ skills/gpui-query/SKILL.md | 446 ++++++++++++++ web/astro.config.mjs | 1 + web/package.json | 2 +- web/scripts/generate-llms-txt.ts | 71 +-- web/scripts/generate-md-alt.ts | 47 -- web/scripts/generate-page-alts.ts | 50 ++ web/scripts/lib/docs-md.ts | 123 ++-- web/scripts/lib/markdown.ts | Bin 0 -> 4924 bytes web/scripts/lib/pages.ts | 195 +++++++ web/scripts/lib/site.ts | 52 ++ web/src/components/overrides/Head.astro | 10 + .../docs/docs/guides/claude-skills.mdx | 54 ++ web/src/layouts/BaseLayout.astro | 10 + web/src/lib/faq-data.ts | 102 ++++ web/src/lib/inline-md.ts | 66 +++ web/src/lib/legal-content.ts | 160 +++++ web/src/lib/page-meta.ts | 30 + web/src/pages/blog.astro | 10 +- web/src/pages/changelog.astro | 10 +- web/src/pages/faq.astro | 94 +-- web/src/pages/privacy.astro | 116 +--- web/src/pages/terms.astro | 99 +--- 27 files changed, 1911 insertions(+), 561 deletions(-) delete mode 100644 .claude/skills/gpui-query/SKILL.md create mode 100644 skills/gpui-query-extensions/SKILL.md create mode 100644 skills/gpui-query/SKILL.md delete mode 100644 web/scripts/generate-md-alt.ts create mode 100644 web/scripts/generate-page-alts.ts create mode 100644 web/scripts/lib/markdown.ts create mode 100644 web/scripts/lib/pages.ts create mode 100644 web/scripts/lib/site.ts create mode 100644 web/src/content/docs/docs/guides/claude-skills.mdx create mode 100644 web/src/lib/faq-data.ts create mode 100644 web/src/lib/inline-md.ts create mode 100644 web/src/lib/legal-content.ts create mode 100644 web/src/lib/page-meta.ts diff --git a/.claude/skills/gpui-query/SKILL.md b/.claude/skills/gpui-query/SKILL.md deleted file mode 100644 index 4e57c28..0000000 --- a/.claude/skills/gpui-query/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: gpui-query -description: Use when working IN the gpui-query codebase — adding or fixing query, mutation, caching, retry, persistence, HTTP-cache, or observer behavior; touching the core/client/hook layers or the http/persist satellite crates; or building, testing, documenting, or releasing the crates. Do not use for general GPUI app development; use it when the change is to this library itself. ---- - -## What it is - -gpui-query is async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Main crate `gpui-query` (v0.2.0); two satellites add HTTP cache-header handling (`gpui-query-http`) and a disk persistence adapter (`gpui-query-persist`). - -## Architecture - -Three layers in the main crate, each a Cargo feature, strictly additive; the public API is glob re-exported at the crate root so users write `gpui_query::use_query`. - -- **`core`** (`feature = "core"`) — serde-only state machine. `QueryResource`, `MutationResource`, `InfiniteQueryResource`, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`, two-phase completion types. No GPUI dep — usable anywhere. -- **`client`** (`feature = "client"`, the default) — `QueryClient`, a GPUI `Global` holding type-erased, type-partitioned buckets (`AHashMap>`). GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`, devtools diagnostics. -- **`hook`** (`feature = "hook"`) — `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`, returning `(Entity, Subscription)`. -- **`persist`** (`feature = "persist"`) — async `Persister` trait, `QueryClient::persist_with` debounced driver, free `hydrate()` fn, typed serializer registries. - -`lib.rs` exposes each layer as a `pub mod` plus a gated glob re-export: `pub use core::*;` / `pub use client::*;` / `pub use hook::*;` (the `client` glob carries `#[allow(ambiguous_glob_reexports)]` because `current_time_ms` is defined identically in both `client` and `hook`). - -Satellites: `gpui-query-http` depends on core-only (no GPUI); `gpui-query-persist` hard-depends on `persist+client+hook`. - -## Core state machine - -`QueryStatus` (derives serde; `Idle` default): `Idle`, `LoadingEmpty`, `LoadingWithData`, `Success`, `Failure`, `Cancelled`. Methods: `label()`, `is_loading()`, `is_pending()`. - -`CachePolicy` (derives serde; default `Ttl { ttl_ms: 60_000 }`): - -| Variant | Behavior | Freshness | -|---|---|---| -| `NoCache` | Always fetches. | `should_short_circuit_cache` = false | -| `Ttl { ttl_ms }` | Serves fresh within TTL. | `is_cache_fresh` when `age <= ttl_ms` (INCLUSIVE boundary, unlike HTTP max-age) | -| `StaleWhileRevalidate { ttl_ms, stale_ms }` | Stale window `[ttl, ttl+stale]` serves stale + background revalidate. | `should_serve_stale_and_revalidate` in that window | - -`QueryKey`: hierarchical key backed by `Arc<[Arc]>` (cheap clone). Serde-flexible — deserializes from a JSON array of strings OR a bare string. `starts_with` is segment-wise prefix (used by `QueryKeyFilter::Prefix`). `QueryKey::new` PANICS on an empty iterator — guard emptiness at the call site. - -`RetryPolicy`: all fields public (`max_retries`, `retry_delay_ms`, `exponential_backoff`, `max_retry_delay_ms`). Default = 3 retries, exponential backoff, 1s base, 30s configured cap, hard 1-hour ceiling. `delay_for_attempt` = `retry_delay_ms * 2^attempt` (shift clamped to 62, saturating mul). `QueryResource::new` starts with `RetryPolicy::no_retries()`; the hook layer installs the real policy. - -## Two-phase completion - -`begin_request[_with_id]` → `QueryBeginResult` (`Started` / `CacheHit` / `StaleCacheHit` / `IgnoredWhileLoading`) + `RequestId`. Caller fetches async, then `accept_current_request(id)` returns `Option` — `Some` only if `id` is still active, else `None` (stale result silently discarded). `complete_*(guard, ...)` consume the single-use `RequestGuard` by value. This is the authoritative stale-write guard: cancelled async work can never overwrite newer state. - -`RequestId` = `(NonZero scope, sequence)`; a per-resource `RequestSequencer` mints monotonic ids (scope advances on u64::MAX sequence). The `QueryClient` bucket keeps a co-located sequencer so ids stay unique across a resource's lifetime. - -## Hooks - -```rust -pub fn use_query( - options: impl Into, - fetcher: F, - cx: &mut Context, -) -> (Entity>, Subscription) -where - F: Fn(QuerySignal) -> Fut + Send + 'static, - Fut: Future> + Send + 'static; -``` - -```rust -pub fn use_mutation( - options: impl Into, - cx: &mut Context, -) -> (Entity>, Subscription); // use_mutation((), cx) works - -pub fn use_infinite_query( - options: InfiniteQueryOptions, - fetch_next: FNext, - cx: &mut Context, -) -> (Entity>, Subscription) -where - FNext: Fn(Option<&T>) -> Fut + 'static, - Fut: Future>; // (page, has_more) -``` - -Every hook returns `(Entity, Subscription)` — both must be stored; dropping the `Subscription` kills the observation. (`use_query_select` returns a 3-tuple: mapped entity, source entity, two subscriptions.) - -Fetcher + signal contract: the fetcher receives a `QuerySignal` (shared `Arc`; cancelling any clone cancels all). Poll `signal.is_cancelled()` to abort early, but write rejection is owned by `accept_current_request` — do NOT rely on an `is_cancelled()` check after the fetch returns (TOCTOU). `use_query_with_policy`'s fetcher returns `Fetched` so a server-derived `CachePolicy` overrides the resource's on success (`Fetched::with_policy`, "server wins"). - -Minimal example: - -```rust -use gpui_query::{use_query, QueryClient}; - -cx.set_global(QueryClient::new()); - -let (entity, _sub) = use_query( - "users", - |signal| async move { - if signal.is_cancelled() { return Err(MyError::Cancelled); } - Ok::, MyError>(fetch_users().await?) - }, - cx, -); -``` - -Plain-query fetch tasks are `.detach()`ed (cooperative signal + `accept_current_request` prevent stale writes; the task self-terminates on entity drop). Mutations and infinite queries store the task via `set_current_task` so replacement/unmount HARD-aborts the prior task. - -## Observers - -A single generic `Observer` with aliases `QueryObserver` / `InfiniteQueryObserver` / `MutationObserver`. It holds a `Cell>` and calls `cx.notify()` ONLY when `observable_status()` changes — `increment_retry()` / `prepare_retry()` (stay `Loading`) and `set_current_task` do NOT re-render. Attach with `Observer::new(&entity).with_config(cfg).observe(cx) -> Option`. - -## Persistence - -Two tiers, both `persist`-gated, both defined in the MAIN crate (`gpui-query-persist` only ships the `FilePersister` adapter): - -- Legacy metadata-only: `QueryPersister` trait (sync, object-safe, `Vec`), `QueryClient::dehydrate` / `hydrate` (method — a NO-OP stub) / `persist` / `restore`. `DehydratedEntry` carries key + type_id + kind only — NO data field. -- Value-carrying (use this): `Persister` trait (async, NON-object-safe — returns `impl Future`; monomorphized via `persist_with`), `PersistSnapshot` / `PersistedEntry` / `PersistError`. `QueryClient::persist_with(persister, opts, cx) -> PersistHandle` debounces saves off a `CacheMutation` marker Global; the free `hydrate()` fn re-primes the cache via registered deserializers. - -`FilePersister` (in `gpui-query-persist`) is an atomic, durable disk adapter: sibling `NamedTempFile` → fsync → macOS `F_FULLFSYNC` → rename over target → POSIX parent-dir fsync. Tolerant load: missing → empty snapshot, corrupt → logged + empty, version mismatch → typed `PersistError::VersionMismatch`. `PersistFormat::Json` (default) or `::Bincode`. It implements `Persister`, NOT `QueryPersister`. - -Register typed (de)serializers via `register_serializer::(fn(&T)->JsonValue)` / `register_deserializer::(fn(&JsonValue)->Option)`. `hydrate()` offers EVERY entry to EVERY deserializer (O(n×m)) — keep deserializers STRICT (return `None` for foreign shapes). - -## HTTP cache - -`gpui-query-http` derives a `CachePolicy` from RFC 9111 `Cache-Control` ("server wins") and layers an in-memory URL-keyed `HttpCache` for cheap 304 revalidations. - -- `cache_policy_from_headers(headers: &HeaderMap) -> Result` — `no-store`/`no-cache` short-circuits to `NoCache`; `s-maxage` overrides `max-age`; `stale-while-revalidate` → SWR. Absent/empty header → `Ok(NoCache)` (no Expires heuristic). -- `HttpCache::fetch(url)` short-circuits on fresh entries; otherwise a conditional GET carries `If-None-Match` (ETag) / `If-Modified-Since`; a 304 re-serves the cached body, a 200 parses headers and stores fresh. Only 200s are stored. -- Hand the result to `Fetched::with_policy(data, policy)` inside a `use_query_with_policy` fetcher. -- `ReqwestBackend` is behind the OFF-by-default `reqwest` feature (rustls-tls only; no cookies/compression). `HttpBackend` is non-object-safe (static dispatch via `HttpCache`); guards are never held across an `.await`. - -## Common tasks - -- Add a feature flag: add to `crates/gpui-query/Cargo.toml` `[features]`, gate the module in `lib.rs` with `#[cfg(feature = "...")]` + `#[cfg_attr(docsrs, doc(cfg(feature=...)))]`, glob re-export at the crate root. -- Add a test: tests are inline under `crates/gpui-query/src/tests/` (declared in `src/tests/mod.rs`), feature-gated per module. Match the gate to the layer under test (`core_*` = ungated, `integration_*` = `#[cfg(feature = "client")]` / `hook`). Run: `cargo test --features ""` or `just test` for everything. -- Update docs / regenerate llms.txt: edit `web/src/content/docs/docs/**` (Astro + Starlight, MDX), then `just web-build` — it regenerates `llms.txt` / `llms-full.txt` via `web/scripts/generate-llms-txt.ts`. Never hand-edit those or `web/dist/**`. -- Cut a release: add a `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`, then `just release x.y.z` (commits + pushes; CI tags, publishes, deploys). - -## Gotchas - -- WeakEntity retention: async tasks capture `entity.downgrade()`. If the owning component unmounts mid-fetch, the result is silently discarded (`weak.upgrade()` = `None`). Mutation `on_settled(None, None)` still fires on drop as a safety net. -- Notify only on status changes: `Observer` calls `cx.notify()` only when `observable_status()` changes. `increment_retry` / `prepare_retry` / `set_current_task` do NOT re-render. -- Signal cancellation on LatestWins replacement: `begin_loading` cancels the OLD signal before installing a fresh one, so a superseded fetcher observes `is_cancelled()`. The authoritative stale-write guard is `accept_current_request` returning `None`. -- Inclusive TTL boundary: `age <= ttl_ms` is fresh (opposite of HTTP `max-age`). Watch for off-by-one at the boundary. -- Bounded `max_pages` default: `InfiniteQueryResource` caps at `Some(50)` pages. `FetchDirection::ForwardOnly` (default) starts `has_next_page=true` as an assumption — use `new_bidirectional()` if the fetcher's `has_more` should drive fetching. -- Mutation GC actually runs: `gc_time_ms` default is 300_000 (explicit `Default` impl; the derived one produced 0, which DISABLED GC). `with_gc_time(0)` still disables GC; values < 1000 are clamped. -- AHashMap cache keys: `QueryClient` buckets use `AHashMap` (~2x faster, trusted keys). `SerializerRegistry` is keyed by `TypeId::of::()` ALONE, not `(T,E)` — registering the same `T` under two `E`s overwrites. -- macOS Metal toolchain: building `client`/`hook`/`persist` (anything pulling `gpui`) fails without the Metal Toolchain. Run `xcodebuild -downloadComponent MetalToolchain` once. Core-only builds need nothing. -- Cache-mutation dirty signal: `cx.default_global::()` is bumped at every cache-mutation site (`set_query_data`, `PreparedFetch` completions, hook completions); `persist_with`'s `observe_global` reacts to it. -- `QueryError` messages are stored verbatim and surface in Display/Debug/serde. Use the typed constructors (`response` / `transport` / `cancelled`) to preserve category, and `sanitized()` (redacts tokens/paths/emails, truncates to 512 bytes) before constructing from untrusted server data. - -## Pointers - -- Docs site: https://gpui-query.freeoxide.com/docs/ — guides under `/docs/guides/` (`caching`, `retry`, `query-keys`, `persistence`, `http-caching`, `select-pattern`, `error-handling`); API under `/docs/api/`. -- `llms.txt` / `llms-full.txt` are generated into the site root at build time (not committed source) — read them from a built `web/dist/client/` if you need the full rendered reference. -- Key source paths: - - Main crate: `crates/gpui-query/src/{core,client,hook}/` (re-exported by `lib.rs`). - - `QueryResource` lifecycle: `src/core/resource/{lifecycle,completion,cache}.rs`. - - `QueryClient`: `src/client/{mod,lifecycle,bucket/}.rs`; persistence: `src/client/persist.rs`. - - Hooks: `src/hook/{query_hooks,mutation_hooks/,use_infinite_query/}.rs`. - - HTTP: `crates/gpui-query-http/src/{cache,backend,reqwest_backend}.rs`. - - Disk adapter: `crates/gpui-query-persist/src/lib.rs`. -- Tests: `crates/gpui-query/src/tests/` (feature-gated per module). diff --git a/AGENTS.md b/AGENTS.md index 370aaf8..5452e46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ Manual escape hatches: `just publish ` (Publish Crate Manual), `just deploy `web/` is Astro + Starlight, bun-managed, deployed to Cloudflare Pages project `gpui-query` from `web/dist/client`. Docs served at `/docs/**`; site root is the marketing page. - `llms.txt` and `llms-full.txt` are AUTO-GENERATED into the site root by `web/scripts/generate-llms-txt.ts` during `just web-build`. Do NOT hand-edit them or anything under `web/dist/**`. +- Per-page `.md` and `.txt` alternates (one per public page — append the extension to any URL, e.g. `/docs/guides/caching.md`; root → `/index.{md,txt}`, docs index → `/docs.{md,txt}`) are AUTO-GENERATED by `web/scripts/generate-page-alts.ts` from `web/scripts/lib/pages.ts`, and each HTML page advertises them via ``. They run ~80–90% smaller than the HTML. Do NOT hand-edit them. +- Page copy shared between the rendered page and its `.md`/`.txt` alternates is authored ONCE: FAQ in `web/src/lib/faq-data.ts`, privacy + terms in `web/src/lib/legal-content.ts` (rendered to HTML by `web/src/lib/inline-md.ts`). Edit there — never duplicate it into the `.astro` files or the generator. - Search is one combined Pagefind index. - Directory output (`build.format: "directory"`) with `trailingSlash: "ignore"` so both `/docs` and `/docs/` serve; the `Head.astro` override normalizes canonical/OG URLs. diff --git a/CLAUDE.md b/CLAUDE.md index 9a1bbfa..f5acd1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,6 @@ ## Claude Code notes - AGENTS.md above is the source of truth for layout, commands, features, and release flow. Keep this file thin; edit AGENTS.md when the facts change. -- A committed project skill lives at `.claude/skills/gpui-query/SKILL.md`. Use it for crate work — query, mutation, caching, retry, persistence, HTTP-cache, or observer behavior; the core/client/hook layers; and the http/persist satellites. +- Two installable skills ship at `skills/gpui-query/` (essentials: hooks, in-memory caching, retry, invalidation, observers) and `skills/gpui-query-extensions/` (HTTP cache-control + disk persistence). They're user-facing — for apps that *depend on* gpui-query. Install globally: `cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/`. For crate-internal work (layout, release flow), use AGENTS.md. - Run `just test` (= `cargo test --all-features`) before claiming any Rust change is done. Bare `cargo test` uses default features (`client`) and skips the `hook`/`persist` test modules. - Never edit `web/dist/**` or the generated `llms.txt` / `llms-full.txt` — they are produced by `just web-build`. Rebuild the site to refresh them. diff --git a/README.md b/README.md index f2a6aab..2c4c9c8 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,29 @@ Only `Success` entries with a registered serializer are persisted; the typed rou Garbage collection runs on idle resources older than `gc_time_ms` (default: 5 minutes). Configurable per-query via `QueryOptions::gc_time_ms()`. +## claude code skills + +Two installable [Claude Code](https://claude.com/claude-code) skills ship in this repo under [`skills/`](./skills) — knowledge packs that teach an AI assistant the real gpui-query API (signatures, defaults, lifecycle, gotchas) so it writes correct hooks, caching, retry, and persistence code instead of guessing. + +- **`gpui-query`** — the essentials: `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`, in-memory `CachePolicy`, `RetryPolicy`, `QueryKey` filters, `QueryClient` bulk ops, observers, GC. +- **`gpui-query-extensions`** — the satellites: HTTP `Cache-Control` → `CachePolicy` + `HttpCache` (`gpui-query-http`), and durable disk persistence with `FilePersister` + the `persist` feature (`gpui-query-persist`). + +Install both globally (available in every project), from a clone of the repo: + +```sh +cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/ +``` + +Or grab a single skill without cloning: + +```sh +mkdir -p ~/.claude/skills/gpui-query +curl -fsSL https://raw.githubusercontent.com/freeoxide/gpui-query/master/skills/gpui-query/SKILL.md \ + -o ~/.claude/skills/gpui-query/SKILL.md +``` + +Once installed, the skills activate automatically when you work on a GPUI app that depends on gpui-query — no manual invocation needed. See the [Claude Code skills guide](https://gpui-query.freeoxide.com/docs/guides/claude-skills) for details. + ## links - Documentation: diff --git a/skills/gpui-query-extensions/SKILL.md b/skills/gpui-query-extensions/SKILL.md new file mode 100644 index 0000000..b8ec106 --- /dev/null +++ b/skills/gpui-query-extensions/SKILL.md @@ -0,0 +1,545 @@ +--- +name: gpui-query-extensions +description: Use when adding HTTP cache-header handling (RFC 9111 Cache-Control -> CachePolicy, HttpCache, ReqwestBackend, conditional GETs/304s) or durable disk persistence (FilePersister, async Persister trait, persist_with debounced driver, hydrate, typed serializer/deserializer registries) to a gpui-query app. Do not use for the essential in-memory cache alone, for general GPUI app work, or for editing the gpui-query crates themselves (see AGENTS.md for crate-internal work). +--- + +# gpui-query extensions: HTTP caching & disk persistence + +The main crate ships an in-memory, type-partitioned cache with GC, invalidation, and TTL/SWR policies. Two **satellite crates** + one **main-crate feature** add cross-restart durability and server-driven HTTP cache semantics. Everything here is strictly additive over `client`. + +- `gpui-query-http` (v0.1.0) — RFC 9111 `Cache-Control` → `CachePolicy` ("server wins"), plus a URL-keyed in-memory `HttpCache` for cheap `304` revalidations. **GPUI-free** (depends on `core` only). +- `gpui-query-persist` (v0.1.0) — reference atomic-durable `FilePersister`. +- main crate `persist` feature — the async `Persister` trait, `persist_with` debounced driver, `hydrate`, and the typed serializer/deserializer registries. + +Reach for these when: +- the app should **survive a restart** with its cached data primed (cold start shows last-known-good instead of a loading spinner); +- a query fetches over **HTTP** and you want the server's `max-age`/`stale-while-revalidate` to drive the resource's `CachePolicy`, and `ETag`/`Last-Modified` to make refetches cheap. + +Do NOT reach for them for ephemeral in-memory state, or if you only need client-side TTLs the app controls itself (use `CachePolicy` directly on the resource). + +## Install + +```toml +[dependencies] +gpui = "0.2.2" +gpui-query = { version = "0.2.0", features = ["persist"] } # enables persist layer + +# HTTP cache (optional reqwest backend): +gpui-query-http = { version = "0.1", features = ["reqwest"] } # drop "reqwest" to use your own HttpBackend + +# Reference disk persister: +gpui-query-persist = "0.1" +``` + +The `persist` feature is `client + hook + dep:serde_json + dep:thiserror`. `gpui-query-persist` hard-depends on `persist + client + hook`. `gpui-query-http`'s `reqwest` feature pulls `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }`. With `default-features = false`, reqwest drops its own defaults (`default-tls` = native-tls, plus `charset`, `http2`, `macos-system-configuration`) and only rustls TLS is added back. Cookies and gzip/brotli/deflate compression are opt-in reqwest features that were **never** on by default — enable them on your own `reqwest::Client` if you need them. + +macOS note: building `client`/`hook`/`persist` needs the Metal Toolchain once (`xcodebuild -downloadComponent MetalToolchain`). `gpui-query-http` (core-only) needs nothing extra. + +--- + +## Persistence mental model + +``` +register (de)serializers ──► hydrate() at cold start ──► persist_with() reacts to cache writes + (TypeId-keyed) (load snapshot, (debounced save on every + re-prime concrete T) CacheMutation bump) +``` + +1. **Register** a serializer and deserializer per resource data type `T`. Only resources with a registered serializer are emitted into the snapshot; unregistered types fall back to metadata-only (skipped). +2. **`hydrate()`** at startup: load the snapshot, offer each on-disk entry to every registered deserializer, prime matches via `set_query_data::`. +3. **`persist_with()`**: install a drop-guard driver. Every `CacheMutation` bump (a query/mutation resolving, `set_query_data`, `invalidate`, GC eviction) collects a fresh snapshot on the main thread, stashes it in a single pending slot, and spawns a debounced `save` on GPUI's `background_executor`. Bursts coalesce — only the latest snapshot survives the window. + +The snapshot value is an opaque `serde_json::Value`; the typed round-trip is driven by the registries, so core never needs a `T: Serialize` bound. + +--- + +## Persister trait + snapshot types + +Main crate, `gpui_query::client::*` (re-exported at the crate root via `pub use client::*`). + +```rust +pub const PERSIST_VERSION: u32 = 1; + +#[derive(Debug, thiserror::Error)] +pub enum PersistError { + #[error("persistence io error: {0}")] + Io(#[from] std::io::Error), + #[error("persistence serialize error: {0}")] + Serialize(#[from] serde_json::Error), + #[error("persistence deserialize error: {0}")] + Deserialize(String), // reserved for backends that surface parse errors + #[error("persistence version mismatch: expected {expected}, found {found}")] + VersionMismatch { expected: u32, found: u32 }, + #[error("persistence bad path: {0}")] + BadPath(String), + #[error("persistence permission denied: {0}")] + Permission(String), // retryable (Windows AV / lock contention) +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PersistedEntry { + pub value: serde_json::Value, // opaque; typed round-trip via registries + pub cached_at: u64, // wall-clock ms since UNIX epoch + pub cache_policy: CachePolicy, + pub meta: Option, // reserved for HTTP CacheMeta, etc. +} + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct PersistSnapshot { + pub entries: std::collections::HashMap, // keyed by QueryKey::to_path() + pub version: u32, +} +impl PersistSnapshot { pub fn new() -> Self { /* empty, at PERSIST_VERSION */ } } +``` + +The trait is **async + non-object-safe** (methods return `impl Future + Send`, not `Pin>`). It is consumed generically by `persist_with`, which monomorphizes the driver around the concrete `P`. This keeps `Send + 'static` visible at the call site (the save future runs on GPUI's `background_executor`) and avoids boxed-future overhead. Consequence: you cannot hold a `dyn Persister`. + +```rust +pub trait Persister: Send + Sync + 'static { + fn load(&self) -> impl Future> + Send; + fn save(&self, snapshot: &PersistSnapshot) + -> impl Future> + Send; +} +``` + +`PersistHandle` is the drop-guard returned by `persist_with`. Holding it keeps the `CacheMutation` observation (and thus the debounced save loop) alive; **dropping it drops the `Subscription`**, so no new saves are scheduled. A save already parked on its debounce timer is detached and may still complete one final save. `PersistHandle::empty()` constructs a no-op handle (tests). + +--- + +## persist_with + PersistOptions + +```rust +impl QueryClient { + pub fn persist_with( + &self, + persister: P, // wrapped in Arc

internally + opts: PersistOptions, + cx: &mut gpui::App, + ) -> PersistHandle; +} +``` + +| `PersistOptions` field | type | default | +|---|---|---| +| `filter` | `PersistFilter` | `PersistFilter::All` | +| `max_age` | `std::time::Duration` | `Duration::from_secs(24 * 60 * 60)` (24h); entries older than this at save time are skipped | +| `debounce` | `std::time::Duration` | `Duration::from_millis(500)`; `Duration::ZERO` disables the timer window (saves still serialize through the drain slot) | + +```rust +#[derive(Clone, Debug)] +pub enum PersistFilter { // owned counterpart to core's borrowing QueryKeyFilter<'a> + Exact(QueryKey), // only this key + Prefix(QueryKey), // every key that starts with this prefix + All, // every persistable entry +} +impl PersistFilter { pub fn matches(&self, key: &QueryKey) -> bool; } +``` + +The observer callback collects a snapshot on the main thread (cheap; has `&App`), stashes it in a shared `Mutex>` slot (replacing any pending one), then spawns a debounced task on the `background_executor` that, after `opts.debounce`, drains the slot and runs `persister.save`. An `armed` flag bounds in-flight tasks to one per window — a bump arriving while a task is already armed skips spawning (its snapshot still lands in the slot, drained by the armed task). Latest snapshot wins. + +--- + +## hydrate + the registries + +```rust +pub async fn hydrate( + client: &mut QueryClient, + persister: &P, + filter: &PersistFilter, + max_age: Duration, + cx: &mut gpui::App, +) -> Result; +``` + +Loads the snapshot, double-checks `version == PERSIST_VERSION` (returns `VersionMismatch` otherwise), then for **every** registered deserializer walks **every** entry and lets the step decode + prime it. Returns the post-filter snapshot so you can do additional metadata-only priming or diagnostics. + +> Name collision: there is also a legacy `QueryClient::hydrate(&mut self, _state, _cx)` **method** (metadata-only, no-op stub — see Legacy tier below). The value-carrying primitive is the **free function** `hydrate(...)`. + +```rust +impl QueryClient { + pub fn register_serializer(&mut self, f: fn(&T) -> serde_json::Value) + where T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static; + + pub fn register_deserializer(&mut self, deserialize: fn(&serde_json::Value) -> Option) + where T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static; + + pub fn collect_persist_snapshot( + &self, filter: &PersistFilter, max_age: Duration, cx: &gpui::App, + ) -> PersistSnapshot; +} +``` + +- `f`/`deserialize` are **plain `fn` pointers** (not closures) — `Send + Sync + 'static` with no boxing at the call site. The registry boxes each pointer internally (`Box` / `Arc`) for type-erased storage — one allocation per registered type, not per save. +- `SerializerRegistry` keys on `TypeId::of::()` alone (not `(T, E)`), because the bucket impls look up by `T`. Serialization depends only on the data type. **Registering two serializers for the same `T` under different `E` silently overwrites** (last wins); whichever survives applies to both `(T, E)` buckets, which is correct because the value *is* that `T`. +- `DeserializerRegistry` is a `Vec<(TypeId, step)>`. **`hydrate` offers each entry to every deserializer** — O(deserializers × entries). There is no type discriminator on `PersistedEntry`, so routing is by trial. +- **Strict-deserializer contract:** a deserializer MUST return `None` for any JSON shape it does not recognize as its own `T`. A lax decoder that accepts a foreign shape wastes work and can mis-prime. Keep them strict and cheap (`v.as_str().map(...)` for a string, `serde_json::from_value(v).ok()` for a struct). + +--- + +## FilePersister (gpui-query-persist) + +```rust +pub enum PersistFormat { Json, Bincode } // Json default; Bincode smaller/faster, not human-readable + +pub struct FilePersister { /* path, format, write_lock: Mutex<()> */ } + +impl FilePersister { + pub fn new(path: impl Into, format: PersistFormat) -> Self; + pub fn json(path: impl Into) -> Self; // = new(_, Json) + pub fn bincode(path: impl Into) -> Self; // = new(_, Bincode) + pub fn in_cache_dir(app_name: impl AsRef) -> Result; // dirs::cache_dir/app/gpui-query-cache.json (Json) + pub fn path(&self) -> &Path; +} +impl Persister for FilePersister { async fn load(&self) -> Result; async fn save(&self, &PersistSnapshot) -> Result<(), PersistError>; } +``` + +**Atomic durable write** (`save`): ensure parent dir → serialize → write to a sibling `tempfile::NamedTempFile` → `sync_all` (fsync) → on macOS issue `F_FULLFSYNC` (flushes the drive's own cache; best-effort, logged on failure) → `tempfile`'s `.persist()` renames over the target (`rename(2)` POSIX / `MoveFileEx` Windows) → on POSIX, fsync the **parent directory** so the rename is durable across power loss. Writes serialize through an internal `Mutex`, so concurrent `save` calls from the background executor never interleave temp-file lifecycles. + +**Tolerant load** (`load`): + +| On-disk state | Result | +|---|---| +| Missing file | empty snapshot (no error) | +| Corrupt / unparseable | logged via `eprintln!` + **empty snapshot** (no panic) | +| `version != PERSIST_VERSION` | `Err(PersistError::VersionMismatch { expected, found })` — typed, so you can distinguish corrupt from wrong-format | +| Valid | decoded snapshot | + +**Error mapping:** Windows `ERROR_ACCESS_DENIED` during the atomic replace (antivirus / concurrent reader) maps to `PersistError::Permission` (retryable — back off and retry). Every other IO failure is `PersistError::Io` with the original `std::io::Error` (kind + source chain intact). + +`save`/`load` do **synchronous `std::fs` I/O** in their async bodies — intended for GPUI's `background_executor` (a blocking-friendly pool). On a tokio multi-thread runtime, wrap in `spawn_blocking`. **Bincode format** JSON-encodes each entry's `value` to a `String` inside a bincode-safe adapter (bincode can't drive `serde_json::Value`'s `deserialize_any`); the conversion is lossless. + +`pub use gpui_query::client::NoopPersister;` is re-exported from this crate as a one-stop default/test persister. + +--- + +## Full cold-start example + +```rust +use std::time::Duration; +use gpui::{App, AppContext as _, Global}; +use gpui_query::client::{ + hydrate, PersistFilter, PersistOptions, QueryClient, +}; +use gpui_query::core::{CachePolicy, Fetched, QueryError, QueryKey, RequestPolicy}; +use gpui_query::hook::{use_query_with_policy, QueryOptions}; +use gpui_query_persist::FilePersister; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct User { id: u64, name: String } +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Users(Vec); + +// Global so the bootstrap task can install the client before any view reads it. +struct ClientGlobal(QueryClient); +impl Global for ClientGlobal; + +fn bootstrap(cx: &mut App) { + let mut client = QueryClient::new(); + + // 1. Register a (de)serializer for each T you want to round-trip. + client.register_serializer::(|u| serde_json::to_value(u).unwrap()); + client.register_deserializer::(|v| serde_json::from_value(v.clone()).ok()); + + let persister = FilePersister::json("/var/cache/myapp/gpui-query-cache.json"); + let max_age = Duration::from_secs(60 * 60 * 24); + let filter = PersistFilter::All; + + // 2. Cold start: hydrate primes the live cache from disk. Block on it from + // a background task; GPUI's background_executor is blocking-friendly. + cx.background_executor().spawn({ + let persister = persister; // FilePersister is Send + Sync + Clone-free + async move { + // NOTE: hydrate needs &mut QueryClient + &mut App — drive it in a + // cx.update_global lease, not detached across an await holding a guard. + } + }).detach(); + + cx.set_global(ClientGlobal(client)); + + // (Hydrate must run inside a global lease that owns &mut App. The typical + // shape is a blocking `cx.run` / `block_on` for the ready load, or a + // spawn that re-enters via update_global. See the hydrate signature above.) + + // 3. Install the debounced save driver. Hold the handle for the app lifetime. + let _handle = cx.update_global::(|ClientGlobal(client), cx| { + client.persist_with( + persister, + PersistOptions { filter, max_age, ..PersistOptions::default() }, + cx, + ) + }); +} +``` + +Then a consuming component fetches as usual; a real fetch completion bumps `CacheMutation`, which the driver observes and saves: + +```rust +fn render_users(cx: &mut gpui::Context) { + let (entity, _sub) = use_query_with_policy::( + QueryOptions::new(["users", "all"]) + .cache_policy(CachePolicy::Ttl { ttl_ms: 60_000 }) + .request_policy(RequestPolicy::LatestWins), + |signal| async move { + let resp: Vec = fetch_users().await?; // your async + if signal.is_cancelled() { return Err(QueryError::cancelled("aborted")); } + Ok(Fetched::new(Users(resp))) + }, + cx, + ); + // entity.read(cx).data() / .status() ... +} +# async fn fetch_users() -> Result, QueryError> { Ok(vec![]) } +``` + +Both `use_query` completions and `use_mutation` / `use_infinite_query` first-page completions bump the dirty signal; the imperative `prepare_fetch_query().complete_success(...)` path bumps it too, so nothing fetched through the public API is invisible to the driver. + +--- + +## HTTP cache mental model + +``` +response headers ──► cache_policy_from_headers() ──► CachePolicy ──► Fetched::with_policy() + │ + HttpCache in-memory layer over HttpBackend + (fresh short-circuit, conditional GET, 304 re-serve) +``` + +Two independent pieces: + +1. **`cache_policy_from_headers`** — pure header → `CachePolicy` ("server wins"). Hand the result to `Fetched::with_policy(data, policy)` so the resource adopts the server's TTL. +2. **`HttpCache`** — a URL-keyed in-memory layer over any `HttpBackend`. Fresh entries short-circuit the network; stale entries revalidate with `If-None-Match` / `If-Modified-Since`; a `304` re-serves the cached body without transferring a new one. This is the cheap-revalidation cache that lives *inside* your fetcher, orthogonal to gpui-query's own resource cache. + +`CacheMeta` is `Serialize + Deserialize` so a future persistence layer can store it alongside the body and rehydrate a cold start with valid `ETag`s — enabling cheap `304` refetches on the first request after launch. It uses `SystemTime` (epoch-relative, serde-supported), **never** `Instant` (no serde, meaningless across restarts). + +--- + +## cache_policy_from_headers rules + +```rust +pub fn cache_policy_from_headers(headers: &http::HeaderMap) + -> Result; +``` + +Priority order (from [RFC 9111]): + +1. **`no-store` / `no-cache`** (any value, including bare) → `CachePolicy::NoCache`. Short-circuits immediately — a malformed trailing directive (e.g. `no-store, max-age=abc`) does NOT surface a parse error. +2. **`s-maxage=N`** (shared-cache directive) takes precedence over `max-age=N` when both present. The chosen TTL yields `CachePolicy::Ttl { ttl_ms: N*1000 }`. If `stale-while-revalidate=M` is also present, yields `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms: M*1000 }` instead. +3. **Otherwise** → `CachePolicy::NoCache`. There is **no `Expires`-based heuristic** (reserved for a later addition). + +Directive names are matched **case-insensitively**; values may be quoted (`max-age="600"`). Multiple `Cache-Control` headers combine. Other directives (`public`, `private`, …) are ignored unless they map to a rule above. + +`ParseError { InvalidMaxAge(String), InvalidStaleWhileRevalidate(String) }` — only a malformed TTL value (e.g. `max-age=abc`) produces an error; wrap with `.unwrap_or(CachePolicy::NoCache)` to degrade gracefully. + +[RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111 + +```rust +let policy = cache_policy_from_headers(&resp.headers).unwrap_or(CachePolicy::NoCache); +let fetched = Fetched::with_policy(data, policy); +``` + +--- + +## HttpBackend + HttpCache flow + +```rust +pub struct Conditionals { + pub if_none_match: Option, // from a cached ETag + pub if_modified_since: Option, // from a cached Last-Modified +} +impl Conditionals { pub fn from_meta(meta: Option<&CacheMeta>) -> Self; } + +pub struct BackendResponse { + pub status: u16, + pub headers: http::HeaderMap, + pub body: bytes::Bytes, // owned, outlives the connection +} + +pub trait HttpBackend: Send + Sync { + type Error: std::error::Error + Send + Sync + 'static; + fn fetch(&self, url: &str, conditionals: Conditionals) + -> impl Future> + Send; +} +``` + +The trait uses `-> impl Future + Send` (not `async fn`) so the future is guaranteed `Send` for any executor. This makes it **non-object-safe** — dispatch is static via `HttpCache`, no `dyn`/`Pin>` overhead. + +```rust +pub struct HttpCache { /* backend + two Mutex */ } + +impl HttpCache { + pub fn new(backend: B) -> Self; + pub async fn fetch(&self, url: &str) + -> Result<(bytes::Bytes, CachePolicy, Option), HttpError>; +} +``` + +`fetch` branches: + +| Branch | Behavior | +|---|---| +| **Fresh hit** (`stored_at + fresh_for > now`) | return cached body immediately; **backend never called** | +| Stale / first fetch | build `Conditionals::from_meta(cached)`, call `backend.fetch` | +| **`304 Not Modified`** | re-serve cached body + stored policy/meta; `Err(NotModifiedWithoutCachedBody)` if no cached body exists | +| **`200 OK`** | parse policy via `cache_policy_from_headers`; if `NoCache`, serve body + store nothing; else store body + meta, return fresh triple | +| Any other status | return body + `CachePolicy::NoCache` + `None`; nothing stored | + +Only `200`s with a cacheable policy populate the cache. `meta` is `None` only for non-cacheable responses. + +```rust +#[derive(Debug, thiserror::Error)] +pub enum HttpError { + #[error("backend request failed")] + Backend { #[source] source: Box }, + #[error(transparent)] + InvalidPolicy(#[from] ParseError), + #[error("received 304 without a cached body for {url:?}")] + NotModifiedWithoutCachedBody { url: String }, + #[error("cache mutex poisoned")] + Poisoned, +} +``` + +**Concurrency:** state is guarded by two `std::sync::Mutex`es (one for meta, one for bodies). A guard is acquired, the needed value is **cloned out, and the guard dropped before any `.await` point** — so the cache never holds a `std` mutex across `.await`. It is `Send + Sync` and requires **no tokio** (works on GPUI's `background_executor`, tokio, or anything else). + +`CacheMeta` round-trips through persistence: a non-zero `stale_for` reconstructs `StaleWhileRevalidate`, a non-zero `fresh_for` reconstructs `Ttl`, both-zero collapses to `NoCache` (mirroring `fresh_for_from_policy` / `stale_for_from_policy`). + +--- + +## ReqwestBackend + +Behind the `reqwest` cargo feature (`gpui-query-http = { features = ["reqwest"] }`): + +```rust +pub struct ReqwestBackend(pub reqwest::Client); + +impl ReqwestBackend { + pub fn from_client(client: reqwest::Client) -> Self; // for custom TLS/timeouts/proxies +} +impl HttpBackend for ReqwestBackend { + type Error = reqwest::Error; + fn fetch(&self, url: &str, conditionals: Conditionals) + -> impl Future> + Send; +} +``` + +The request is built **synchronously** (no `.await`) — `If-None-Match` / `If-Modified-Since` attached when present — then the `send` + `bytes()` half is returned as a `Send` future. This eager build keeps the future `Send` even where `reqwest::RequestBuilder` is `!Send`. The client is reused across requests (configure TLS provider, timeouts, proxies on the `reqwest::Client` before wrapping). + +`reqwest` is *one* possible backend. Any client that can do a conditional `GET` and produce status + headers + body can `impl HttpBackend` and feed `HttpCache::new`. + +--- + +## Full HTTP example: HttpCache inside a with_policy fetcher + +```rust +use gpui_query::core::{CachePolicy, Fetched, QueryError}; +use gpui_query::hook::{use_query_with_policy, QueryOptions}; +use gpui_query_http::{HttpCache, ReqwestBackend, cache_policy_from_headers}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Release { tag: String } + +// One cache per app, shared across fetchers. Wrap it in an `Arc` — `HttpCache` +// is NOT `Clone` (it holds `Mutex`s), so clone the `Arc`, not the cache. +fn http_cache() -> std::sync::Arc> { + std::sync::Arc::new(HttpCache::new(ReqwestBackend::from_client(reqwest::Client::new()))) +} + +fn fetch_releases(cx: &mut gpui::Context) { + let cache = http_cache(); + let (entity, _sub) = use_query_with_policy::( + QueryOptions::new(["releases", "latest"]), + move |_signal| { + let cache = cache.clone(); // cheap Arc clone — one per fetch invocation + async move { + // 1. HttpCache.fetch handles fresh short-circuit / 304 revalidation. + let (body, policy, meta) = cache + .fetch("https://api.example.com/releases/latest") + .await + .map_err(|e| QueryError::transport(format!("http: {e}")))?; + + let release: Release = serde_json::from_slice(&body) + .map_err(|e| QueryError::unknown(format!("decode: {e}")))?; + + // 2. Server wins: adopt the response's CachePolicy. + let mut fetched = Fetched::with_policy(release, policy); + + // 3. (persist feature) Carry CacheMeta so the next cold start + // can issue a conditional GET immediately. + if let Some(m) = meta { + fetched = fetched.with_meta(serde_json::to_value(m).unwrap()); + } + Ok(fetched) + } + }, + cx, + ); + // entity.read(cx) ... +} +``` + +`Fetched` API: + +```rust +impl Fetched { + pub fn new(data: T) -> Self; // keep the caller's policy + pub fn with_policy(data: T, policy: CachePolicy) -> Self; // override with server's + #[cfg(feature = "persist")] + pub fn with_meta(mut self, meta: serde_json::Value) -> Self; // attach CacheMeta etc. +} +``` + +`Fetched::with_meta` requires the `persist` feature; the `meta` flows into `PersistedEntry::meta` when the resource is persisted, enabling cold-start revalidation. + +--- + +## Legacy tier (metadata-only) — steer away + +Alongside the value-carrying `Persister`, the main crate retains an older **metadata-only** persistence API (also gated behind `persist`), kept for back-compat. It carries **no data**: + +```rust +#[cfg(feature = "persist")] +pub trait QueryPersister: Send + Sync { + fn load(&self) -> Vec; + fn save(&self, entries: Vec); +} + +#[cfg(feature = "persist")] +pub struct DehydratedEntry { pub key: String, pub type_id: std::any::TypeId, pub kind: &'static str } +``` + +And on `QueryClient`: + +| Method | Behavior | +|---|---| +| `dehydrate(&self, cx: &App) -> DehydratedState` | collects key + `TypeId` + kind of `Success` entries only (**no data**) | +| `hydrate(&mut self, _state, _cx)` | **no-op stub** — body is empty; callers must iterate and `set_query_data` themselves | +| `persist(&self, &dyn QueryPersister, cx)` | dehydrates + saves entries (metadata-only) | +| `restore(persister: &dyn QueryPersister) -> Vec` | associated fn (no `&self`); loads raw entries | + +Prefer the **value-carrying** `Persister` + `persist_with` + free-fn `hydrate` for any new code — it round-trips real data through the serializer/deserializer registries. The legacy types exist only to avoid breaking the old `dehydrate`/`hydrate`/`persist`/`restore` surface. + +--- + +## Gotchas + +- **Strict deserializers are load-bearing.** `hydrate` offers every on-disk entry to every registered deserializer (O(n × m), no type discriminator). A permissive decoder that accepts a foreign shape will mis-prime the wrong bucket. Return `None` for anything that isn't unambiguously your `T`. +- **TypeId-only registry overwrites.** `register_serializer::` then `register_serializer::` for the same `T` silently overwrites — both are keyed on `TypeId::of::()`. Last write wins, and it applies to both `(T, E)` buckets. This is correct (the value *is* that `T`) but surprises people expecting per-`(T, E)` keying. +- **Non-object-safe traits.** `Persister` and `HttpBackend` both return `impl Future + Send` and are consumed generically (`persist_with`, `HttpCache`). You cannot `Box` or `Box` — use an `enum` of backends or generic plumbing instead. +- **Mutex guards never cross `.await`.** Both `HttpCache` and `FilePersister` acquire a `std::sync::Mutex`, clone the value out, and drop the guard before yielding. If you write your own `Persister`/`HttpBackend`, do the same — holding a `std` mutex across `.await` is undefined behavior (the future is `Send` but the guard often is not) and trips on some runtimes. +- **Only `200`s are cached.** A `304` re-serves a *prior* `200` body; a `304` with no cached body is `Err(NotModifiedWithoutCachedBody)`. `no-store`/`no-cache` and non-`200`/`304` statuses store nothing. +- **`no-store` short-circuits parsing.** `no-store, max-age=abc` returns `Ok(NoCache)` — the malformed `max-age` is never reached. Only a malformed TTL *without* a preceding `no-store`/`no-cache` yields `InvalidMaxAge`. +- **macOS Metal Toolchain.** Building `client`/`hook`/`persist` (so, the persist feature and `gpui-query-persist`) fails on macOS without it: run `xcodebuild -downloadComponent MetalToolchain` once. `gpui-query-http` (core-only) is unaffected. +- **`PersistHandle` drop stops *new* saves.** A save already parked on its debounce timer is detached and may still complete once after you drop the handle. Keep the handle for the app lifetime (store it on a long-lived view/entity) if you want continuous persistence. +- **`Debounced saves use GPUI's timer.** In tests, the mock clock does not advance on `run_until_parked`; use `cx.background_executor().advance_clock(debounce + ε)` to mature the timer, or set `debounce: Duration::ZERO` for immediate saves. + +--- + +## Pointers + +- Persistence guide: `https://gpui-query.freeoxide.com/docs/guides/persistence` +- HTTP caching guide: `https://gpui-query.freeoxide.com/docs/guides/http-caching` +- Cache policies deep-dive: `https://gpui-query.freeoxide.com/docs/blog/cache-policies-explained` +- Crate docs: `https://docs.rs/gpui-query`, `https://docs.rs/gpui-query-http`, `https://docs.rs/gpui-query-persist` +- RFC 9111 (Cache-Control): `https://www.rfc-editor.org/rfc/rfc9111` diff --git a/skills/gpui-query/SKILL.md b/skills/gpui-query/SKILL.md new file mode 100644 index 0000000..6d9db1e --- /dev/null +++ b/skills/gpui-query/SKILL.md @@ -0,0 +1,446 @@ +--- +name: gpui-query +description: Use when building a GPUI app that depends on gpui-query (v0.2.0) — writing use_query / use_mutation / use_infinite_query / use_query_select hooks; configuring in-memory CachePolicy (NoCache/Ttl/StaleWhileRevalidate) or RetryPolicy; constructing QueryKey / QueryKeyFilter; calling QueryClient for fetch_query / prefetch / set_query_data / invalidate_queries / cancel_queries / reset_queries / remove_queries; wiring cx.set_global(QueryClient::new()); or debugging observer re-render / stale-write / GC behavior. Do NOT use for general GPUI app work that does not involve gpui-query, for the HTTP-cache/disk-persistence satellites (use the gpui-query-extensions skill), or for editing the gpui-query crate itself (see AGENTS.md for crate-internal work). +--- + +# gpui-query (essential) + +Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Crate: `gpui-query` v0.2.0. This skill covers the in-memory core/client/hook tiers (persistence and HTTP-cache satellites are separate). + +## Install + +Three strictly-additive tiers, glob re-exported at the crate root (`pub use core::*; pub use client::*; pub use hook::*;`) — import everything from `gpui_query::`. + +| Tier | Cargo line | What you get | +|---|---|---| +| core only (no GPUI) | `gpui-query = { version = "0.2.0", default-features = false, features = ["core"] }` | `QueryResource` state machine, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`. Zero GPUI dep — usable in non-GPUI libs. | +| client (DEFAULT) | `gpui-query = "0.2.0"` | + `QueryClient` GPUI `Global`: type-partitioned buckets, GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`. | +| hooks | `gpui-query = { version = "0.2.0", features = ["hook"] }` | + `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`. | + +The `client` tier pulls `gpui = "0.2.2"`. macOS builds of any tier with GPUI need the Metal Toolchain installed once: `xcodebuild -downloadComponent MetalToolchain` (core-only builds need nothing). + +## Mental model + +**Two-phase fetch lifecycle.** Every fetch is gated by a monotonic `RequestId` minted per-resource by a `RequestSequencer` co-located in the bucket. The lifecycle: + +1. `begin_request` → `QueryBeginResult` (`Started { request_id, .. }` | `CacheHit` | `StaleCacheHit { request_id, .. }` | `IgnoredWhileLoading`). Transitions status to `LoadingEmpty`/`LoadingWithData` and installs a fresh `QuerySignal`. +2. Caller fetches async (cooperative: poll `signal.is_cancelled()` to abort early). +3. `accept_current_request(request_id) -> Option` — `Some` ONLY if `request_id` is still the active one. A superseded fetch gets `None`. +4. `complete_success(guard, data, now_ms)` / `complete_failure(guard, error, now_ms)` consume the single-use `RequestGuard` by value. + +**`RequestGuard` is the authoritative stale-write protection.** A cancelled or superseded async task can `accept` → `None` and its result is silently discarded. Do NOT rely on a `signal.is_cancelled()` check after the fetch returns (TOCTOU window) — the guard is what guarantees correctness. The hooks wrap all of this for you; you only touch `accept`/`complete` directly via `fetch_query_with_signal` or `PreparedFetch`. + +**Status states** (`QueryStatus`, `#[derive(Default)]` so `Idle` is the start): + +``` +Idle → LoadingEmpty → Success | Failure +Success → LoadingWithData → Success | Failure (refetch) +``` + +- `is_loading()` = `LoadingEmpty | LoadingWithData` +- `is_pending()` = `Idle | LoadingEmpty` (TanStack `isPending` parity) +- Plus `Cancelled` (explicit cancel). + +Plain-query fetch tasks are `.detach()`ed — the signal + guard already prevent stale writes, and the task self-terminates when its `WeakEntity` target is dropped. Mutations and infinite queries store the task via `set_current_task`, so a replacement call or component unmount HARD-aborts the prior task. + +## Quick start + +```rust +use gpui_query::{use_query, QueryClient}; + +// Once, at app startup. In debug builds the `use_query` family panics without +// this (`use_infinite_query` / `use_mutation` fall back silently); release +// builds always fall back to standalone entities (no shared cache, no GC, no +// bulk ops). Always set it. +cx.set_global(QueryClient::new()); + +struct UserList { + users: gpui::Entity, MyError>>, + _subscription: gpui::Subscription, // store both — see Observers +} + +impl UserList { + fn new(cx: &mut gpui::Context) -> Self { + let (users, sub) = use_query( + "users", // &str: Into + |signal| async move { + if signal.is_cancelled() { return Err(MyError::Cancelled); } + Ok(fetch_users().await?) + }, + cx, + ); + Self { users, _subscription: sub } + } +} +``` + +Every hook returns `(Entity, Subscription)` — both must be stored. Dropping the `Subscription` kills the observation and the component stops re-rendering on state changes. + +## use_query / use_query_with_policy + +```rust +pub fn use_query( + options: impl Into, + fetcher: F, + cx: &mut Context, +) -> (Entity>, Subscription) +where + T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + std::fmt::Debug + 'static, + C: 'static, + F: Fn(QuerySignal) -> Fut + Send + 'static, + Fut: Future> + Send + 'static; +``` + +`use_query_with_policy` is identical except `Fut: Future, E>>` — the fetcher returns a `Fetched` so a server-derived `CachePolicy` overrides the resource's on success (**"server wins"**): + +```rust +use gpui_query::{use_query_with_policy, QueryOptions, Fetched, CachePolicy}; + +let (entity, _sub) = use_query_with_policy( + QueryOptions::new("user/42"), + |signal| async move { + let (user, cc) = fetch_user_with_cache_control().await?; + // Fetched::with_policy overrides the resource's policy with the + // server's (e.g. parsed from Cache-Control). Fetched::new(user) + // keeps the caller's policy unchanged. + Ok(Fetched::with_policy(user, cc)) + }, + cx, +); +``` + +`Fetched::new(data)` keeps the caller's policy; `Fetched::with_policy(data, policy)` replaces it after `complete_success`, so subsequent freshness/SWR checks use the server's TTL. + +`QueryOptions` builder (also `From<&str>` / `From` / `From` / `From<(QueryKey, CachePolicy, RequestPolicy)>`): + +| Builder | Effect | +|---|---| +| `.cache_policy(p)` | Per-query cache policy (default `Ttl { ttl_ms: 60_000 }`). | +| `.request_policy(p)` | `LatestWins` (default) or `IgnoreWhileLoading`. | +| `.retry_policy(p)` | Per-query retry (default 3 + exponential). Installed onto the entity — without a hook the resource starts at `RetryPolicy::no_retries()`. | +| `.force()` | `force_fetch = true` → bypass cache freshness, always fetch. | +| `.gc_time(ms)` / `.keep_previous()` | **Reserved / forward-compat.** Stored, NOT consumed today (GC runs off `QueryClient::with_gc_time`). | + +**Imperative refetch** on an existing entity (button click, timer, after invalidation): `fetch_query(&entity, || async { ... }, cx)`. Variants: `fetch_query_with_policy` (server-wins) and `fetch_query_with_signal` (`FnOnce(QuerySignal) -> Fut`; **no retries** because `FnOnce` is consumed on first call). + +## use_mutation + +```rust +pub fn use_mutation( + options: impl Into, // use_mutation((), cx) works via From<()> + cx: &mut Context, +) -> (Entity>, Subscription) +where V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + 'static, C: 'static; +``` + +`MutationResource`: `V` = variables (input), `T` = success output, `E` = error (defaults to `QueryError`). Status: `Idle` → `Loading` → `Success` | `Failure`. + +Trigger with `mutate` (or `mutate_with_callbacks`): + +```rust +pub fn mutate( + entity: &Entity>, + variables: V, + mutator: F, // Fn(V) -> Fut + cx: &mut Context, +) where ...; + +pub fn mutate_with_callbacks( + entity: &Entity>, + variables: V, + mutator: F, + callbacks: MutationCallbacks, + cx: &mut Context, +); +``` + +`MutationCallbacks::new().on_success(|t: &T| ...).on_error(|e: &E| ...).on_settled(|opt_t: Option<&T>, opt_e: Option<&E>| ...)`. The closures are `Fn(&T)` / `Fn(&E)` / `Fn(Option<&T>, Option<&E>)` (borrowed, not owned) — they fire on the terminal outcome (after retries exhaust / first success), run outside any entity borrow, and are safe to call `entity.update()` inside. + +```rust +use gpui_query::hook::{use_mutation, mutate_with_callbacks, MutationCallbacks}; + +// In handle_submit: +mutate_with_callbacks( + &self.create_user, + NewUser { name }, + |vars| async move { api_create_user(vars).await }, + MutationCallbacks::new() + .on_success(|_user: &User| { /* navigate, etc. */ }) + .on_settled(|_, _| { /* always: hide spinner */ }), + cx, +); +``` + +Behavior: +- **Concurrent guard:** `mutate` does an atomic check+`begin` inside one `entity.update`. If already `Loading`, the call is a no-op (no second in-flight mutation on the same entity). +- **Drop safety net:** if the entity is dropped mid-mutation, `on_error` / `on_settled(None, None)` still fire so callers always get a terminal callback. +- **Hard-abort:** the task is stored via `set_current_task`; a replacement `mutate` or component unmount aborts the prior task. +- `mutate_by_ref` / `mutate_arc` — mutator takes `&V` (borrowed from a stored `Arc`), so the retry loop does **no `V::clone` per attempt**. `V: Clone` is still required (the initial `begin` stores one owned copy). +- `use_mutation_state::(cx)` returns all registered mutation entities of that type triple (for devtools / batch views). + +**Optimistic update pattern** — write to the cache before the mutation resolves, then invalidate/refetch on settle: + +```rust +// Before mutate: prime the cache so the UI updates instantly. +cx.update_global::(|c, cx| { + c.set_query_data::, MyError>( + "users", + vec![User { name: name.clone(), ..Default::default() }], // optimistic + cx, + ); +}); +// On settle: invalidate_queries so the real fetcher overwrites it. +``` + +`set_query_data` saves the previous data; the resource exposes `rollback_to_previous()` if you need to undo on failure. + +## use_infinite_query + +```rust +pub fn use_infinite_query( + options: InfiniteQueryOptions, // concrete (not impl Into) + fetch_next: FNext, + cx: &mut Context, +) -> (Entity>, Subscription) +where + T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + std::fmt::Debug + 'static, + C: 'static, + FNext: Fn(Option<&T>) -> Fut + 'static, // receives the last page (or None) + Fut: Future> + Send + 'static; // (page, has_more) +``` + +The fetcher returns `(page_data, has_more)`. `Option<&T>` is the previously-loaded last page so the fetcher can derive a cursor. Drive pagination imperatively: + +```rust +use gpui_query::{use_infinite_query, fetch_next_page_infinite, InfiniteQueryOptions}; + +let (feed, _sub) = use_infinite_query( + InfiniteQueryOptions::new("feed").max_pages(20), + |last: Option<&Vec>| async move { + let cursor = last.and_then(|p| p.last().map(|x| x.id)).unwrap_or(0); + let (posts, more) = fetch_page(cursor).await?; + Ok((posts, more)) + }, + cx, +); + +// On scroll-to-bottom: +fetch_next_page_infinite(&self.feed, |last| async move { /* same shape */ }, cx); +// Backward: fetch_previous_page_infinite(&entity, |first| async move { ... }, cx); +``` + +`InfiniteQueryOptions`: `.max_pages(n)` (default `Some(50)`, bounded to prevent unbounded growth), `.unbounded_pages()` (`None` — never evict; use with caution), plus `.cache_policy()` / `.retry_policy()` / `.gc_time()` builders. Construct via `InfiniteQueryOptions::new(key)` or `InfiniteQueryOptions::from("feed")`. + +`InfiniteQueryResource` accessors: + +| Method | Returns | +|---|---| +| `pages()` | `&VecDeque>` — all loaded pages, first→last. | +| `page_count()` / `has_data()` | page count / any loaded. | +| `first_page()` / `last_page()` | `Option<&T>` borrowed views. | +| `first_page_arc()` / `last_page_arc()` | `Option>` — cheap refcount bump to hand to a fetcher. | +| `has_next_page()` / `has_previous_page()` | more pages available in either direction. | +| `is_fetching_next_page()` / `is_fetching_previous_page()` | a page fetch is in flight. | +| `is_page_data_valid()` | `true` on `Success`/`LoadingWithData`; on `Failure` returns `true` if pages exist (the failure is scoped to the last page fetch — prior pages stay valid). | + +`FetchDirection` (set at construction, not by the hook options): `ForwardOnly` (default — `has_next_page` starts `true` as an assumption, fetcher's `has_more` drives it false) vs `Bidirectional` (both start `false`; use `InfiniteQueryResource::new_bidirectional`). The default hook uses `ForwardOnly`. + +## use_query_select + +Project cached data through a `SelectTransform` — multiple derived views over one cache entry, no duplication. + +```rust +pub type QuerySelectResult = ( + Entity>, + Entity>, + (Subscription, Subscription), // query sub + mapped observer sub +); + +pub fn use_query_select( + options: impl Into, + transform: SelectTransform, // SelectTransform::new(|t: &T| -> U) + fetcher: F, + cx: &mut Context, +) -> QuerySelectResult +where T: Clone + PartialEq + Send + Sync + 'static, U: 'static, /* ... */; +``` + +```rust +use gpui_query::{use_query_select, QueryOptions, core::SelectTransform}; + +let count = SelectTransform::new(|users: &Vec| users.len()); +let (mapped, query_entity, subs) = use_query_select( + QueryOptions::new("users"), + count, + |signal| async move { Ok(fetch_users().await?) }, + cx, +); +// mapped.read(cx).data() -> Option +``` + +Store **both** subscriptions (`(query_sub, mapped_sub)`) or the projection stops updating. The transform closure runs on every `MappedQueryResource::data()` call (no output cache) — for expensive transforms, bind `let data = mapped.read(cx).data();` once per render and reuse. The mapped entity re-syncs from the source only when the source `T` actually changed (`PartialEq`), so unchanged notifications pay just an `Arc::clone`. + +## CachePolicy + +`#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]`. Default (via `QueryOptions` and `QueryClient`): `Ttl { ttl_ms: 60_000 }`. + +| Variant | Behavior | Freshness | +|---|---|---| +| `NoCache` | Always fetch; never short-circuits. | `is_fresh` always false; `is_expired` always true. | +| `Ttl { ttl_ms }` | Serve fresh within TTL. | `is_fresh(age)` when `age <= ttl_ms` — **INCLUSIVE boundary** (opposite of HTTP `max-age`; mind the off-by-one). | +| `StaleWhileRevalidate { ttl_ms, stale_ms }` | `[0, ttl]` fresh; `[ttl, ttl+stale]` serve stale + background revalidate; past `ttl+stale` expired. | `is_stale_but_serveable(age)` in the stale window. | + +Helper methods: `can_short_circuit()` (Ttl/SWR), `can_serve_stale()` (SWR only), `is_fresh(age)`, `is_stale_but_serveable(age)`, `is_expired(age)`, `ttl_ms()`, `total_valid_ms()` (Ttl → `ttl_ms`; SWR → `ttl_ms + stale_ms` saturating). `ttl_ms == 0` behaves like `NoCache` (only `debug_assert`s in debug builds). + +## RetryPolicy + +All four fields public; builder pattern. + +```rust +pub struct RetryPolicy { + pub max_retries: u32, // 0 = no retries + pub retry_delay_ms: u64, // base delay + pub exponential_backoff: bool, + pub max_retry_delay_ms: u64, // configured cap +} +``` + +| Constructor / builder | Result | +|---|---| +| `RetryPolicy::no_retries()` | `max_retries: 0`. (This is what a bare `QueryResource::new` starts with — the hook installs the real policy.) | +| `RetryPolicy::new(n)` | `max_retries: n`, base 1000ms, no exponential, cap 30_000ms. | +| `RetryPolicy::default()` | `new(3).with_exponential_backoff()` — **3 retries, exponential, 1s base, 30s cap.** | +| `.with_delay(ms)` / `.with_exponential_backoff()` / `.with_max_delay(ms)` | mutators. | + +`delay_for_attempt(attempt)` (0-based): + +- exponential off → constant `retry_delay_ms`. +- exponential on → `retry_delay_ms * 2^attempt`, where the shift is clamped to 62 (prevents overflow) and the multiply is saturating; then `.min(max_retry_delay_ms).min(3_600_000)` — a **hard 1-hour absolute ceiling** regardless of config. + +`should_retry(current_retries)` = `current_retries < max_retries`. Mutations and queries reset `retry_count` on a new `begin`, so each invocation gets fresh retries. + +## QueryKey + QueryKeyFilter + +`QueryKey` — hierarchical key backed by `Arc<[Arc]>` (clone = one refcount bump regardless of length). Serde-flexible (deserializes from a JSON string array OR a bare string). + +```rust +// Construction +QueryKey::from("users") // From<&str> -> single segment +QueryKey::from(["users", "42", "posts"]) // From<[&str; N]> -> multi-segment +QueryKey::from_single("users") +QueryKey::new(["users", &id.to_string()]) // PANICS on an empty iterator — guard emptiness +``` + +Accessors: `parts() -> &[Arc]`, `as_single() -> Option<&str>` (only if exactly one segment), `first_segment() -> &str` (first only — NOT the joined key), `to_path() -> String` (`"::"`-joined, e.g. `"users::42::posts"`), `join(extra) -> QueryKey` (O(n) copy — prefer one `new([...])` over chained `.join()`). `starts_with(prefix)` is **segment-wise** (used by `Prefix` filters); an empty prefix matches every valid key. + +`QueryKeyFilter<'a>` (used by every bulk op on `QueryClient`): + +| Variant | Matches | +|---|---| +| `Exact(&QueryKey)` | only that exact key. | +| `Prefix(&QueryKey)` | all keys that `starts_with` it (segment-wise — `["users"]` matches `["users", "42", "posts"]`). | +| `All` | every key. | + +## QueryClient API + +A GPUI `Global`. Set once: `cx.set_global(QueryClient::new())`. Access from anywhere: `cx.global::()` / `cx.update_global::(|c, cx| ...)`. + +**Construction:** +- `QueryClient::new()` — defaults (`Ttl 60s`, `LatestWins`, `gc_time_ms: 300_000`). +- `QueryClient::with_policies(cache, request)` — custom default policies for resources created via `resource()` (per-query `QueryOptions` still overrides). +- `.with_gc_time(ms)` — `0` disables **opportunistic** (automatic) GC only; an explicit `gc(cx)` / `gc_with_time(now, cx)` still runs (clamping any value `< 1000` to `1000` during the pass). + +**Resource access** (type-partitioned by `(T, E)`): +```rust +client.resource::, MyError>("users", cx) // get-or-create with default policies +client.resource_with_policies::(key, cache, request, cx) // explicit policies +client.query::(&key) // Option>> +client.all_queries::() // Vec> (allocates) +``` + +**Data accessors** (ergonomic cache reads/writes; type params required): +```rust +client.get_query_data::, MyError>(&"users", cx) // Option> (clones T) +client.with_query_data::, MyError, _>(&"users", cx, |u: &Vec| u.len()) // Option, zero-clone +client.set_query_data::, MyError>("users", data, cx) // creates resource if absent; saves previous for rollback +``` + +**Bulk operations** (take `&QueryKeyFilter`, operate across all query + infinite buckets): +```rust +client.invalidate_queries(&filter, cx) // clears last_updated_at; DATA RETAINED but now stale -> next read refetches +client.reset_queries(&filter, cx) // back to Idle, clears data/error +client.remove_queries(&filter) // drops entries entirely (no cx needed) +client.cancel_queries(&filter, cx) // cooperative-cancel in-flight (signal.cancel() + mark_ignored_result) +``` +`invalidate_queries` is the workhorse after a mutation: it marks matching keys stale so the next `use_query` mount or read refetches, without dropping the cached value (avoids a loading flash). + +**GC and diagnostics:** +- `client.gc(cx)` / `gc_with_time(now_ms, cx)` — evicts dead refs; `Idle`/`Failure`/`Cancelled` older than `gc_time_ms`; `Success` older than `gc_time_ms * SUCCESS_GC_MULTIPLIER`; loading resources always retained. GC also fires opportunistically every `GC_INTERVAL` operations (debounced by `MIN_GC_TIME_MS`), so you rarely call it manually. +- `client.diagnostics(cx) -> ClientDiagnostic` — per-resource key/status/cache-age for devtools. + +**Imperative fetch / prefetch** (no observer attached — for warming the cache outside a component): +```rust +// fetchQuery: forced, always returns a PreparedFetch (None only if no sequencer). +if let Some(p) = client.prepare_fetch_query::("user/42", cx) { + let signal = p.signal.clone(); + let entity = p.entity.clone(); + cx.spawn(async move |_, cx| { + match fetch(signal).await { + Ok(data) => p.complete_success(data, cx), // no-op if superseded + Err(e) => p.complete_failure(e, cx), + } + }).detach(); +} + +// prefetchQuery: respects cache_policy — returns None on a fresh cache hit. +if let Some(p) = client.prepare_prefetch_query::(key, cache, request, cx) { /* ... */ } +``` + +`PreparedFetch` is `#[must_use]` — it holds the entity, `request_id`, `signal`, and the captured `now_ms` (used as the logical completion time). `complete_success(data, cx)` / `complete_failure(err, cx)` consume it by value and are no-ops if the request was superseded. + +### Manual `QueryResource` / `MutationResource` control + +Beyond hooks, the entity itself exposes (call inside `entity.update(cx, |r, cx| …)`): + +- `cancel(error: E) -> bool` — Loading → Cancelled, stashes current data into `previous_data`. **No-op (returns `false`) unless currently Loading.** `MutationResource::cancel(error: E)` has the same shape — cancel takes an `E`, not unit. +- `reset()` — back to Idle; clears data / error / diagnostic counters, preserves policies and key. +- `set_data(data)` / `clear_data()` — optimistic primitives (write or clear without a fetch); `rollback_to_previous()` undoes a `set_query_data` / `set_data`. +- `is_data_stale(now_ms)` — staleness heuristic; `complete_current_success(request_id, data, now_ms)` / `complete_current_failure(request_id, err, now_ms)` do accept + complete in one call for manual flows. + +## Observers & re-render semantics + +A single generic `Observer` with aliases `QueryObserver`, `InfiniteQueryObserver`, `MutationObserver`. The hooks attach one automatically; manual use: + +```rust +let sub = QueryObserver::new(&entity) + .with_config(ObserverConfig { notify_on_status_change_only: true }) // default + .observe(cx)?; // Option — None if entity already dropped +``` + +**Re-renders fire ONLY when `observable_status()` changes.** `increment_retry()` / `prepare_retry()` (status stays `Loading`) and `set_current_task` do NOT re-render — this is why a 3-retry mutation doesn't paint 3 times. Set `notify_on_status_change_only: false` to get a notify on every entity mutation. + +**Subscription retention is mandatory.** A hook's returned `Subscription` must outlive the component's interest; dropping it detaches the observer and the component stops reacting. `use_query_select` returns **two** subscriptions (query + mapped observer) — store both, usually as a tuple field. + +## Gotchas + +- **Inclusive TTL boundary.** `is_fresh(age)` is `age <= ttl_ms` (not `<`) — opposite of HTTP `max-age`. A `Ttl { ttl_ms: 1000 }` is still fresh at exactly 1000ms. Watch for off-by-one when translating server `Cache-Control`. +- **Empty-key panic.** `QueryKey::new(iter::empty())` panics unconditionally in all build modes. Use `from_single` / `From<&str>` / guard the iterator at the call site. +- **`QueryClient` required in debug.** `use_query_manual` — and the `use_query` / `use_query_with_policy` / `use_query_unsignalled` / `use_query_manual_opts` hooks that delegate to it — **panics** in debug builds if no global `QueryClient` is set; release silently falls back to a standalone entity. `use_infinite_query` and `use_mutation` do NOT panic: in debug `use_infinite_query` prints an `eprintln!` warning and falls back to a standalone entity, and `use_mutation` silently skips client registration (so no shared cache, no GC, no bulk ops, no `use_mutation_state`). Always `cx.set_global(QueryClient::new())` at startup. +- **Subscription drop kills observation.** Every hook returns `(Entity, Subscription)`; `use_query_select` returns a 3-tuple with two subs. Bind them as struct fields (`_subscription`), not temporaries, or the component freezes after first render. +- **`WeakEntity` silent discard.** Async tasks capture `entity.downgrade()`. If the owning component unmounts mid-fetch, `weak.upgrade()` returns `None` and the result is silently discarded — no callback fires. Mutations are the exception: `on_settled(None, None)` / `on_error` still fire as a safety net. For completion guarantees on queries, use `fetch_query_with_signal` with your own handling. +- **`QueryResource::new` starts with `no_retries()`.** The `use_query` hook installs the real `RetryPolicy` from options; if you construct resources directly (or call `fetch_query` on a manually-made entity), set it yourself via `entity.update(cx, |r, _| r.set_retry_policy(p))`. +- **Mutation GC actually runs.** `QueryClient`'s explicit `Default` sets `gc_time_ms: 300_000` (the derived `Default` would have been `0` = disabled). `with_gc_time(0)` still disables; any value `< 1000` is clamped to `1000` during the pass. Mutations are GC-eligible by completion time, not insertion time. +- **`mutate` is a no-op when Loading.** The check+`begin` is atomic inside one `entity.update`; a racing second call cannot slip through. If you need to replace an in-flight mutation, cancel or reset first. +- **`max_pages` default is bounded.** `InfiniteQueryOptions` defaults to `Some(50)`. `ForwardOnly` (default direction) starts `has_next_page = true` as an assumption — the fetcher's `has_more` is what flips it false. +- **`MappedQueryResource::data()` re-runs the transform every call.** No output cache. Bind the result to a local once per render. +- **`cancel_queries` is two-step per match.** It reads `is_loading()` before mutating so non-loading entries don't get spurious observer notifications. +- **macOS Metal Toolchain.** Any tier pulling `gpui` (`client`/`hook`/`persist`) fails to build without it. `xcodebuild -downloadComponent MetalToolchain` once. Core-only builds are unaffected. + +## Pointers + +- Docs site: https://gpui-query.freeoxide.com/docs/ — guides under `/docs/guides/` (`caching`, `retry`, `query-keys`, `select-pattern`, `error-handling`); API reference under `/docs/api/`. +- Public API surface: everything is glob re-exported at the crate root. The `core::`, `client::`, and `hook::` modules are also public if you need a fully-qualified path (e.g. `gpui_query::core::SelectTransform`, `gpui_query::client::PreparedFetch`). +- Type parameters are load-bearing: `QueryResource`, `MutationResource` (E defaults to `QueryError`), `InfiniteQueryResource`. `(T, E)` is the bucket partition key — the same key under two different type pairs is two separate cache entries. diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 34b0667..c60ff42 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -108,6 +108,7 @@ export default defineConfig({ { label: "HTTP cache headers", link: "/docs/guides/http-caching" }, { label: "Query Keys", link: "/docs/guides/query-keys" }, { label: "The Select Pattern", link: "/docs/guides/select-pattern" }, + { label: "Claude Code skills", link: "/docs/guides/claude-skills" }, ], }, { diff --git a/web/package.json b/web/package.json index 01ee985..9a6d3a8 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ }, "scripts": { "dev": "astro dev --port 3000", - "build": "rm -rf dist && node scripts/generate-og-images.ts && astro build && npx pagefind --site dist/client && node scripts/generate-llms-txt.ts --output dist/client && node scripts/generate-md-alt.ts --output dist/client", + "build": "rm -rf dist && node scripts/generate-og-images.ts && astro build && npx pagefind --site dist/client && node scripts/generate-llms-txt.ts --output dist/client && node scripts/generate-page-alts.ts --output dist/client", "preview": "astro preview", "og": "node scripts/generate-og-images.ts", "deploy": "bun run build && npx wrangler pages deploy dist/client --project-name=gpui-query" diff --git a/web/scripts/generate-llms-txt.ts b/web/scripts/generate-llms-txt.ts index 4fd9de9..5882cd6 100644 --- a/web/scripts/generate-llms-txt.ts +++ b/web/scripts/generate-llms-txt.ts @@ -3,77 +3,60 @@ * Generate llms.txt and llms-full.txt for AI agent optimization. * Follows the llmstxt.org specification. * - * Source: Starlight docs at src/content/docs/docs (all .md and .mdx files). + * Source: the doc collection (src/content/docs/docs) via lib/pages.ts, which is + * the same loader the rendered Starlight pages and the per-page `.md`/`.txt` + * alternates use. Site-wide constants live once in lib/site.ts. * - * Usage: node scripts/generate-llms-txt.ts [--output .output/public] + * Usage: node scripts/generate-llms-txt.ts [--output dist/client] */ import { writeFile, mkdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import process from "node:process"; -import { loadDocs } from "./lib/docs-md.ts"; +import { loadDocIndex } from "./lib/pages.ts"; +import { + HEADER_LINES, + ALT_FORMAT_NOTE, + docIndexLines, + SITE_URL, + GITHUB_URL, +} from "./lib/site.ts"; const outputDir = process.argv.includes("--output") ? process.argv[process.argv.indexOf("--output") + 1] : "dist/client"; -// Scripts run from web/. Starlight docs live under src/content/docs/docs and -// are served at /docs/** (route "" -> /docs/). -const docsRoot = resolve("src", "content", "docs", "docs"); -const siteUrl = "https://gpui-query.freeoxide.com"; - -const HEADER = [ - "# gpui-query", - "", - "> Zero-boilerplate async state management for GPUI. Brings TanStack Query patterns to Rust and the Zed editor's GPUI framework with caching, retry, cooperative cancellation, and persistence.", - "", -]; - async function main(): Promise { - const parsed = await loadDocs(docsRoot); - if (parsed.length === 0) { + const docs = await loadDocIndex(); + if (docs.length === 0) { console.log("No docs found, skipping llms.txt generation"); return; } - const docs = parsed.map((doc) => ({ - route: doc.route, - title: doc.frontmatter.title || doc.route || "Home", - description: doc.frontmatter.description || "", - order: Number(doc.frontmatter.sidebar_position ?? 0), - plain: doc.plain, - })); - - // Stable ordering: by sidebar_position, then route. - docs.sort((a, b) => a.order - b.order || a.route.localeCompare(b.route)); - await mkdir(outputDir, { recursive: true }); // --- llms.txt (summary, llmstxt.org) --- - const llmsLines = [...HEADER, "## Documentation", ""]; - - for (const doc of docs) { - const url = `${siteUrl}/docs/${doc.route}`; - const desc = doc.description ? `: ${doc.description}` : ""; - // llmstxt.org: `- [Title](URL): Optional description` - llmsLines.push(`- [${doc.title}](${url})${desc}`); - } - - llmsLines.push( + const llmsLines = [ + ...HEADER_LINES, + "## Documentation", + "", + ...docIndexLines(docs), + "", + ALT_FORMAT_NOTE, "", "## Links", "", - "- [GitHub](https://github.com/freeoxide/gpui-query): Source code and issues", - "- [Introduction](https://gpui-query.freeoxide.com/docs/): What is gpui-query and why you need it", - ); + `- [GitHub](${GITHUB_URL}): Source code and issues`, + `- [Introduction](${SITE_URL}/docs/): What is gpui-query and why you need it`, + "", + ]; const llmsTxt = llmsLines.join("\n"); await writeFile(join(outputDir, "llms.txt"), llmsTxt, "utf-8"); console.log(`Generated llms.txt (${docs.length} docs, ${llmsTxt.split("\n").length} lines)`); // --- llms-full.txt (concatenated content) --- - const fullLines = [...HEADER]; - + const fullLines = [...HEADER_LINES]; for (const doc of docs) { fullLines.push(`## ${doc.title}`, ""); if (doc.description) { diff --git a/web/scripts/generate-md-alt.ts b/web/scripts/generate-md-alt.ts deleted file mode 100644 index c17cfed..0000000 --- a/web/scripts/generate-md-alt.ts +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -/** - * Generate clean .md alternatives for documentation pages. - * AI crawlers can fetch these directly for plain markdown content. - * - * Source: Starlight docs at src/content/docs/docs (all .md and .mdx files). - * Output: {output}/docs/{route}.md (route "" -> docs/index.md, served at /docs/) - * - * Usage: node scripts/generate-md-alt.ts [--output .output/public] - */ - -import { writeFile, mkdir } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import process from "node:process"; -import { loadDocs } from "./lib/docs-md.ts"; - -const outputDir = process.argv.includes("--output") - ? process.argv[process.argv.indexOf("--output") + 1] - : "dist/client"; - -// Scripts run from web/. Starlight docs live under src/content/docs/docs. -const docsRoot = resolve("src", "content", "docs", "docs"); - -async function main(): Promise { - const docs = await loadDocs(docsRoot); - if (docs.length === 0) { - console.log("No Docusaurus docs found, skipping .md generation"); - return; - } - - const docsOutDir = join(outputDir, "docs"); - await mkdir(docsOutDir, { recursive: true }); - - for (const doc of docs) { - // route may contain subdirectory segments (e.g. "guides/caching"). - const outFile = join(docsOutDir, `${doc.route || "index"}.md`); - await mkdir(dirname(outFile), { recursive: true }); - await writeFile(outFile, `${doc.plain}\n`, "utf-8"); - } - - console.log(`Generated ${docs.length} .md alternative files in ${docsOutDir}`); -} - -main().catch((err: unknown) => { - console.error("Failed to generate .md alternatives:", err); - process.exit(1); -}); diff --git a/web/scripts/generate-page-alts.ts b/web/scripts/generate-page-alts.ts new file mode 100644 index 0000000..dd3132c --- /dev/null +++ b/web/scripts/generate-page-alts.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Generate token-light `.md` and `.txt` alternates for every public page. + * + * For each page the rendered HTML serves at `/{route}` (or `/` for the root), + * this writes `/{route}.md` and `/{route}.txt` (root -> `/index.{md,txt}`) so an + * AI agent can append `.md` or `.txt` to any page URL and get a small copy + * instead of the full HTML. Content is sourced once via lib/pages.ts. + * + * Usage: node scripts/generate-page-alts.ts [--output dist/client] + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { loadAllPages } from "./lib/pages.ts"; +import { pageMarkdown, markdownToPlain } from "./lib/markdown.ts"; + +const outputDir = process.argv.includes("--output") + ? process.argv[process.argv.indexOf("--output") + 1] + : "dist/client"; + +async function main(): Promise { + const pages = await loadAllPages(); + + let mdCount = 0; + let txtCount = 0; + for (const page of pages) { + // route "" is the site root -> index.md / index.txt. + const base = page.route === "" ? "index" : page.route; + const mdPath = join(outputDir, `${base}.md`); + const txtPath = join(outputDir, `${base}.txt`); + await mkdir(dirname(mdPath), { recursive: true }); + + const md = pageMarkdown(page); + await writeFile(mdPath, md, "utf-8"); + mdCount++; + await writeFile(txtPath, markdownToPlain(md), "utf-8"); + txtCount++; + } + + console.log( + `Generated ${mdCount} .md and ${txtCount} .txt alternative files in ${outputDir}`, + ); +} + +main().catch((err: unknown) => { + console.error("Failed to generate page alternates:", err); + process.exit(1); +}); diff --git a/web/scripts/lib/docs-md.ts b/web/scripts/lib/docs-md.ts index d3a9692..bad13d0 100644 --- a/web/scripts/lib/docs-md.ts +++ b/web/scripts/lib/docs-md.ts @@ -1,7 +1,7 @@ /** * Shared helpers for scripts that read the Starlight docs * (src/content/docs/docs) and turn them into plain markdown: - * generate-llms-txt.ts and generate-md-alt.ts. + * generate-llms-txt.ts and generate-page-alts.ts (via lib/pages.ts). */ import { readdir, readFile } from "node:fs/promises"; @@ -21,6 +21,19 @@ export interface ParsedDoc { plain: string; } +/** + * Unified page shape for the alt-format generators (`.md` / `.txt`). + * `route` is the output slug with no leading/trailing slash; "" means the site + * root (written as index.md / index.txt). + */ +export interface ParsedPage { + route: string; + title: string; + description?: string; + /** Prose markdown body (no frontmatter, no H1). */ + markdown: string; +} + /** Recursively collect every .md and .mdx file under `dir`, as absolute paths. */ export async function collectDocs(dir: string): Promise { let entries; @@ -95,55 +108,93 @@ export function resolveRoute(relPath: string, frontmatter: DocFrontmatter): stri * Strip frontmatter and Docusaurus/MDX-specific syntax down to prose * markdown. Admonitions become blockquotes; JSX components and imports * are removed. + * + * Code is sacred: fenced blocks and inline code spans are stashed behind + * sentinels before any JSX/expression stripping and restored verbatim, so Rust + * generics like `Arc` and `Result, MyError>` are not + * eaten by the JSX-tag remover. */ export function toPlainMarkdown(body: string): string { - return ( - body - // Drop ES import / export statements (MDX). - .replace(/^\s*import\s+.*$/gm, "") - .replace(/^\s*export\s+.*$/gm, "") - // Docusaurus admonitions :::type[title] {props} ... ::: - // -> keep inner content as a blockquote. - .replace( - /:::[A-Za-z]+(?:\[([^\]]*)\])?(?:\s*\{[^}]*\})?\r?\n([\s\S]*?):::/g, - (_m, title: string | undefined, inner: string) => { - const header = title ? `> **${title.trim()}**\n>\n` : ""; - const quoted = inner - .replace(/\r?\n$/, "") - .split(/\r?\n/) - .map((line) => `> ${line}`) - .join("\n"); - return `${header}${quoted}`; - }, - ) - // Any stray admonition fences (opening/closing) without a body. - .replace(/:::[A-Za-z]+(?:\[([^\]]*)\])?(?:\s*\{[^}]*\})?\s*(\r?\n)?/g, "") - .replace(/^:::\s*$/gm, "") - // JSX self-closing and paired tags -> remove (children kept for paired). - .replace(/<[A-Z][A-Za-z0-9]*[^>]*\/>/g, "") - .replace(/<\/?[A-Z][A-Za-z0-9]*[^>]*>/g, "") - // Remove remaining inline JSX expressions like {variable} (best effort). - .replace(/^\s*\{[^}]*\}\s*$/gm, "") - // Collapse 3+ blank lines. - .replace(/\n{3,}/g, "\n\n") - .trim() - ); + const store: string[] = []; + const stash = (m: string): string => { + store.push(m); + return `${store.length - 1}`; + }; + + // Protect fenced code blocks first (greedy, multiline), then inline code. + let s = body.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, stash); + s = s.replace(/`[^`\n]+`/g, stash); + + s = s + // Drop ES import / export statements (MDX). + .replace(/^\s*import\s+.*$/gm, "") + .replace(/^\s*export\s+.*$/gm, "") + // Docusaurus admonitions :::type[title] {props} ... ::: + // -> keep inner content as a blockquote. + .replace( + /:::[A-Za-z]+(?:\[([^\]]*)\])?(?:\s*\{[^}]*\})?\r?\n([\s\S]*?):::/g, + (_m, title: string | undefined, inner: string) => { + const header = title ? `> **${title.trim()}**\n>\n` : ""; + const quoted = inner + .replace(/\r?\n$/, "") + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + return `${header}${quoted}`; + }, + ) + // Any stray admonition fences (opening/closing) without a body. + .replace(/:::[A-Za-z]+(?:\[([^\]]*)\])?(?:\s*\{[^}]*\})?\s*(\r?\n)?/g, "") + .replace(/^:::\s*$/gm, "") + // JSX self-closing and paired tags -> remove (children kept for paired). + .replace(/<[A-Z][A-Za-z0-9]*[^>]*\/>/g, "") + .replace(/<\/?[A-Z][A-Za-z0-9]*[^>]*>/g, "") + // Remove remaining inline JSX expressions like {variable} (best effort). + .replace(/^\s*\{[^}]*\}\s*$/gm, "") + // Collapse 3+ blank lines. + .replace(/\n{3,}/g, "\n\n") + .trim(); + + // Restore protected code verbatim. + return s.replace(/(\d+)/g, (_m, i: string) => store[Number(i)] ?? ""); } -/** Read and parse every doc under `docsRoot`. */ +/** + * Read and parse every doc under `docsRoot` using Starlight-style routing + * (index files collapse to their directory route; a `slug` frontmatter wins). + */ export async function loadDocs(docsRoot: string): Promise { - const files = await collectDocs(docsRoot); + return loadMarkdownDir(docsRoot, { routeFrom: resolveRoute }); +} + +/** + * Generic markdown/MDX directory loader shared by the docs and blog + * collections. `routeFrom` maps a file's path + frontmatter to its route; + * it defaults to the docs/Starlight convention but blog passes a plain + * filename-stem mapper (posts are served at /blog/{stem}). + */ +export async function loadMarkdownDir( + dir: string, + opts: { routeFrom?: (relPath: string, frontmatter: DocFrontmatter) => string } = {}, +): Promise { + const routeFrom = opts.routeFrom ?? resolveRoute; + const files = await collectDocs(dir); const docs: ParsedDoc[] = []; for (const file of files) { const raw = await readFile(file, "utf-8"); const { frontmatter, body } = parseFrontmatter(raw); - const relPath = relative(docsRoot, file); + const relPath = relative(dir, file); docs.push({ relPath, - route: resolveRoute(relPath, frontmatter), + route: routeFrom(relPath, frontmatter), frontmatter, plain: toPlainMarkdown(body), }); } return docs; } + +/** Filename-stem route mapper for the blog collection (`foo.mdx` -> `foo`). */ +export function blogRoute(relPath: string): string { + return relPath.replace(/\.(mdx?|md)$/, "").split(sep).join("/"); +} diff --git a/web/scripts/lib/markdown.ts b/web/scripts/lib/markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..0781334c4c73f5e31dd17e60bfa19b352a60409d GIT binary patch literal 4924 zcmaJ_+fo}z5}kSFE7BSwwp-`|}6V98Jg+5)Yg%kDXUW&U6Y?_Qn~L= z3OiSxrrKz$d|_P=2l?z71YG9vYnGLc^k;3asZ>*qqgHFWQrB)@)L3m&&1zqry*cTS zqH(U3pH4p~zq|2l80v9DNe-k62U2^x)ePy{PVWu2zX$IxO@Xd3qX7eAy3*0QG6 z!CZL{lT-2q*~*yAOsULOX2|O=p8ogO-@(I?EAk3X_~KF0NNVm zwIg_tebzLw#hePGS69JqDDRLfXj~Xv>M3(%I3AQ%WuA?3KIN$Ox3=_`vak?3SM+QJnaf+x=D`6ve%B)R`^Ytjk;FjMTk+T(;! zA%?1)1JunkL}|_b4r@c6kO3fvZSK(@x(vZE?4io@YZ5F(rl5a6d2_PvP0XpBXC|9x zUnSX+GPg{n#E#u#^@V||x%MhmUa@I{BCr{~3Z&EU;%$SW3FPplE7|;8w zB*jgHBwI+g^Td8SI;8XAFSLEb2YO=4V%VWpYrt8|CM24AYvG8)!CqOp*}Y9Z8S_a| zz9!QEya&+DqgZ6DM%AF4wr_&;e~ZjSt;DqAMPh~rTb#>2eE=|FX-SQ%4zD#qi= z0*++3%yTJ@efo)_(~l=HO)F(nhi%E_xJY$}RsGy-Otcv@Tja&~()E}TIDcL;P#^ME z8OI#rxD2V_swi#YG%IQ_E4AW_YCPjI<$Ib^>TQJ}KYRQ9#l?pgr)RHEUz{M=t)E&4 z_14pqx6l6A*!vlK4SZh)B3Od{8Rv`}|_=03xB@Y#i2E z$MBze{QE~}Il?aUU;*wHZmwx(iX6-pSLTw+^}L)Zm$^PtdD&+oW=%{qWr?8oDEi1G z?ZySnZ#B{_b~{DUjQ9eL!a03=R-b}yX3@;kRZ->QoCZa6^;Jt%3?3Se^fWUUR{vRP z2exLDhrjJ!?C$Okdok>|UxW?L&x&_U+R+?g53Km35}cM|7w~&`n*h){(}GBmYH;beHWB5_yyO~y!imbAmXgKt7*vKEH}_Z}GKD^h z-snQr{8<%Vcdx7}OT;t!;FZm@>1?S1(UE&b)nv`SZ)B=G$EExSSSL0MR5Zy!CQrE< zu@}X3cqBYUO@#{AM9@XY$B01OTTz9CiU>vrwgH=L7S>|>rmpi`o2j2sm-euBM`&2< z;X4}hh!$ca*H~0%!)8E)%1^jQ6q7)6acwNZ^xPID8V-6v%8VcvOQ@ud5x|$P-+wsU zr+e{o6sdGxn2f31arB789wrdMrsPucDxCt26Tv6sqjf~X;V?(pw4@?M2H^KP^aYep zqYjP8VZtdDV$OKS(H1vb{(Lnn&=6|?um_ipwOe(yVHby&2H!M;wW*){kkVKHVC#-XaP5c#O&jrJcqSnhm05*j2daMD$h!y6xN~4g9Y4{ zmUl#O{@d;CZQSo+-0=|`7B?vgEvyFnYLV6u7uA_(*B+<}UWU&P)rEKjkPw=at}(#E zD=nA|rL=fe^7(oqKK`(pIwB&o5&MEHfZ2Re1m9WF9cv^Nj}AAJ!4(~T0;B_P@XDgz zK_1aq8w0~CNA_N{fWvoIgSIh7*6miZ*YtsdK&&;Icz}^%v$k?c5&^r77qIF$RP2T~ z$5K+XzX7aehVGi&VCu=)zwi=}nsd}WqLG9Q%`Du;gvYGIfw~XUUvBm~zbz8Ca~G+v z0Pe2{62=Q#p*C1C%Iq?j7&-MB$qB<8+F6Q$1CxnN0v5JlT+{JGcs=j7F9vb4ck^op zx3;$%+^&Ik$!7I5#c6xc_oZMiG2$d{q;N&7fH@ON#REKsQ6B9>myl+t=iCnYrE;Zd z2FIA2Wg_B72apANU2(3kB9wKzU7m@UXaIVFVJK@2%EaJpuAAlMG{tA2I&cUJJ2&sO^Wz&__Ht155B6F zSWvWm!xD>lnK4P&Q#2szV{BA-8KYY}6jJ6ciQfp4uvq3p2J@xHHwi^GoqgZ=cG8B+ zZD26L$Bpnmn6~KCHx`F9A4`lJtdDK~`U9xn-r~Tm9JsyRxCd!|uU(c_%x~!OJX@1j zQ+{4PjGf<;{_**f?tfJG>)`y;(V%@K3inR|w?{4zYxFy^Qdb@6y-t&`w=q$i%gy0UtXcV^AYuFp;9`^iYm&Ew8O T5|9{vG_PTQMN=Gu<~99)0OBT- literal 0 HcmV?d00001 diff --git a/web/scripts/lib/pages.ts b/web/scripts/lib/pages.ts new file mode 100644 index 0000000..a23f5a4 --- /dev/null +++ b/web/scripts/lib/pages.ts @@ -0,0 +1,195 @@ +/** + * Assemble every public page as a `ParsedPage` (route + title + description + + * markdown body) from the site's existing sources of truth. + * + * Nothing is re-authored here: docs and blog come from their MDX, the changelog + * from the repo-root CHANGELOG.md (via release.ts), the FAQ from faq-data.ts, + * and the legal pages from legal-content.ts — the same modules the rendered + * Astro pages import. `generate-page-alts.ts` turns each entry into `.md` and + * `.txt`; `generate-llms-txt.ts` reads the doc subset for the index files. + */ + +import { resolve } from "node:path"; +import { + loadDocs, + loadMarkdownDir, + blogRoute, + type ParsedDoc, + type ParsedPage, +} from "./docs-md.ts"; +import { parseChangelog } from "../../src/lib/release.ts"; +import { faqCategories, faqSubtitle, faqDescription } from "../../src/lib/faq-data.ts"; +import { legalDocs } from "../../src/lib/legal-content.ts"; +import { blogIndexMeta, changelogMeta } from "../../src/lib/page-meta.ts"; +import { + HEADER_LINES, + ALT_FORMAT_NOTE, + docIndexLines, + GITHUB_URL, + SITE_URL, +} from "./site.ts"; + +const DOCS_ROOT = resolve("src", "content", "docs", "docs"); +const BLOG_ROOT = resolve("src", "content", "blog"); + +// Order matters only for readability of generated files; the generator writes +// each page to its own route-derived path regardless. + +/** Docs pages, served at /docs/{route}; the index doc maps to /docs. */ +function docPage(doc: ParsedDoc): ParsedPage { + return { + route: doc.route === "" ? "docs" : `docs/${doc.route}`, + title: doc.frontmatter.title || doc.route || "Documentation", + description: doc.frontmatter.description || undefined, + markdown: doc.plain, + }; +} + +/** Blog posts, served at /blog/{id}. Prefixes a small byline to the body. */ +function blogPostPage(post: ParsedDoc): ParsedPage { + const meta: string[] = []; + if (post.frontmatter.author) meta.push(`**Author:** ${post.frontmatter.author}`); + if (post.frontmatter.date) meta.push(`**Date:** ${post.frontmatter.date}`); + const byline = meta.length ? `${meta.join(" · ")}\n\n` : ""; + return { + route: `blog/${post.route}`, + title: post.frontmatter.title || post.route, + description: post.frontmatter.description || undefined, + markdown: `${byline}${post.plain}`, + }; +} + +/** /blog index: a compact, newest-first list of posts. */ +function blogIndexPage(posts: ParsedDoc[]): ParsedPage { + const sorted = [...posts].sort((a, b) => + (b.frontmatter.date ?? "").localeCompare(a.frontmatter.date ?? ""), + ); + const lines = [blogIndexMeta.subtitle, ""]; + for (const p of sorted) { + const title = p.frontmatter.title || p.route; + const desc = p.frontmatter.description ? ` — ${p.frontmatter.description}` : ""; + const date = p.frontmatter.date ? ` (${p.frontmatter.date})` : ""; + lines.push(`- [${title}](/blog/${p.route})${date}${desc}`); + } + return { + route: "blog", + title: blogIndexMeta.title, + description: blogIndexMeta.description, + markdown: lines.join("\n"), + }; +} + +/** /changelog: rendered from CHANGELOG.md via release.ts. */ +function changelogPage(): ParsedPage { + const entries = parseChangelog(); + const lines = [changelogMeta.subtitle, ""]; + for (const e of entries) { + lines.push(`## v${e.version} — ${e.date}`, ""); + if (e.description) lines.push(`> ${e.description}`, ""); + for (const item of e.items) { + lines.push(`- **${item.category}**: ${item.text}`); + } + if (e.items.length) lines.push(""); + } + return { + route: "changelog", + title: changelogMeta.title, + description: changelogMeta.description, + markdown: lines.join("\n"), + }; +} + +/** /faq: rendered from the shared faq-data module. */ +function faqPage(): ParsedPage { + const lines = [faqSubtitle, ""]; + for (const cat of faqCategories) { + lines.push(`## ${cat.label}`, ""); + for (const item of cat.items) { + lines.push(`### ${item.question}`, "", item.answer, ""); + } + } + return { + route: "faq", + title: "FAQ", + description: faqDescription, + markdown: lines.join("\n"), + }; +} + +/** /privacy and /terms: rendered from the shared legal-content module. */ +function legalPage(doc: (typeof legalDocs)[number]): ParsedPage { + const lines: string[] = []; + for (const section of doc.sections) { + lines.push(`## ${section.title}`, ""); + lines.push(...section.paragraphs, ""); + } + return { + route: doc.slug, + title: doc.title, + description: doc.intro, + markdown: lines.join("\n"), + }; +} + +/** Site-root overview (/index.md): the llms.txt pitch + doc index + links. */ +function rootPage(docs: ParsedDoc[]): ParsedPage { + const indexDocs = docs.map((d) => ({ + route: d.route, + title: d.frontmatter.title || d.route || "Docs", + description: d.frontmatter.description || undefined, + order: Number(d.frontmatter.sidebar_position ?? 0), + })); + const lines = [ + ALT_FORMAT_NOTE, + "", + "## Documentation", + "", + ...docIndexLines(indexDocs), + "", + "## Links", + "", + `- [GitHub](${GITHUB_URL}): Source code and issues`, + `- [Introduction](${SITE_URL}/docs/): What is gpui-query and why you need it`, + `- [Blog](${SITE_URL}/blog)`, + `- [Changelog](${SITE_URL}/changelog)`, + `- [FAQ](${SITE_URL}/faq)`, + ]; + return { + route: "", + title: "gpui-query", + // The tagline is the H1's description line; HEADER_LINES[2] holds it. + description: HEADER_LINES[2].replace(/^>\s?/, ""), + markdown: lines.join("\n"), + }; +} + +/** + * Every public page that gets a `.md` / `.txt` alternate. The 404 page is + * intentionally excluded — it carries no content an agent would fetch. + */ +export async function loadAllPages(): Promise { + const docs = await loadDocs(DOCS_ROOT); + const blog = await loadMarkdownDir(BLOG_ROOT, { routeFrom: blogRoute }); + + return [ + rootPage(docs), + ...docs.map(docPage), + ...blog.map(blogPostPage), + blogIndexPage(blog), + changelogPage(), + faqPage(), + ...legalDocs.map(legalPage), + ]; +} + +/** Doc subset (title/description/route/order) for the llms.txt index files. */ +export async function loadDocIndex() { + const docs = await loadDocs(DOCS_ROOT); + return docs.map((d) => ({ + route: d.route, + title: d.frontmatter.title || d.route || "Home", + description: d.frontmatter.description || "", + order: Number(d.frontmatter.sidebar_position ?? 0), + plain: d.plain, + })); +} diff --git a/web/scripts/lib/site.ts b/web/scripts/lib/site.ts new file mode 100644 index 0000000..f2e92c2 --- /dev/null +++ b/web/scripts/lib/site.ts @@ -0,0 +1,52 @@ +/** + * Site-level constants and the shared doc-index format. + * + * One home for the name/tagline/URLs and the `- [Title](URL): desc` doc list so + * `llms.txt`, `llms-full.txt`, and the root `index.md` overview never drift + * apart. Consumed by generate-llms-txt.ts and lib/pages.ts. + */ + +export const SITE_URL = "https://gpui-query.freeoxide.com"; +export const SITE_NAME = "gpui-query"; +export const GITHUB_URL = "https://github.com/freeoxide/gpui-query"; +export const TAGLINE = + "Zero-boilerplate async state management for GPUI. Brings TanStack Query patterns to Rust and the Zed editor's GPUI framework with caching, retry, cooperative cancellation, and persistence."; + +/** Header block that tops llms.txt, llms-full.txt, and the root overview. */ +export const HEADER_LINES: string[] = [ + `# ${SITE_NAME}`, + "", + `> ${TAGLINE}`, + "", +]; + +/** + * One-line note advertising the `.md` / `.txt` alternates. Appended to llms.txt + * and the root index.md so AI agents learn the convention from the index files. + */ +export const ALT_FORMAT_NOTE = + "Every page is also available as `.md` and `.txt` — append either extension to any URL above for a token-light copy of that page."; + +/** Minimal doc shape needed to render the index list. */ +export interface IndexDoc { + route: string; + title: string; + description?: string; + order?: number; +} + +/** + * llmstxt.org-style doc list lines: `- [Title](URL): Optional description`. + * `route` is the docs route relative to /docs/ ("" -> the /docs index). + */ +export function docIndexLines(docs: IndexDoc[]): string[] { + const sorted = [...docs].sort( + (a, b) => + (a.order ?? 0) - (b.order ?? 0) || a.route.localeCompare(b.route), + ); + return sorted.map((doc) => { + const url = `${SITE_URL}/docs/${doc.route}`; + const desc = doc.description ? `: ${doc.description}` : ""; + return `- [${doc.title}](${url})${desc}`; + }); +} diff --git a/web/src/components/overrides/Head.astro b/web/src/components/overrides/Head.astro index de33f75..cfdf6cf 100644 --- a/web/src/components/overrides/Head.astro +++ b/web/src/components/overrides/Head.astro @@ -32,5 +32,15 @@ head.push( { tag: "link", attrs: { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" } }, { tag: "link", attrs: { rel: "apple-touch-icon", href: "/apple-touch-icon.png" } }, ); + +// Token-light alternates: advertise the per-page `.md` / `.txt` copies (root of +// the docs section -> /docs.md; nested pages -> /docs/{...}.md) so agents +// inspecting the head can find them. See scripts/generate-page-alts.ts. +const altPath = Astro.url.pathname.replace(/\/+$/, ""); +const altBase = altPath === "" ? "/index" : altPath; +head.push( + { tag: "link", attrs: { rel: "alternate", type: "text/markdown", href: `${altBase}.md` } }, + { tag: "link", attrs: { rel: "alternate", type: "text/plain", href: `${altBase}.txt` } }, +); --- {head.map(({ tag: Tag, attrs, content }) => )} diff --git a/web/src/content/docs/docs/guides/claude-skills.mdx b/web/src/content/docs/docs/guides/claude-skills.mdx new file mode 100644 index 0000000..8cee2a2 --- /dev/null +++ b/web/src/content/docs/docs/guides/claude-skills.mdx @@ -0,0 +1,54 @@ +--- +sidebar_position: 9 +title: Claude Code skills +description: "Install gpui-query's Claude Code skills so your AI assistant writes correct hooks, caching, retry, and persistence code without guessing the API." +--- + +gpui-query ships two [Claude Code](https://claude.com/claude-code) skills — self-contained knowledge packs that teach an AI assistant the real gpui-query API (exact signatures, defaults, lifecycle, and gotchas). Install them once and the assistant stops guessing how `use_query`, `CachePolicy`, `persist_with`, or `HttpCache` work. + +:::note +These use the standard [Claude Code skill format](https://docs.claude.com/en/docs/claude-code/skills), so they work in the Claude Code CLI, desktop app, web, and IDE extensions. They activate automatically based on what you're working on — you don't invoke them by hand. +::: + +## What you get + +| Skill | Covers | +|---|---| +| `gpui-query` | The essentials: `use_query`, `use_mutation`, `use_infinite_query`, `use_query_select`, in-memory `CachePolicy` (TTL / stale-while-revalidate / no-cache), `RetryPolicy`, `QueryKey` filters, `QueryClient` bulk operations, observers, and GC. | +| `gpui-query-extensions` | The satellite crates: HTTP `Cache-Control` → `CachePolicy` and an `HttpCache` layer for cheap `304` refetches (`gpui-query-http`), plus durable disk persistence with `FilePersister` and the `persist` feature (`gpui-query-persist`). | + +Install `gpui-query` for everyday app work; add `gpui-query-extensions` when you wire up HTTP caching or disk persistence. + +## Install + +Both skills live in the repo under [`skills/`](https://github.com/freeoxide/gpui-query/tree/master/skills). Copy them into your user-level skills directory so they're available in **every** project: + +```sh +cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/ +``` + +Install just one, or fetch it without cloning the repo: + +```sh +# essentials +mkdir -p ~/.claude/skills/gpui-query +curl -fsSL https://raw.githubusercontent.com/freeoxide/gpui-query/master/skills/gpui-query/SKILL.md \ + -o ~/.claude/skills/gpui-query/SKILL.md + +# extensions +mkdir -p ~/.claude/skills/gpui-query-extensions +curl -fsSL https://raw.githubusercontent.com/freeoxide/gpui-query/master/skills/gpui-query-extensions/SKILL.md \ + -o ~/.claude/skills/gpui-query-extensions/SKILL.md +``` + +:::tip[Verify it loaded] +After installing, open a project that depends on gpui-query and ask the assistant something about queries or caching — the skill loads on its own. Run `/skills` to confirm both are registered. +::: + +## Project-scoped alternative + +To pin a skill to a single repo instead of every project, drop it into that project's `.claude/skills/` directory rather than `~/.claude/skills/`. It then activates only when you're working inside that project. + +## Updating + +The skills track gpui-query **v0.2.0** (essentials) and the **v0.1.0** satellites (extensions). Each skill is a single `SKILL.md` with no other dependencies, so re-running the install command above after a `cargo update` is all it takes to refresh them. diff --git a/web/src/layouts/BaseLayout.astro b/web/src/layouts/BaseLayout.astro index 78aea7b..3226c69 100644 --- a/web/src/layouts/BaseLayout.astro +++ b/web/src/layouts/BaseLayout.astro @@ -38,6 +38,12 @@ const SITE = "https://gpui-query.freeoxide.com"; const canonical = new URL(canonicalPath, SITE).href; const ogImage = new URL(image, SITE).href; const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; + +// Token-light alternates: every page is also served as `.md` and `.txt` at the +// same path (root -> /index.{md,txt}). Advertise them so crawlers/agents that +// inspect the head can find the small copy without guessing the convention. +const altBase = canonicalPath.replace(/\/+$/, ""); +const altHref = (ext: string) => `${altBase === "" ? "/index" : altBase}${ext}`; --- @@ -55,6 +61,10 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; + {/* Token-light alternates (markdown + plain text) for AI agents. */} + + + {/* OpenGraph */} {description && } diff --git a/web/src/lib/faq-data.ts b/web/src/lib/faq-data.ts new file mode 100644 index 0000000..94da71f --- /dev/null +++ b/web/src/lib/faq-data.ts @@ -0,0 +1,102 @@ +/** + * Single source of truth for the FAQ page content. + * + * Consumed by `src/pages/faq.astro` (renders the accordion + FAQPage JSON-LD) + * AND by `scripts/lib/pages.ts` (emits `/faq.md` and `/faq.txt`), so the + * questions and answers are authored exactly once. The `icon` field is SVG + * path markup used only by the rendered page; the alt-format generator ignores + * it. + */ + +export interface FaqItem { + question: string; + answer: string; +} + +export interface FaqCategory { + label: string; + /** Inner SVG path markup (lucide-style); rendering-only, not in alt formats. */ + icon: string; + items: FaqItem[]; +} + +export const faqCategories: FaqCategory[] = [ + { + label: "Getting Started", + icon: '', + items: [ + { + question: "How is gpui-query different from TanStack Query?", + answer: + "gpui-query adapts TanStack Query's patterns to Rust and the GPUI framework. It uses Rust's type system for compile-time guarantees, Arc for cooperative cancellation, and integrates directly with GPUI's render loop.", + }, + { + question: "Can I use gpui-query outside of Zed?", + answer: + "gpui-query is designed for the GPUI framework, which powers the Zed editor. While architecturally the Core layer is framework-agnostic, the Hook layer depends on GPUI's reactive primitives.", + }, + { + question: "How do I set up QueryClient in my app?", + answer: + "Create a QueryClient instance and register it in your GPUI application. The client manages all query resources, caching, and garbage collection. See the Getting Started guide for a complete walkthrough.", + }, + ], + }, + { + label: "Architecture", + icon: '', + items: [ + { + question: "Why does use_query return a tuple instead of an object?", + answer: + "use_query returns (Entity>, Subscription). Read data and status from the resource entity during render, and store the Subscription to keep the observation alive: dropping it stops updates, which is GPUI's standard lifecycle convention.", + }, + { + question: "What happens if my component unmounts during a fetch?", + answer: + "gpui-query uses cooperative cancellation via QuerySignal (Arc). When a component unmounts, the signal is set and the query checks it between retry attempts, which keeps teardown clean.", + }, + { + question: "What is QuerySignal and when do I check it?", + answer: + "QuerySignal is an Arc that enables cooperative cancellation. Long-running queries should check the signal periodically (especially between retry attempts) and abort early if cancelled.", + }, + ], + }, + { + label: "Advanced", + icon: '', + items: [ + { + question: "Why does LatestWins cancel my in-flight request?", + answer: + "LatestWins is a RequestPolicy that ensures only the most recent request's result is used. When a new request arrives, previous in-flight requests are cancelled via their signals, which prevents stale data from overwriting fresh results.", + }, + { + question: "How do I persist my query cache?", + answer: + "Enable the persist feature and implement the async Persister trait to save and restore query state across restarts. gpui-query supports custom backends (files, databases, KV) and ships a ready-made disk adapter in the gpui-query-persist crate.", + }, + { + question: "How do I handle pagination?", + answer: + "Use use_infinite_query for paginated data. It supports bidirectional fetching (fetch_next_page_infinite / fetch_previous_page_infinite) and configurable max_pages to limit cached pages.", + }, + { + question: "Is there a devtools experience?", + answer: + "gpui-query provides ClientDiagnostic types for inspecting cache state, query status, and resource lifecycle, a developer toolkit for debugging async state.", + }, + ], + }, +]; + +/** Flattened Q&A list, convenient for JSON-LD and the alt-format body. */ +export const faqItems: FaqItem[] = faqCategories.flatMap((c) => c.items); + +/** Visible subtitle under the FAQ H1; also the first line of /faq.md and /faq.txt. */ +export const faqSubtitle = "Common questions about gpui-query, grouped by topic."; + +/** SEO meta description for /faq (shared by faq.astro and the alt generator). */ +export const faqDescription = + "Common gpui-query questions answered: differences from TanStack Query, QueryClient setup, QuerySignal cancellation, persistence, and pagination."; diff --git a/web/src/lib/inline-md.ts b/web/src/lib/inline-md.ts new file mode 100644 index 0000000..a544fa8 --- /dev/null +++ b/web/src/lib/inline-md.ts @@ -0,0 +1,66 @@ +/** + * Render the tiny inline-markdown subset used in the legal pages + * (`**bold**`, `` `code` ``, `[label](url)`) to HTML. + * + * The legal content lives once, as markdown strings, in `legal-content.ts` — + * the `.astro` pages render it to HTML with this helper, and the alt-format + * generator uses the same strings directly for `.md` / `.txt`. Only the + * constructs that appear in the legal copy are supported; body text is escaped. + */ + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} + +export function renderInlineMarkdown(input: string): string { + // Tokenize so HTML-escaping never touches the inside of code spans or tags. + // We walk the string, copying raw text (escaped) until we hit a recognized + // inline construct, then emit its HTML and continue. + let out = ""; + let i = 0; + const text = input; + while (i < text.length) { + const rest = text.slice(i); + + // Inline code: `...` + const code = rest.match(/^`([^`]+)`/); + if (code) { + out += `${escapeHtml(code[1])}`; + i += code[0].length; + continue; + } + // Link: [label](url). External http(s) links open in a new tab with the + // standard safety attrs (matches the prior hand-written legal markup); + // own-domain, relative, and mailto: links stay in-tab. + const link = rest.match(/^\[([^\]]+)\]\(([^)\s]+)\)/); + if (link) { + const raw = link[2]; + const url = escapeHtml(raw); + const label = renderInlineMarkdown(link[1]); + const isExternal = + /^https?:\/\//i.test(raw) && !raw.includes("gpui-query.freeoxide.com"); + const attrs = isExternal + ? ' target="_blank" rel="noopener noreferrer"' + : ""; + out += `${label}`; + i += link[0].length; + continue; + } + // Bold: **...** + const bold = rest.match(/^\*\*([^*]+)\*\*/); + if (bold) { + out += `${renderInlineMarkdown(bold[1])}`; + i += bold[0].length; + continue; + } + + // Plain character (escape &, <, >). + const ch = text[i]; + out += escapeHtml(ch); + i += 1; + } + return out; +} diff --git a/web/src/lib/legal-content.ts b/web/src/lib/legal-content.ts new file mode 100644 index 0000000..5cde207 --- /dev/null +++ b/web/src/lib/legal-content.ts @@ -0,0 +1,160 @@ +/** + * Single source of truth for the Privacy Policy and Terms of Service. + * + * Each section's paragraphs are markdown strings (the tiny `**bold**` / + * `` `code` `` / `[label](url)` subset). `src/pages/privacy.astro` and + * `terms.astro` render them to HTML via `renderInlineMarkdown`; the alt-format + * generator (`scripts/lib/pages.ts`) uses the same strings verbatim for + * `/privacy.md`, `/privacy.txt`, `/terms.md`, `/terms.txt`. Authored once. + */ + +export interface LegalSection { + title: string; + /** Each entry is one `

`, written in inline markdown. */ + paragraphs: string[]; +} + +export interface LegalDoc { + slug: "privacy" | "terms"; + title: string; + /** SEO meta description (BaseLayout). */ + description: string; + /** Visible subtitle under the H1 (LegalPage); also the first line of the .md/.txt alt. */ + intro: string; + updatedAt: string; + sections: LegalSection[]; +} + +export const legalDocs: LegalDoc[] = [ + { + slug: "privacy", + title: "Privacy Policy", + description: + "How the gpui-query website handles data: Firebase Analytics, Cloudflare hosting, self-hosted fonts, and local storage.", + intro: + "How the gpui-query website handles analytics, hosting logs, fonts, and local storage.", + updatedAt: "July 9, 2026", + sections: [ + { + title: "Scope", + paragraphs: [ + "This policy covers the gpui-query website at [gpui-query.freeoxide.com](https://gpui-query.freeoxide.com), including the documentation and blog. The site has no user accounts, no comment forms, and no newsletter. We do not ask you for personal information anywhere on it. The data described below is what the services we build on collect.", + ], + }, + { + title: "Analytics", + paragraphs: [ + "We use **Firebase Analytics** (part of Google Firebase, operated by Google LLC) to understand how the site is used: which pages are visited, roughly where visitors come from, and what kind of device and browser they use. Firebase collects this through a device identifier and reports it to us only in aggregate. We use it only to see which docs pages get traffic and where readers drop off.", + "Google's own use of this data is described in the [Firebase privacy documentation](https://firebase.google.com/support/privacy) and the [Google Privacy Policy](https://policies.google.com/privacy). Most content blockers block Firebase Analytics; the site works exactly the same with it blocked.", + ], + }, + { + title: "Hosting", + paragraphs: [ + "The site is served by **Cloudflare**. Like any web host, Cloudflare sees your IP address and request details and keeps short-lived server logs for security and operations. See the [Cloudflare Privacy Policy](https://www.cloudflare.com/privacypolicy/).", + ], + }, + { + title: "Fonts", + paragraphs: [ + "Fonts are **self-hosted**: the font files are bundled with the site and served from the same origin. No font request is sent to any third party, so your IP address is not shared with a font provider.", + ], + }, + { + title: "Local storage", + paragraphs: [ + "Your light/dark theme choice is saved in your browser's `localStorage`. It never leaves your device and you can clear it at any time through your browser settings.", + ], + }, + { + title: "External links", + paragraphs: [ + "The site links out to GitHub, crates.io, docs.rs, and other third-party sites. Once you follow one of those links, that site's privacy policy applies, not this one.", + ], + }, + { + title: "Children", + paragraphs: [ + "The site is developer documentation and is not directed to children under 13. We do not knowingly collect any information from them.", + ], + }, + { + title: "Changes", + paragraphs: [ + "If we change what the site collects (for example by adding or removing a service), we will update this page and the date at the top.", + ], + }, + { + title: "Contact", + paragraphs: [ + "Questions about this policy? Open an issue on [GitHub](https://github.com/freeoxide/gpui-query/issues) or email [hmziqrs@gmail.com](mailto:hmziqrs@gmail.com).", + ], + }, + ], + }, + { + slug: "terms", + title: "Terms of Service", + description: + "Terms for using the gpui-query website and documentation. The gpui-query library itself is MIT licensed.", + intro: + "Terms for using the gpui-query website, documentation, blog, and changelog.", + updatedAt: "July 9, 2026", + sections: [ + { + title: "Scope", + paragraphs: [ + "These terms apply to the gpui-query website at [gpui-query.freeoxide.com](https://gpui-query.freeoxide.com), which includes the landing pages, documentation, blog, and changelog. By using the site you agree to them. They are short because the site is simple: it is free documentation for an open-source library.", + ], + }, + { + title: "The library is MIT licensed", + paragraphs: [ + "The gpui-query crate itself is distributed under the [MIT License](https://github.com/freeoxide/gpui-query/blob/master/LICENSE). That license, not these terms, governs your use of the source code and the published crate. You can use it in personal and commercial projects, modify it, and redistribute it, subject to the license's conditions.", + ], + }, + { + title: "Site content", + paragraphs: [ + "You may read, link to, and quote the documentation and blog posts with attribution. Code snippets shown in the docs and blog are provided so you can use them; treat them as MIT-licensed like the library they document.", + ], + }, + { + title: "No warranty", + paragraphs: [ + "The site and its content are provided as-is. We work to keep the documentation accurate, but APIs change between releases and pages can lag behind the code. Nothing on this site is a guarantee that the library is fit for a particular purpose, and we are not liable for damages arising from your use of the site or the library (the same disclaimer the MIT License makes for the code).", + ], + }, + { + title: "Acceptable use", + paragraphs: [ + "Don't attempt to disrupt the site, scrape it at a rate that degrades it for others, or use it to distribute malware or spam. That's it.", + ], + }, + { + title: "Third-party services and links", + paragraphs: [ + "The site links to third-party sites (GitHub, crates.io, docs.rs, and others) and relies on the services described in the [Privacy Policy](/privacy), including Firebase Analytics and Cloudflare. We don't control those services and aren't responsible for their content or conduct.", + ], + }, + { + title: "Changes", + paragraphs: [ + "We may update these terms as the site evolves. Material changes will be reflected on this page with a new date at the top. Continuing to use the site after a change means you accept the updated terms.", + ], + }, + { + title: "Contact", + paragraphs: [ + "Questions about these terms? Open an issue on [GitHub](https://github.com/freeoxide/gpui-query/issues) or email [hmziqrs@gmail.com](mailto:hmziqrs@gmail.com).", + ], + }, + ], + }, +]; + +export function getLegalDoc(slug: LegalDoc["slug"]): LegalDoc { + const doc = legalDocs.find((d) => d.slug === slug); + if (!doc) throw new Error(`Unknown legal doc: ${slug}`); + return doc; +} diff --git a/web/src/lib/page-meta.ts b/web/src/lib/page-meta.ts new file mode 100644 index 0000000..1fcff9d --- /dev/null +++ b/web/src/lib/page-meta.ts @@ -0,0 +1,30 @@ +/** + * Single source of truth for the "synthetic" index pages — Blog index and + * Changelog — whose bodies are generated from data rather than authored as + * MDX. Their title / SEO description / visible subtitle are shared between the + * rendered .astro page and the .md/.txt alt generator (scripts/lib/pages.ts), + * so the two can never drift. + */ + +export interface PageMeta { + /** Bare page title (the .astro appends " - gpui-query" for ). */ + title: string; + /** SEO meta description. */ + description: string; + /** Visible subtitle under the H1; also the first line of the .md/.txt body. */ + subtitle: string; +} + +export const blogIndexMeta: PageMeta = { + title: "Blog", + description: + "Deep dives on async state management for GPUI in Rust: cache policies, cooperative cancellation, and updates from the gpui-query project.", + subtitle: "Announcements, deep dives, and updates about gpui-query.", +}; + +export const changelogMeta: PageMeta = { + title: "Changelog", + description: + "gpui-query release history: the v1 to v2 rewrite, crate reorganization, fixes, and docs updates in every published version.", + subtitle: "Release history for gpui-query. Every version, every improvement.", +}; diff --git a/web/src/pages/blog.astro b/web/src/pages/blog.astro index 4e19a4c..6221fe5 100644 --- a/web/src/pages/blog.astro +++ b/web/src/pages/blog.astro @@ -1,6 +1,7 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; import { getCollection } from "astro:content"; +import { blogIndexMeta } from "../lib/page-meta"; // Replaces the frontmatter-only glob in lib/blog.ts with an Astro content // collection query. Posts are sorted newest-first (same as getAllBlogPosts). @@ -8,9 +9,8 @@ const posts = (await getCollection("blog")).sort( (a, b) => b.data.date.getTime() - a.data.date.getTime() ); -const title = "Blog - gpui-query"; -const description = - "Deep dives on async state management for GPUI in Rust: cache policies, cooperative cancellation, and updates from the gpui-query project."; +const title = `${blogIndexMeta.title} - gpui-query`; +const description = blogIndexMeta.description; const jsonLd = { "@context": "https://schema.org", "@type": "Blog", @@ -36,9 +36,9 @@ function formatDate(d: Date): string { <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 sm:py-16 lg:px-8"> <div class="mx-auto max-w-7xl"> <div class="border-l-4 border-primary pl-5"> - <h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Blog</h1> + <h1 class="text-3xl font-bold tracking-tight sm:text-4xl">{blogIndexMeta.title}</h1> <p class="mt-2 text-lg text-muted-foreground"> - Announcements, deep dives, and updates about gpui-query. + {blogIndexMeta.subtitle} </p> </div> diff --git a/web/src/pages/changelog.astro b/web/src/pages/changelog.astro index 0491c84..4e4a580 100644 --- a/web/src/pages/changelog.astro +++ b/web/src/pages/changelog.astro @@ -2,6 +2,7 @@ import BaseLayout from "../layouts/BaseLayout.astro"; import { changelogPage } from "../lib/seo"; import { parseChangelog, type Category } from "../lib/release"; +import { changelogMeta } from "../lib/page-meta"; // Generated at build time from the repo-root CHANGELOG.md (the single source of // truth) via src/lib/release.ts. The per-entry headline comes from a blockquote @@ -46,9 +47,8 @@ const changelogEntries: ChangelogEntry[] = parseChangelog().map((entry) => ({ links: docLinks[entry.version], })); -const title = "Changelog - gpui-query"; -const description = - "gpui-query release history: the v1 to v2 rewrite, crate reorganization, fixes, and docs updates in every published version."; +const title = `${changelogMeta.title} - gpui-query`; +const description = changelogMeta.description; const jsonLd = changelogPage({ name: "gpui-query Changelog", description: "Release history for gpui-query, the async state management library for GPUI.", @@ -74,9 +74,9 @@ function badgeClasses(index: number): string { <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl"> <div class="mb-12 text-center"> - <h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Changelog</h1> + <h1 class="text-3xl font-bold tracking-tight sm:text-4xl">{changelogMeta.title}</h1> <p class="mt-3 text-lg text-muted-foreground"> - Release history for gpui-query. Every version, every improvement. + {changelogMeta.subtitle} </p> </div> diff --git a/web/src/pages/faq.astro b/web/src/pages/faq.astro index 570f248..f67928c 100644 --- a/web/src/pages/faq.astro +++ b/web/src/pages/faq.astro @@ -1,95 +1,15 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; import { faqPage } from "../lib/seo"; +import { faqCategories as categories, faqItems, faqSubtitle, faqDescription } from "../lib/faq-data"; // Native <details>/<summary> instead of a React accordion: the answers stay in -// the DOM (crawlable for the FAQPage structured data) with no JS. Same content -// as the old faq.tsx. See astro-migration.mdx § "Route and content mapping". -interface FaqItem { - question: string; - answer: string; -} -interface FaqCategory { - label: string; - icon: string; // inner SVG markup (lucide-style) - items: FaqItem[]; -} - -const categories: FaqCategory[] = [ - { - label: "Getting Started", - icon: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>', - items: [ - { - question: "How is gpui-query different from TanStack Query?", - answer: - "gpui-query adapts TanStack Query's patterns to Rust and the GPUI framework. It uses Rust's type system for compile-time guarantees, Arc<AtomicBool> for cooperative cancellation, and integrates directly with GPUI's render loop.", - }, - { - question: "Can I use gpui-query outside of Zed?", - answer: - "gpui-query is designed for the GPUI framework, which powers the Zed editor. While architecturally the Core layer is framework-agnostic, the Hook layer depends on GPUI's reactive primitives.", - }, - { - question: "How do I set up QueryClient in my app?", - answer: - "Create a QueryClient instance and register it in your GPUI application. The client manages all query resources, caching, and garbage collection. See the Getting Started guide for a complete walkthrough.", - }, - ], - }, - { - label: "Architecture", - icon: '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>', - items: [ - { - question: "Why does use_query return a tuple instead of an object?", - answer: - "use_query returns (Entity<QueryResource<T, E>>, Subscription). Read data and status from the resource entity during render, and store the Subscription to keep the observation alive: dropping it stops updates, which is GPUI's standard lifecycle convention.", - }, - { - question: "What happens if my component unmounts during a fetch?", - answer: - "gpui-query uses cooperative cancellation via QuerySignal (Arc<AtomicBool>). When a component unmounts, the signal is set and the query checks it between retry attempts, which keeps teardown clean.", - }, - { - question: "What is QuerySignal and when do I check it?", - answer: - "QuerySignal is an Arc<AtomicBool> that enables cooperative cancellation. Long-running queries should check the signal periodically (especially between retry attempts) and abort early if cancelled.", - }, - ], - }, - { - label: "Advanced", - icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>', - items: [ - { - question: "Why does LatestWins cancel my in-flight request?", - answer: - "LatestWins is a RequestPolicy that ensures only the most recent request's result is used. When a new request arrives, previous in-flight requests are cancelled via their signals, which prevents stale data from overwriting fresh results.", - }, - { - question: "How do I persist my query cache?", - answer: - "Enable the persist feature and implement the async Persister trait to save and restore query state across restarts. gpui-query supports custom backends (files, databases, KV) and ships a ready-made disk adapter in the gpui-query-persist crate.", - }, - { - question: "How do I handle pagination?", - answer: - "Use use_infinite_query for paginated data. It supports bidirectional fetching (fetch_next_page_infinite / fetch_previous_page_infinite) and configurable max_pages to limit cached pages.", - }, - { - question: "Is there a devtools experience?", - answer: - "gpui-query provides ClientDiagnostic types for inspecting cache state, query status, and resource lifecycle, a developer toolkit for debugging async state.", - }, - ], - }, -]; - -const faqItems: FaqItem[] = categories.flatMap((c) => c.items); +// the DOM (crawlable for the FAQPage structured data) with no JS. The Q&A data +// itself lives once in ../lib/faq-data and is shared with the /faq.md and +// /faq.txt alt-format generator. See astro-migration.mdx § "Route and content +// mapping". const title = "FAQ - gpui-query"; -const description = - "Common gpui-query questions answered: differences from TanStack Query, QueryClient setup, QuerySignal cancellation, persistence, and pagination."; +const description = faqDescription; const jsonLd = faqPage({ questions: faqItems.map((item) => ({ question: item.question, answer: item.answer })), }); @@ -101,7 +21,7 @@ const ogImage = "/og/faq.png"; <div class="border-l-4 border-primary pl-5"> <h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Frequently Asked Questions</h1> <p class="mt-2 text-lg text-muted-foreground"> - Common questions about gpui-query, grouped by topic. + {faqSubtitle} </p> </div> diff --git a/web/src/pages/privacy.astro b/web/src/pages/privacy.astro index 19c18a2..4cb908c 100644 --- a/web/src/pages/privacy.astro +++ b/web/src/pages/privacy.astro @@ -2,107 +2,23 @@ import BaseLayout from "../layouts/BaseLayout.astro"; import LegalPage from "../components/LegalPage.astro"; import LegalSection from "../components/LegalSection.astro"; - -const title = "Privacy Policy - gpui-query"; -const description = - "How the gpui-query website handles data: Firebase Analytics, Cloudflare hosting, self-hosted fonts, and local storage."; +import { getLegalDoc } from "../lib/legal-content"; +import { renderInlineMarkdown } from "../lib/inline-md"; + +// Legal copy lives once in ../lib/legal-content (shared with the /privacy.md +// and /privacy.txt alt-format generator). Here we only render it to HTML. +const doc = getLegalDoc("privacy"); +const title = `${doc.title} - gpui-query`; +const description = doc.description; --- <BaseLayout title={title} description={description} canonicalPath="/privacy"> - <LegalPage - title="Privacy Policy" - description="How the gpui-query website handles analytics, hosting logs, fonts, and local storage." - updatedAt="July 9, 2026" - > - <LegalSection title="Scope"> - <p> - This policy covers the gpui-query website at - <a href="https://gpui-query.freeoxide.com">gpui-query.freeoxide.com</a>, including the - documentation and blog. The site has no user accounts, no comment forms, and no newsletter. - We do not ask you for personal information anywhere on it. The data described below is what - the services we build on collect. - </p> - </LegalSection> - - <LegalSection title="Analytics"> - <p> - We use <strong>Firebase Analytics</strong> (part of Google Firebase, operated by Google LLC) - to understand how the site is used: which pages are visited, roughly where visitors come - from, and what kind of device and browser they use. Firebase collects this through a device - identifier and reports it to us only in aggregate. We use it only to see which docs pages - get traffic and where readers drop off. - </p> - <p> - Google's own use of this data is described in the - <a - href="https://firebase.google.com/support/privacy" - target="_blank" - rel="noopener noreferrer">Firebase privacy documentation</a - > and the - <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer" - >Google Privacy Policy</a - >. Most content blockers block Firebase Analytics; the site works exactly the same with it - blocked. - </p> - </LegalSection> - - <LegalSection title="Hosting"> - <p> - The site is served by <strong>Cloudflare</strong>. Like any web host, Cloudflare sees your IP - address and request details and keeps short-lived server logs for security and operations. - See the - <a - href="https://www.cloudflare.com/privacypolicy/" - target="_blank" - rel="noopener noreferrer">Cloudflare Privacy Policy</a - >. - </p> - </LegalSection> - - <LegalSection title="Fonts"> - <p> - Fonts are <strong>self-hosted</strong>: the font files are bundled with the site and served - from the same origin. No font request is sent to any third party, so your IP address is not - shared with a font provider. - </p> - </LegalSection> - - <LegalSection title="Local storage"> - <p> - Your light/dark theme choice is saved in your browser's <code>localStorage</code>. It never - leaves your device and you can clear it at any time through your browser settings. - </p> - </LegalSection> - - <LegalSection title="External links"> - <p> - The site links out to GitHub, crates.io, docs.rs, and other third-party sites. Once you - follow one of those links, that site's privacy policy applies, not this one. - </p> - </LegalSection> - - <LegalSection title="Children"> - <p> - The site is developer documentation and is not directed to children under 13. We do not - knowingly collect any information from them. - </p> - </LegalSection> - - <LegalSection title="Changes"> - <p> - If we change what the site collects (for example by adding or removing a service), we will - update this page and the date at the top. - </p> - </LegalSection> - - <LegalSection title="Contact"> - <p> - Questions about this policy? Open an issue on - <a - href="https://github.com/freeoxide/gpui-query/issues" - target="_blank" - rel="noopener noreferrer">GitHub</a - > or email <a href="mailto:hmziqrs@gmail.com">hmziqrs@gmail.com</a>. - </p> - </LegalSection> + <LegalPage title={doc.title} description={doc.intro} updatedAt={doc.updatedAt}> + {doc.sections.map((section) => ( + <LegalSection title={section.title}> + {section.paragraphs.map((p) => ( + <p set:html={renderInlineMarkdown(p)} /> + ))} + </LegalSection> + ))} </LegalPage> </BaseLayout> diff --git a/web/src/pages/terms.astro b/web/src/pages/terms.astro index cc6b4f7..1033ae5 100644 --- a/web/src/pages/terms.astro +++ b/web/src/pages/terms.astro @@ -2,90 +2,23 @@ import BaseLayout from "../layouts/BaseLayout.astro"; import LegalPage from "../components/LegalPage.astro"; import LegalSection from "../components/LegalSection.astro"; - -const title = "Terms of Service - gpui-query"; -const description = - "Terms for using the gpui-query website and documentation. The gpui-query library itself is MIT licensed."; +import { getLegalDoc } from "../lib/legal-content"; +import { renderInlineMarkdown } from "../lib/inline-md"; + +// Legal copy lives once in ../lib/legal-content (shared with the /terms.md +// and /terms.txt alt-format generator). Here we only render it to HTML. +const doc = getLegalDoc("terms"); +const title = `${doc.title} - gpui-query`; +const description = doc.description; --- <BaseLayout title={title} description={description} canonicalPath="/terms"> - <LegalPage - title="Terms of Service" - description="Terms for using the gpui-query website, documentation, blog, and changelog." - updatedAt="July 9, 2026" - > - <LegalSection title="Scope"> - <p> - These terms apply to the gpui-query website at - <a href="https://gpui-query.freeoxide.com">gpui-query.freeoxide.com</a>, which includes the - landing pages, documentation, blog, and changelog. By using the site you agree to them. They - are short because the site is simple: it is free documentation for an open-source library. - </p> - </LegalSection> - - <LegalSection title="The library is MIT licensed"> - <p> - The gpui-query crate itself is distributed under the - <a - href="https://github.com/freeoxide/gpui-query/blob/master/LICENSE" - target="_blank" - rel="noopener noreferrer">MIT License</a - >. That license, not these terms, governs your use of the source code and the published - crate. You can use it in personal and commercial projects, modify it, and redistribute it, - subject to the license's conditions. - </p> - </LegalSection> - - <LegalSection title="Site content"> - <p> - You may read, link to, and quote the documentation and blog posts with attribution. Code - snippets shown in the docs and blog are provided so you can use them; treat them as - MIT-licensed like the library they document. - </p> - </LegalSection> - - <LegalSection title="No warranty"> - <p> - The site and its content are provided as-is. We work to keep the documentation accurate, but - APIs change between releases and pages can lag behind the code. Nothing on this site is a - guarantee that the library is fit for a particular purpose, and we are not liable for damages - arising from your use of the site or the library (the same disclaimer the MIT License makes - for the code). - </p> - </LegalSection> - - <LegalSection title="Acceptable use"> - <p> - Don't attempt to disrupt the site, scrape it at a rate that degrades it for others, or use it - to distribute malware or spam. That's it. - </p> - </LegalSection> - - <LegalSection title="Third-party services and links"> - <p> - The site links to third-party sites (GitHub, crates.io, docs.rs, and others) and relies on - the services described in the <a href="/privacy">Privacy Policy</a>, including Firebase - Analytics and Cloudflare. We don't control those services and aren't - responsible for their content or conduct. - </p> - </LegalSection> - - <LegalSection title="Changes"> - <p> - We may update these terms as the site evolves. Material changes will be reflected on this - page with a new date at the top. Continuing to use the site after a change means you accept - the updated terms. - </p> - </LegalSection> - - <LegalSection title="Contact"> - <p> - Questions about these terms? Open an issue on - <a - href="https://github.com/freeoxide/gpui-query/issues" - target="_blank" - rel="noopener noreferrer">GitHub</a - > or email <a href="mailto:hmziqrs@gmail.com">hmziqrs@gmail.com</a>. - </p> - </LegalSection> + <LegalPage title={doc.title} description={doc.intro} updatedAt={doc.updatedAt}> + {doc.sections.map((section) => ( + <LegalSection title={section.title}> + {section.paragraphs.map((p) => ( + <p set:html={renderInlineMarkdown(p)} /> + ))} + </LegalSection> + ))} </LegalPage> </BaseLayout> From 6b012fe0834bc9bd830c6bcb5d0840f4c3a47149 Mon Sep 17 00:00:00 2001 From: hmziqrs <hmziqrs@gmail.com> Date: Wed, 29 Jul 2026 03:04:23 +0500 Subject: [PATCH 004/111] fix: legal page paragraph spacing and 404 alts --- web/scripts/lib/markdown.ts | Bin 4924 -> 6850 bytes web/scripts/lib/pages.ts | 2 +- web/src/layouts/BaseLayout.astro | 7 +++++-- web/src/pages/404.astro | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/scripts/lib/markdown.ts b/web/scripts/lib/markdown.ts index 0781334c4c73f5e31dd17e60bfa19b352a60409d..9de8b480d17b986d2b1eac8eb471ff214d06234b 100644 GIT binary patch delta 2824 zcmZuzU2hx56@~ke4>?U_BPkLXX|D-YUD701N|B(IWT}oUw+0dy5GkaED221TLvo|p z8D?fyG@&tA6ev)jkJ~=>KeTWk`jDr*_9g!yMf(d{^sx^;vrCGKV?qMCGk5OXk8{r5 z&(=SG_s{?N=4@X{tCeWQBc{o4X;aCCp(sy<C1y$ISjspJw905KSSsmWXaC_tGK%bo z<15!OC&|ybUI{}QDUldrBvQ*Y6ZGil1B#}Wx2ZegQyS(>CzMKoYbaL9j1!Xy4Y-VX zLXlAMnW4!j72JFG4tX{nF_W4QN6x~ZcR&1qtY*>-l+KI~j})%1V#&2uxlB?yq_I|p zw^w2%jV*(?OC77R)ApI{BintI@L;x4-<}&Cb?Esvn=n}k8;uoe&;!A&<q`@0?7@d* zS%g5fnvhjw#w_M7!^ccBi)3OhP{?4Mj=7;ktMQm8^)?+D>?tXx-d!}CmXWvZV@qRZ zN4~6@O(<jfEKw6#U4ipT{?L-(>}<O_kR_G<8Rui;m$Eh-hAV?y#x_-w234e8AL5~! z1c-8rOj&~Z;s;&KR){pWxt2Z+JP@@tN1tOJgr_~z=((^-wOaj|t)9c)+SM&HM$3bm zTbO2S90YNl?(S0D#<f{cpF8<Pr81}y)lk=KPoDncTQ6UG8+DNh*Ctk)Q*F`n8l?tV z6?a~^H47#x7YT7-vLEqbDnHiz)0`XI5Bu+Je7v!-(XVflL5Brz^&5R(-i05yDO2|? zC~FUhp@~B-a}W2aFP1#>unDB{@Uy(^Cb8rbR6<&^GgUfFQE5#H7is1JUibZC8mk$% zX$;h#aq7Q|tAErdlk4;JoSUj+nwvDqnP_<kw=5faf0}0{dJ!M?QKi@;2Vl}1+<_5E zmH|FS{e%v4AQ>TZSef}v_jJ5-&#Ekq-&acPmPs>Iu%x4}6_@R&zklKVYi|(1V?n$U z4*>dr3B!xOzxb<LN1Z2Y7Zvl`g3BR5*rH9W9d(|5^wQ2ZUZVN2;x{W>#bo8R;`Qs_ zE$oZ8pEj<4c5U?;m)pgcuWZb~+e?0!EwJUXVkKJw*J`!O|2pY(ID*m)P)*o*wv;An z%q4WBgdMz<Olx1#Z^RkULU$-q$<*7!6f)vj=D9GtavvImo_bDs-wS_cDlL0l63QSw zR2;|1(-1Vmr_zno++TE>?w0E)V>^|x$P;dYTB}y4_4TiB%sdU2J98rK9rw(ZYn?P& ziyu9=SJJ2ryxBm25B~0SK*8-rS=ZMA-enYcRJmRBX_q!|i(fguvf85A=E;>P5lfve z=23plcJsue;Tf@bj@3N41sFI^S)KDL<g#EG9rNenm)E~DE0MOmxXkKm@xBt)RktNH zw+UU3JS|CSbWpZRXq0n<ON<!buM!pW#HcLHI5(rJkXyc#_@I-^!0`NJS%`LN{cP@{ zUi|x&{}%sReWQ5r>i#VZFmM5*ikn5*D|$RSD*pEB7aswX2ry%qmujn7h9y|Uh1VP= zz+(UuvEo)F#F7L&H%%`a3$TLg35FJf%HR&f%DjFTe|hbqTcHr{Q0&s_Tu7HV2~X!Y zhiL^O?*i?fYdQ{9w#(;YZ}qK$t$wfg)9P`tdE@QbL(2rDE2yNC8ki1#JT265sg0Lv z`p;kgq0G~B)Qe_=GOtb1B1sFeW!i>IAq|6)pO1dgrGXMcO`vkov{YII><4F6xmYM4 z#@u67Gk+-J80Z|Y2+MQTu{L$orQmuA=r^jOb)mnOO+B|+Y*Ed(wu4g#;sq!5UL)vr zg1xQd(?(C8G<u!-o?qL$i|uxEd+RnHPfpxgJse{8t!=KoQykp*{)~LyhX<YR;lusz z!TsmRbTGjrfQn6&PO9J||31R>bS`inv|iE6x$zu9zbfWU+UYjwV7KW@2X7m|&rbg1 zUCf(>z-K9PJi{cz%aETIl?wVf;wK%FezW-L&2P`bonE*XFvp@uyU0a}a;4sXnk&m4 z)?&;u=OONr#3kvuON2|T<Cz;BZ?@h$aU<<gmu5qE79~!k&cB{GCEVDdEzUTTF(Dip zDIIQCa_CGR;eYHv(R+eB>xJ6c#MyyT*4YvMKn-WL%Uv$qIn(uCbbPvtc7%Jo;V^9? zWxONQ{CDxk8|(*?Pw0>j4<3(${@R77Wzg#-ck0#eJ~}n5)r<e#xWD#QOU$>0A%4#7 Pn#FH#-YNci^LPIT(U73U delta 945 zcmZ9K&rcIU6vwF&l*Wd{3Rn`GM?=VNDQ(f{0T3ckB9WBHkB~?Uo$fxi8@4lbW>%<( z)tg7nc=8`G-ZWgf=)tpI`~y7r2YB=5?D9j)9(LcpH}BWyJ6~=e4S)Q7v9*F!x)ePD zvPL9Wgi<DfS(eYir9c_46AvWr$#Liaqqw$cg5wiz3mx`!CRohIOf;8+$Z233sf9LF zJ!b8QEUebB2_8z_0Z)@8Csb@R-l_}WJ_Ys!IW#yE7*yJIWsUg~Fz`y|TQ!$DVI%#b zQjeUCYC}kvU79OwtrUu@3&p}B+=Wy}YT_g`v$!<-pe+{Bv1l%xjzSvdJtB1>^}OrP zC8#!o+0>iPcq@nr$=yanx6FdEMs4K6VjLlb^+t^dSERrdV58?&krRnt6N*c#Cs}&$ zpD*5mD`A)Uo*k4!%{5rvKPwfmgGv=0mu_)<?4ytsAk>CR_nrJ!KA*2-&BI@pXCi7k zx_+yDk%OWA6R;jqN7i5z#<Z{f(BY@(<OS6gGrKj-h3#;=^;j~I2?wb%TMMGK6F~G{ zlCi8Bj%5%1*il5??=jWGt3T$C`3B-HqXtWH7nac}>@|$a(0&{C1EW;3N6hoyN(NGn z`kok26Y;q#|7WO|@&0zlyZAQ~j2*;AIH0Y8xRgkj(U2x_wB!POq3@40)#q#bagNgG z6g*NzFRS&c8>V{Qo9O=c_n|j=Bd5<3&$YlHLLzs?4(Ll)2S&Z7slN7>lE&t<jm>n~ zETs+oEz$CbHgk@ieQ0dxW;p&}UsARD`*hpH^~q9hdgI>cBQpB5ys<e|PEUnrQ))0l ox{hI~_ldbe6!g>1tX-iqXy@D^wUn6&3aFlek<P$U;{&h$0=h#oasU7T diff --git a/web/scripts/lib/pages.ts b/web/scripts/lib/pages.ts index a23f5a4..2b4f1f2 100644 --- a/web/scripts/lib/pages.ts +++ b/web/scripts/lib/pages.ts @@ -121,7 +121,7 @@ function legalPage(doc: (typeof legalDocs)[number]): ParsedPage { const lines: string[] = []; for (const section of doc.sections) { lines.push(`## ${section.title}`, ""); - lines.push(...section.paragraphs, ""); + for (const p of section.paragraphs) lines.push(p, ""); } return { route: doc.slug, diff --git a/web/src/layouts/BaseLayout.astro b/web/src/layouts/BaseLayout.astro index 3226c69..9fbf2bb 100644 --- a/web/src/layouts/BaseLayout.astro +++ b/web/src/layouts/BaseLayout.astro @@ -22,6 +22,8 @@ export interface Props { noindex?: boolean; ogType?: string; jsonLd?: object | object[]; + /** Emit .md/.txt rel=alternate links (default true; false for the 404). */ + alts?: boolean; } const { @@ -32,6 +34,7 @@ const { noindex = false, ogType = "website", jsonLd, + alts = true, } = Astro.props; const SITE = "https://gpui-query.freeoxide.com"; @@ -62,8 +65,8 @@ const altHref = (ext: string) => `${altBase === "" ? "/index" : altBase}${ext}`; <link rel="manifest" href="/manifest.json" /> {/* Token-light alternates (markdown + plain text) for AI agents. */} - <link rel="alternate" type="text/markdown" href={altHref(".md")} /> - <link rel="alternate" type="text/plain" href={altHref(".txt")} /> + {alts && <link rel="alternate" type="text/markdown" href={altHref(".md")} />} + {alts && <link rel="alternate" type="text/plain" href={altHref(".txt")} />} {/* OpenGraph */} <meta property="og:title" content={title} /> diff --git a/web/src/pages/404.astro b/web/src/pages/404.astro index 4aa8609..dedbbed 100644 --- a/web/src/pages/404.astro +++ b/web/src/pages/404.astro @@ -3,7 +3,7 @@ import BaseLayout from "../layouts/BaseLayout.astro"; // Starlight is configured with disable404Route: true, so this page owns the // site-wide 404. (Ported from the NotFoundPage in routes/__root.tsx.) --- -<BaseLayout title="Page not found - gpui-query" canonicalPath="/404" noindex> +<BaseLayout title="Page not found - gpui-query" canonicalPath="/404" noindex alts={false}> <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="flex min-h-[60vh] flex-col items-center justify-center text-center"> <h1 class="text-8xl font-extrabold tracking-tighter text-primary">404</h1> From 6b8fdd1e5718d2644a753441862632d58e467394 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:16:53 +0200 Subject: [PATCH 005/111] fix: import AppContext for release-profile cx.new() in use_query_manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #[cfg(not(debug_assertions))] fallback calls cx.new() without the AppContext trait in scope, so the crate fails to compile in any release build (cargo check/build --release). Dev builds never compile the branch, which masked it. Published 0.1.4 and 0.2.0 both carry this — any app taking gpui-query with the hook feature cannot build --release. --- crates/gpui-query/src/hook/query_hooks.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index 37126b4..59d8d17 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -21,6 +21,10 @@ //! Each site repeats a short form of this rationale next to its `detach()`. use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; +// Only the release-profile fallback below calls `AppContext::new`; importing +// it unconditionally warns as unused in dev builds, hence the cfg gate. +#[cfg(not(debug_assertions))] +use gpui::AppContext as _; use crate::client::{QueryClient, QueryObserver}; use crate::core::{Fetched, QueryFetchMode, QueryKey, QueryResource, QuerySignal, QueryStatus}; From eb1e1bf474ee84bcd07f1ebd7a8d1775728aea7a Mon Sep 17 00:00:00 2001 From: hmziqrs <hmziqrs@gmail.com> Date: Sat, 22 Aug 2026 15:21:23 +0500 Subject: [PATCH 006/111] chore: remove completed astro migration plan --- .gitignore | 5 - astro-migration.mdx | 782 ------------------------ web/astro.config.mjs | 11 +- web/src/components/overrides/Head.astro | 1 - web/src/content.config.ts | 1 - web/src/layouts/BaseLayout.astro | 1 - web/src/pages/faq.astro | 3 +- web/src/styles/starlight.css | 2 +- 8 files changed, 5 insertions(+), 801 deletions(-) delete mode 100644 astro-migration.mdx diff --git a/.gitignore b/.gitignore index 56d5ef2..a4c48a1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ dist/ .output/ .tanstack/ -# Generated docs (built from docs/ by build-docs.mjs) -web/public/docs/ - # OS .DS_Store Thumbs.db @@ -23,6 +20,4 @@ Thumbs.db .wrangler/cache -.astro-migration-baseline/ - .astro diff --git a/astro-migration.mdx b/astro-migration.mdx deleted file mode 100644 index c794e37..0000000 --- a/astro-migration.mdx +++ /dev/null @@ -1,782 +0,0 @@ ---- -sidebar_position: 6 -title: Web and docs migration to Astro + Starlight -description: "Audited implementation plan for migrating gpui-query from TanStack Start and Docusaurus to Astro with Starlight documentation while preserving URLs, SEO, themes, chart tokens, components, search, and Cloudflare Pages deployment." ---- - -This page is the working plan for migrating the `web/` app from TanStack Start to Astro and the documentation from Docusaurus to Starlight. The goal is not a redesign. The goal is to preserve the current public behavior, visual system, docs URLs, SEO output, generated assets, search, and Cloudflare Pages deployment while replacing the framework surface. - -## Audit summary - -The previous Astro plan was missing the required Starlight migration. It treated `docs/` as a Docusaurus site that would continue to be built separately and copied into `web/public/docs`. That is no longer the target. - -Required corrections: - -- Use Starlight for documentation, not Docusaurus. -- Prefer one final Astro build in `web/` that owns both marketing/blog pages and Starlight docs. -- Keep docs under `/docs/**`. -- Keep the current visual system: shared tokens, fonts, dark mode behavior, chart colors, components, icon policy, and generated OG image style. -- Replace Docusaurus-specific docs assumptions: `docusaurus.config.ts`, `_category_.json`, `sidebars.ts`, Infima CSS variables, swizzled logo, Docusaurus client analytics, and Docusaurus build/copy script. -- Convert docs content carefully: sidebar order, root intro page, internal links, admonitions/asides, code highlighting, static assets, SEO fallback image, and generated markdown/llms scripts. -- Keep one Pagefind index for the final combined output. Do not let Starlight and the existing post-build Pagefind step create competing `/pagefind` indexes. - -Repo audit notes: - -- Current docs content is mostly portable Markdown/MDX. -- Current docs ordering depends on Docusaurus `sidebar_position` and `_category_.json`. -- The `advanced` section has duplicate `sidebar_position: 2` values for `devtools.mdx` and `observers.mdx`; use an explicit Starlight sidebar order instead of relying only on autogeneration. -- Current docs root depends on `docs/docs/intro.mdx` with `slug: /`; in Starlight this should become `web/src/content/docs/docs/index.mdx`. -- Current docs contain root-absolute links like `/api/queries`, `/guides/caching`, and `/getting-started/installation`. Under Starlight at `/docs`, these must become `/docs/api/queries`, `/docs/guides/caching`, `/docs/getting-started/installation`, or relative links. -- Current docs styling in `docs/src/css/custom.css` is Docusaurus/Infima-specific. Reuse the design intent and tokens, not the selectors as-is. -- Current chart colors only exist as CSS variables in `web/src/styles.css`; no chart component or chart library was found. Preserve those variables and move them into shared token scope before adding Starlight docs components that may use charts. -- Current `web/README.md`, `web/AGENTS.md`, `justfile`, `web/scripts/build-docs.ts`, `web/scripts/generate-og-images.ts`, `web/scripts/generate-llms-txt.ts`, and `web/scripts/generate-md-alt.ts` all contain Docusaurus or TanStack Start assumptions that must be updated. - -## Current state - -The repository has three surfaces that the migration must respect: - -- `web/`: marketing/blog site currently built with TanStack Start, Vite+, React, Tailwind v4, Pagefind, Firebase Analytics, and Cloudflare Pages. -- `docs/`: Docusaurus documentation site, currently built separately and copied into `web/public/docs`. -- Rust crates: not part of this migration, except that website and docs links point to crate APIs, changelog entries, and release metadata. - -Important existing behavior: - -- The deployed output is static content from `web/dist/client`. -- Canonical URLs intentionally avoid trailing slashes for app pages. -- The current build emits flat files such as `faq.html`, `blog.html`, and `privacy.html` so Cloudflare Pages does not redirect canonical app URLs. -- Documentation remains publicly served under `/docs/`. -- Pagefind indexes the final combined output, including docs and blog pages. -- Blog posts live in `web/src/content/blog/*.mdx`. -- Docs pages currently live in `docs/docs/**/*.mdx`. -- OG images, `llms.txt`, `llms-full.txt`, and Markdown alternatives are generated by scripts in `web/scripts`. -- There is no current server-function or API-route surface in `web/src`. - -The first Astro target should therefore be a static Astro site, not an SSR Worker app. - -## Target architecture - -Use Astro static output for the full public site and Starlight for documentation. - -Final target: - -- `web/astro.config.mjs` owns the Astro build. -- Marketing/blog/legal pages live in `web/src/pages`. -- Blog posts stay in `web/src/content/blog`. -- Starlight docs live in `web/src/content/docs/docs`. -- `web/src/content/docs/docs/index.mdx` renders `/docs/`. -- `web/src/content/docs/docs/getting-started/installation.mdx` renders `/docs/getting-started/installation`. -- `web/dist/client` remains the deploy directory. -- Cloudflare Pages remains the deploy target. - -Do not keep Docusaurus as a permanent second build. A temporary side-by-side phase is fine for parity comparison, but the final migration should remove the Docusaurus app and the copy-to-`web/public/docs` pipeline. - -Do not introduce `@astrojs/cloudflare` unless we add on-demand routes, API handlers, or Cloudflare bindings later. - -## Astro and Starlight configuration - -Use a single static Astro config with React islands, MDX, Tailwind, and Starlight: - -```js -// web/astro.config.mjs -import { defineConfig } from "astro/config"; -import react from "@astrojs/react"; -import mdx from "@astrojs/mdx"; -import starlight from "@astrojs/starlight"; -import tailwindcss from "@tailwindcss/vite"; - -export default defineConfig({ - site: "https://gpui-query.freeoxide.com", - trailingSlash: "never", - output: "static", - outDir: "dist/client", - build: { - format: "file", - }, - integrations: [ - react(), - mdx(), - starlight({ - title: "gpui-query Docs", - logo: { - src: "./src/assets/logo.svg", - }, - customCss: ["./src/styles/starlight.css"], - // First cut: keep the existing post-build Pagefind index for the - // combined marketing/blog/docs output. Re-enable Starlight search only - // if it is wired to the same final index or replaces the global search. - pagefind: false, - social: [ - { - icon: "github", - label: "GitHub", - href: "https://github.com/freeoxide/gpui-query", - }, - ], - sidebar: [ - { label: "Introduction", link: "/docs/" }, - { - label: "Getting Started", - items: [ - { label: "Installation", link: "/docs/getting-started/installation" }, - { label: "Quick Start", link: "/docs/getting-started/quick-start" }, - ], - }, - { - label: "API Reference", - items: [ - { label: "Queries", link: "/docs/api/queries" }, - { label: "Mutations", link: "/docs/api/mutations" }, - { label: "Infinite Queries", link: "/docs/api/infinite-queries" }, - { label: "QueryClient", link: "/docs/api/query-client" }, - ], - }, - { - label: "Guides", - items: [ - { label: "Caching", link: "/docs/guides/caching" }, - { label: "Error handling", link: "/docs/guides/error-handling" }, - { label: "Retry", link: "/docs/guides/retry" }, - { label: "Persistence", link: "/docs/guides/persistence" }, - { label: "Query Keys", link: "/docs/guides/query-keys" }, - { label: "The Select Pattern", link: "/docs/guides/select-pattern" }, - ], - }, - { - label: "Advanced", - items: [ - { label: "Devtools", link: "/docs/advanced/devtools" }, - { label: "Observers", link: "/docs/advanced/observers" }, - { label: "gpui-query vs. raw async", link: "/docs/advanced/comparison" }, - { label: "API Reference", link: "/docs/advanced/api-reference" }, - { label: "Migrating from v1 to v2", link: "/docs/advanced/migration" }, - ], - }, - ], - credits: false, - disable404Route: true, - }), - ], - vite: { - plugins: [tailwindcss()], - resolve: { - alias: { - "#": new URL("./src", import.meta.url).pathname, - "@": new URL("./src", import.meta.url).pathname, - }, - }, - }, -}); -``` - -The sidebar is intentionally explicit for the first cut because the current Docusaurus docs have one duplicate `sidebar_position` and many root-absolute links. During implementation, verify that Starlight route IDs and generated links match `/docs/**` exactly before considering autogeneration. - -Do not set Astro's `base` to `/docs`. The whole site is served from the domain root. The docs subpath comes from placing Starlight content under `src/content/docs/docs/**`. - -Add a combined content config: - -```ts -// web/src/content.config.ts -import { defineCollection } from "astro:content"; -import { glob } from "astro/loaders"; -import { z } from "astro/zod"; -import { docsLoader } from "@astrojs/starlight/loaders"; -import { docsSchema } from "@astrojs/starlight/schema"; - -const blog = defineCollection({ - loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }), - schema: z.object({ - title: z.string(), - description: z.string(), - date: z.coerce.date(), - author: z.string(), - tags: z.array(z.string()).optional(), - }), -}); - -export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), - blog, -}; -``` - -Useful references: - -- Astro configuration: https://docs.astro.build/en/reference/configuration-reference/ -- Astro React integration: https://docs.astro.build/en/guides/integrations-guide/react/ -- Astro MDX integration: https://docs.astro.build/en/guides/integrations-guide/mdx/ -- Astro content collections: https://docs.astro.build/en/guides/content-collections/ -- Starlight manual setup: https://starlight.astro.build/manual-setup/ -- Starlight at a subpath: https://starlight.astro.build/manual-setup/#use-starlight-at-a-subpath -- Starlight sidebar: https://starlight.astro.build/guides/sidebar/ -- Starlight customization: https://starlight.astro.build/guides/customization/ -- Starlight configuration: https://starlight.astro.build/reference/configuration/ -- Starlight search: https://starlight.astro.build/guides/site-search/ - -## Route and content mapping - -| Current route or source | Astro/Starlight target | Migration notes | -| --- | --- | --- | -| `web/src/routes/__root.tsx` | `web/src/layouts/BaseLayout.astro` | Move HTML shell, theme bootstrap, global CSS, navbar, footer, analytics, and metadata defaults. | -| `web/src/routes/index.tsx` | `web/src/pages/index.astro` | Render the current V4 landing page. Keep interactive landing widgets as React islands initially. | -| `web/src/routes/blog/index.tsx` | `web/src/pages/blog.astro` | Replace custom frontmatter-only glob with Astro content collection queries. | -| `web/src/routes/blog/$slug.tsx` | `web/src/pages/blog/[slug].astro` | Use `getStaticPaths()` and `render(post)`. Preserve prev/next navigation and JSON-LD. | -| `web/src/routes/faq.tsx` | `web/src/pages/faq.astro` | Prefer static HTML or native `details`/`summary` before keeping a React accordion. Preserve FAQ JSON-LD. | -| `web/src/routes/changelog.tsx` | `web/src/pages/changelog.astro` | Static page. Keep release data accurate to `CHANGELOG.md`. | -| `web/src/routes/privacy.tsx` | `web/src/pages/privacy.astro` | Static legal page. | -| `web/src/routes/terms.tsx` | `web/src/pages/terms.astro` | Static legal page. | -| `web/src/routes/v1.tsx` through `v4.tsx` | omit or generate conditionally | Preview routes are currently disabled. Keep them out of sitemap and output unless the flag is enabled. | -| `docs/docs/intro.mdx` | `web/src/content/docs/docs/index.mdx` | Remove `slug: /`. Preserve title and description. This is `/docs/`. | -| `docs/docs/getting-started/*.mdx` | `web/src/content/docs/docs/getting-started/*.mdx` | Convert sidebar frontmatter and links. | -| `docs/docs/api/*.mdx` | `web/src/content/docs/docs/api/*.mdx` | Convert sidebar frontmatter and links. | -| `docs/docs/guides/*.mdx` | `web/src/content/docs/docs/guides/*.mdx` | Convert sidebar frontmatter and links. | -| `docs/docs/advanced/*.mdx` | `web/src/content/docs/docs/advanced/*.mdx` | Convert sidebar frontmatter and links. | -| `docs/docs/**/_category_.json` | Starlight `sidebar` config | Preserve labels, order, and expanded/collapsed intent. Remove after parity. | -| `docs/sidebars.ts` | Starlight `sidebar` config | Replace Docusaurus autogenerated sidebar. | -| `docs/static/img/logo.svg` | `web/src/assets/logo.svg` or `web/public/img/logo.svg` | Use Starlight logo config. Preserve header/homepage behavior. | -| `docs/src/theme/Logo/index.tsx` | Starlight component override if needed | Current Docusaurus swizzle makes the docs logo link to `/`. Preserve this with a Starlight override if default logo behavior links only to docs home. | -| `docs/src/css/custom.css` | `web/src/styles/starlight.css` | Rebuild against Starlight CSS variables and selectors. Keep visual intent, tokens, fonts, and density. | - -## Phase 1: Baseline and inventory - -1. Build the current site before changing frameworks. -2. Save a URL and artifact manifest from `web/dist/client`. -3. Save rendered HTML snapshots for representative pages: - - `/` - - `/blog` - - `/blog/cache-policies-explained` - - `/blog/cooperative-cancellation` - - `/faq` - - `/changelog` - - `/privacy` - - `/terms` - - `/docs/` - - `/docs/getting-started/installation` - - `/docs/api/queries` - - `/docs/guides/caching` - - `/docs/advanced/migration` -4. Record required generated files: - - `/index.html` - - `/blog.html` - - `/blog/*.html` - - `/faq.html` - - `/changelog.html` - - `/privacy.html` - - `/terms.html` - - `/docs/**` - - `/pagefind/**` - - `/llms.txt` - - `/llms-full.txt` - - `/docs/**/*.md` - - `/og/**` - - `/og-image.png` - - `/sitemap.xml` - - `/docs/sitemap.xml` -5. Record current theme and component details: - - `shared/tokens.css` - - `web/src/styles.css` - - `docs/src/css/custom.css` - - `web/components.json` - - `web/src/components/ui/**` - - `web/src/components/navbar.tsx` - - `web/src/components/search-dialog.tsx` - - `web/src/components/theme-toggle.tsx` -6. Record docs content details: - - `_category_.json` labels and order - - `sidebar_position` - - `slug` - - root-absolute links - - `:::` admonitions - - static assets - - code block language coverage - -## Phase 2: Scaffold Astro and Starlight - -Add Astro dependencies while keeping TanStack and Docusaurus dependencies in place for comparison: - -```bash -cd web -bun add astro @astrojs/react @astrojs/mdx @astrojs/starlight -``` - -Add temporary scripts so old and new builds can be compared: - -```json -{ - "scripts": { - "astro:dev": "astro dev --port 3000", - "astro:build": "astro build", - "astro:preview": "astro preview" - } -} -``` - -Create: - -- `web/astro.config.mjs` -- `web/src/content.config.ts` -- `web/src/layouts/BaseLayout.astro` -- `web/src/components/HeadMeta.astro` or a small metadata helper -- `web/src/styles/starlight.css` -- `web/src/pages/404.astro` -- `web/src/content/docs/docs/**` - -Do not remove the old routes until the Astro output matches. - -## Phase 3: Starlight docs migration - -Move documentation content into Starlight: - -1. Copy `docs/docs/**/*.mdx` to `web/src/content/docs/docs/**/*.mdx`. -2. Rename `docs/docs/intro.mdx` to `web/src/content/docs/docs/index.mdx`. -3. Remove Docusaurus-only `slug: /` from the new docs index. -4. Convert `sidebar_position` to Starlight sidebar ordering. -5. Convert `_category_.json` labels and positions to the Starlight `sidebar` config. -6. Rewrite root-absolute internal docs links: - - `/getting-started/installation` -> `/docs/getting-started/installation` - - `/api/queries` -> `/docs/api/queries` - - `/guides/caching` -> `/docs/guides/caching` - - `/advanced/migration` -> `/docs/advanced/migration` -7. Verify all current `:::note`, `:::tip`, and `:::caution` blocks render as Starlight asides. Convert only the blocks Starlight does not accept. -8. Preserve Rust, TOML, shell, TypeScript, and TSX code highlighting. Starlight uses Astro/Shiki rather than Docusaurus Prism, so compare light and dark code blocks visually. -9. Move docs static assets into Astro/Starlight paths: - - logo - - favicon - - docs OG fallback image -10. Preserve docs metadata: - - title - - description - - canonical URL - - docs OG fallback - - docs sitemap -11. Replace the Docusaurus analytics client module with a Starlight/Astro-compatible analytics script or component. -12. Keep `docs/` until parity is proven, then remove Docusaurus files and dependencies. - -Do not copy the old Docusaurus build into `web/public/docs` in the final architecture. - -### Docs inventory to preserve - -Use this as the exact initial Starlight sidebar/content inventory. Autogeneration is acceptable only after these routes, labels, and order are verified. - -| Current source | Target route | Title | Order notes | -| --- | --- | --- | --- | -| `docs/docs/intro.mdx` | `/docs/` | Introduction | Top-level docs root. Remove `slug: /`. | -| `docs/docs/getting-started/installation.mdx` | `/docs/getting-started/installation` | Installation | Getting Started, position 1. | -| `docs/docs/getting-started/quick-start.mdx` | `/docs/getting-started/quick-start` | Quick Start | Getting Started, position 2. | -| `docs/docs/api/queries.mdx` | `/docs/api/queries` | Queries | API Reference, position 1. | -| `docs/docs/api/mutations.mdx` | `/docs/api/mutations` | Mutations | API Reference, position 2. | -| `docs/docs/api/infinite-queries.mdx` | `/docs/api/infinite-queries` | Infinite Queries | API Reference, position 3. | -| `docs/docs/api/query-client.mdx` | `/docs/api/query-client` | QueryClient | API Reference, position 4. | -| `docs/docs/guides/caching.mdx` | `/docs/guides/caching` | Caching | Guides, position 1. | -| `docs/docs/guides/error-handling.mdx` | `/docs/guides/error-handling` | Error handling | Guides, position 2. | -| `docs/docs/guides/retry.mdx` | `/docs/guides/retry` | Retry | Guides, position 3. | -| `docs/docs/guides/persistence.mdx` | `/docs/guides/persistence` | Persistence | Guides, position 4. | -| `docs/docs/guides/query-keys.mdx` | `/docs/guides/query-keys` | Query Keys | Guides, position 5. | -| `docs/docs/guides/select-pattern.mdx` | `/docs/guides/select-pattern` | The Select Pattern | Guides, position 6. | -| `docs/docs/advanced/devtools.mdx` | `/docs/advanced/devtools` | Devtools | Advanced, explicit before Observers. | -| `docs/docs/advanced/observers.mdx` | `/docs/advanced/observers` | Observers | Advanced, duplicate old position 2. Keep explicit. | -| `docs/docs/advanced/comparison.mdx` | `/docs/advanced/comparison` | gpui-query vs. raw async | Advanced, position 3. | -| `docs/docs/advanced/api-reference.mdx` | `/docs/advanced/api-reference` | API Reference | Advanced, position 4. | -| `docs/docs/advanced/migration.mdx` | `/docs/advanced/migration` | Migrating from v1 to v2 | Advanced, position 5. | - -Top-level section order from `_category_.json`: - -1. Introduction -2. Getting Started -3. API Reference -4. Guides -5. Advanced - -Root-absolute docs links are widespread across the current docs, not isolated examples. Rewrite all links that currently start with `/api/`, `/guides/`, `/getting-started/`, or `/advanced/` before comparing the Starlight output. - -## Phase 4: Theme, chart, and component parity - -The migration must keep the same themes, chart colors, and components. - -### Shared tokens - -Keep `shared/tokens.css` as the source of truth and make both Astro pages and Starlight docs consume it. - -Move or duplicate these current web-only variables into shared scope before docs components or charts rely on them: - -```css -:root { - --chart-1: oklch(0.828 0.111 230.318); - --chart-2: oklch(0.685 0.169 237.323); - --chart-3: oklch(0.588 0.158 241.966); - --chart-4: oklch(0.5 0.134 242.749); - --chart-5: oklch(0.443 0.11 240.79); -} -``` - -No current chart component or chart library was found in `web/` or `docs/`. Treat chart parity as token parity for this migration, then use those same variables for any future chart component. - -### Starlight CSS - -Do not paste `docs/src/css/custom.css` into Starlight unchanged. It targets Docusaurus and Infima selectors. Recreate the theme against Starlight variables and stable Starlight selectors. - -Minimum `web/src/styles/starlight.css` responsibilities: - -- import `shared/tokens.css` -- use the same fonts as the web app -- map Starlight accent, gray, background, border, text, code, sidebar, and TOC colors to shared tokens -- expose `--chart-1` through `--chart-5` -- preserve the small-radius/sharp visual identity -- preserve light and dark mode -- preserve code block density and table readability -- avoid broad global resets that break Starlight internals - -### Theme behavior - -The existing site uses: - -- `.dark` on `<html>` -- `[data-theme='dark']` in shared tokens for Docusaurus -- `localStorage.theme` -- theme-color meta updates - -Starlight has its own theme UI. The migration must define one site-wide theme contract: - -- Keep `localStorage.theme` as the existing web contract unless there is a deliberate reason to change it. -- Ensure Starlight dark mode sets selectors that shared tokens understand. -- Ensure marketing pages and docs use the same light/dark palette. -- Ensure docs theme changes update the page without fighting the marketing `ThemeToggle`. -- Preserve the dark-mode flash prevention script for Astro pages. -- Verify `meta[name="theme-color"]` is correct in light and dark mode. - -If Starlight's default theme selector uses a different local storage key, add a tiny bridge script or replace the Starlight theme control with a component override. - -### Components - -Preserve the existing component system first: - -- Keep `web/src/components/ui` during the first cut. -- Keep Base UI React components for interactive islands where replacing them adds risk. -- Keep the current navbar, theme toggle, mobile nav, and search behavior unless a Starlight override is required for docs parity. -- Keep Phosphor as the preferred icon family for new or touched shared UI because `web/components.json` declares `"iconLibrary": "phosphor"`. -- Do not do a migration-wide icon swap. -- Existing Lucide icons may stay where they already exist until a component is intentionally touched. -- If an Astro-only component needs an icon, first consider inline SVG copied from existing usage or a tiny local Astro icon wrapper. - -For docs MDX components: - -- Prefer Starlight built-ins for asides, badges, file trees, link cards, steps, and tabs when they match the current design. -- Add local Astro wrappers only when the current docs visual language needs a specific button, badge, card, callout, or chart treatment. -- Style any custom docs component from shared tokens, not from new one-off colors. -- Do not initialize a full shadcn/ui Astro template over this repo. -- Evaluate shadcn/ui for Astro only as a follow-up for components that are being rewritten anyway. - -## Phase 5: Astro shell and web pages - -Move these concerns out of `web/src/routes/__root.tsx`: - -- `html lang="en"` -- theme bootstrap script -- global stylesheet import -- favicon and manifest links -- skip-to-content link -- navbar -- footer -- Firebase Analytics loader -- development-only tools -- 404 and error UI - -The layout should accept explicit metadata props: - -```ts -type PageMeta = { - title: string; - description?: string; - canonicalPath: string; - image?: string; - robots?: string; - jsonLd?: unknown; -}; -``` - -Do not rely on implicit route metadata until canonical behavior has been verified. - -Migrate web pages from lowest to highest interaction risk: - -1. Legal pages: `privacy`, `terms`. -2. `changelog`. -3. `faq`. -4. `blog` index. -5. `blog/[slug]`. -6. Home page. -7. Disabled landing previews, if we decide to keep them. - -For each page: - -- Port route metadata first. -- Port visible HTML second. -- Compare rendered HTML for title, description, canonical, OG, Twitter, and JSON-LD tags. -- Verify generated output paths match the old no-trailing-slash app scheme. -- Verify links to `/docs/**` point to the new Starlight docs. - -## Phase 6: Blog content collections - -Keep blog posts in `web/src/content/blog/*.mdx`. - -Then: - -1. Rebuild `/blog` with `getCollection("blog")`. -2. Rebuild `/blog/[slug]` with `getStaticPaths()`. -3. Use `render(post)` for the MDX body. -4. Preserve the existing JSON-LD shape from `web/src/lib/seo.ts`. -5. Preserve generated OG paths: `/og/blog/<slug>.png`. -6. Preserve date sorting and prev/next post navigation. -7. Delete `web/src/lib/blog.ts`, `web/src/lib/blog-content.ts`, and `mdxFrontmatterOnly()` only after the blog output matches. - -## Phase 7: React island strategy - -The initial migration should keep React where the current component is interactive: - -- `Navbar` -- `ThemeToggle` -- `SearchDialog` -- mobile nav sheet -- landing demos with state, timers, mouse tracking, copy buttons, or tabs - -Use Astro components for static wrappers and pages. - -When a React component stays as an island: - -- Replace TanStack Router `Link` with plain `<a>` or a small local link wrapper. -- Keep props serializable. -- Hydrate with the narrowest directive that works: - - `client:load` for navbar/search/theme controls. - - `client:visible` for below-the-fold landing demos. - - `client:idle` for analytics or non-critical enhancements. - -After parity, reduce React islands in a second pass: - -- Convert footer to Astro. -- Convert legal page wrappers to Astro. -- Convert FAQ to static `details`/`summary` if the design remains acceptable. -- Extract `buttonVariants` and `badgeVariants` into framework-neutral files so Astro and React components can share classes. - -## Phase 8: Search and Pagefind - -Current search behavior: - -- `web/src/components/search-dialog.tsx` lazy-loads `/pagefind/pagefind.js`. -- The build runs `npx pagefind --site dist/client` after all site output exists. -- The index includes marketing, blog, and docs pages. - -Starlight also has Pagefind-based search support. Final architecture must choose one Pagefind owner. - -Preferred first-cut policy: - -- Keep the existing post-build `npx pagefind --site dist/client` step because it indexes the combined site. -- Keep the existing custom `SearchDialog` for global search. -- Set `pagefind: false` in the Starlight config for the first cut so Starlight does not generate or expect a separate Pagefind index. -- Override Starlight's `Search` component only if docs need a search button in the Starlight header; the override should open or link to the same global search experience. -- If we keep Starlight's search UI, configure it to use the same final `/pagefind` index and verify there is only one `/pagefind` output. - -Validation: - -- Search opens from the marketing navbar. -- Search opens from docs if docs header exposes it. -- Results include `/docs/**` and `/blog/**`. -- Search result URLs use canonical no-trailing-slash app URLs and valid docs URLs. -- No duplicate Pagefind assets or stale indexes are emitted. - -## Phase 9: Build pipeline - -Keep the final output directory as `dist/client`. - -Target final build script: - -```json -{ - "scripts": { - "build": "rm -rf dist && node scripts/generate-og-images.ts && astro build && npx pagefind --site dist/client && node scripts/generate-llms-txt.ts --output dist/client && node scripts/generate-md-alt.ts --output dist/client" - } -} -``` - -Update scripts: - -- `scripts/build-docs.ts`: remove in the final architecture. It only exists for Docusaurus. -- `scripts/generate-og-images.ts`: stop writing docs OG fallback to `../docs/static/img/og-docs.png`; write it into `web/public/og/docs.png` or another Astro-owned public path. -- `scripts/generate-llms-txt.ts`: read docs from `web/src/content/docs/docs`, not `../docs/docs`. -- `scripts/generate-md-alt.ts`: read docs from `web/src/content/docs/docs`, not `../docs/docs`. -- `scripts/lib/docs-md.ts`: update comments and route resolution for Starlight-at-`/docs` content. Ensure docs index maps to `/docs/`, not `/docs/docs`. - -Do not generate Markdown alternatives before Astro build if Astro deletes `dist/client`; keep them after build. - -## Phase 10: Sitemap, robots, and SEO - -Preserve the current URL policy: - -- App pages have no trailing slash. -- Blog post `lastmod` comes from frontmatter dates. -- Disabled preview routes are excluded. -- Docs remain under `/docs/**`. -- `robots.txt` continues to advertise app and docs sitemaps unless we intentionally change the sitemap structure. - -Current expected sitemap entries: - -- `https://gpui-query.freeoxide.com/sitemap.xml` -- `https://gpui-query.freeoxide.com/docs/sitemap.xml` - -Starlight can generate sitemap data when `site` is configured, but verify whether it emits the exact `/docs/sitemap.xml` artifact we currently advertise. If it cannot preserve the existing docs sitemap path, create a custom endpoint or update `robots.txt` only after an explicit SEO decision. - -SEO parity checks: - -- App canonical tags keep no trailing slash. -- Docs canonical tags are under `/docs/**`. -- Blog JSON-LD still renders as `BlogPosting`. -- Blog index JSON-LD still renders as `Blog`. -- FAQ JSON-LD still includes every visible question and answer. -- Changelog JSON-LD still reports the latest version. -- Homepage JSON-LD still renders as `SoftwareSourceCode`. -- OG images resolve for homepage, blog, blog posts, changelog, FAQ, and docs. -- `robots.txt` does not point at a missing sitemap. - -## Phase 11: Cloudflare deployment and cleanup - -After the Astro build is the default: - -1. Keep Cloudflare Pages deploy from `dist/client`. -2. Remove TanStack Start's Worker entry from `web/wrangler.jsonc`. -3. Remove old `run_worker_first` rules if they only existed for TanStack Start dev routing. -4. Re-check `assets.html_handling`. The old config used `auto-trailing-slash`; verify it does not reintroduce redirects for canonical no-slash app URLs. -5. Update GitHub workflows only if deploy commands or output paths change. -6. Update `justfile` from `Website (TanStack Start)` to `Website (Astro)`. -7. Update docs recipes from Docusaurus commands to Astro/Starlight commands. -8. Update `web/README.md`. -9. Update `web/AGENTS.md` so it no longer instructs contributors to use Vite+ or TanStack Start. -10. Update footer text from "Built with TanStack Start" to "Built with Astro". - -## Phase 12: Dependency cleanup - -Remove these only after imports and scripts are gone: - -- `@tanstack/react-start` -- `@tanstack/react-router` -- `@tanstack/router-plugin` -- `@tanstack/devtools-vite` -- `@cloudflare/vite-plugin`, if static Pages no longer needs it -- `vite-plus` -- Vite+ overrides for `vite` and `vitest`, if no longer needed -- old `web/vite.config.ts` -- TanStack route tree files -- Docusaurus packages in `docs/package.json` -- `docs/package-lock.json` -- `docs/docusaurus.config.ts` -- `docs/sidebars.ts` -- `docs/src/theme/Logo/index.tsx` -- `docs/src/firebaseAnalytics.ts` -- Docusaurus-specific CSS after it has been ported to Starlight - -Keep initially: - -- `react` -- `react-dom` -- `@astrojs/react` -- `@astrojs/mdx` -- `@astrojs/starlight` -- `@base-ui/react` -- `@phosphor-icons/react` -- existing `lucide-react` usage until those components are touched -- `class-variance-authority` -- `clsx` -- `tailwind-merge` -- `firebase` -- `pagefind` -- `tailwindcss` -- `@tailwindcss/vite` -- `@tailwindcss/typography`, if blog prose still uses it -- `shiki` or Astro/Starlight's code highlighting dependency path - -## Validation checklist - -Run: - -```bash -cd web -bun install --frozen-lockfile -bun run build -npx wrangler pages dev dist/client -``` - -Then verify these routes: - -- `/` -- `/blog` -- `/blog/cache-policies-explained` -- `/blog/cooperative-cancellation` -- `/faq` -- `/changelog` -- `/privacy` -- `/terms` -- `/docs/` -- `/docs/getting-started/installation` -- `/docs/getting-started/quick-start` -- `/docs/api/queries` -- `/docs/api/mutations` -- `/docs/api/infinite-queries` -- `/docs/api/query-client` -- `/docs/guides/caching` -- `/docs/guides/error-handling` -- `/docs/guides/retry` -- `/docs/guides/persistence` -- `/docs/guides/query-keys` -- `/docs/guides/select-pattern` -- `/docs/advanced/comparison` -- `/docs/advanced/devtools` -- `/docs/advanced/observers` -- `/docs/advanced/api-reference` -- `/docs/advanced/migration` -- `/pagefind/pagefind.js` -- `/llms.txt` -- `/llms-full.txt` -- `/sitemap.xml` -- `/docs/sitemap.xml` - -Artifact checks: - -- No generated app page unexpectedly moves from `foo.html` to `foo/index.html`. -- `/docs/` resolves without a 404. -- Docs internal links do not point to root `/api/**`, `/guides/**`, `/advanced/**`, or `/getting-started/**`. -- Generated Markdown alternatives exist under `/docs/**/*.md`. -- `llms.txt` URLs point to `/docs/**`. -- OG docs image is generated from the same OG template as the rest of the site. -- There is only one final Pagefind index. - -SEO checks: - -- Canonical tags match the expected URL list. -- App pages do not redirect from canonical URLs. -- Blog JSON-LD still renders as `BlogPosting`. -- FAQ JSON-LD still includes every visible question and answer. -- Changelog JSON-LD still reports the latest version. -- Docs pages have title and description metadata. -- OG images resolve. -- `robots.txt` advertises only existing sitemaps. - -UI checks: - -- Marketing pages match the current theme. -- Starlight docs match the current docs theme closely enough to count as a migration, not a redesign. -- Light mode and dark mode work on app pages. -- Light mode and dark mode work on docs pages. -- Theme selection stays in sync when moving between app and docs. -- Chart color variables are available in app and docs CSS. -- Navbar mobile menu works. -- Search opens with the button and keyboard shortcut. -- Pagefind returns docs and blog results after a production build. -- Landing interactive panels hydrate only where needed. -- No major layout shift appears from delayed islands. -- Code blocks, tables, asides, badges, and cards are readable in light and dark mode. - -## Merge strategy - -Use three PRs if possible: - -1. Astro web migration with React islands preserved and Docusaurus still available for comparison. -2. Starlight docs migration with URL, theme, search, sitemap, and generated artifact parity. -3. Cleanup PR that removes TanStack Start, Docusaurus, Vite+ assumptions, and temporary comparison code. - -The first production-facing Astro PR should be boring: same pages, same URLs, same design, smaller framework surface. The Starlight PR should be judged by docs parity first: same `/docs/**` URLs, same theme, same content, same search visibility, and no missing generated docs artifacts. diff --git a/web/astro.config.mjs b/web/astro.config.mjs index c60ff42..0c8e201 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -5,8 +5,7 @@ import starlight from "@astrojs/starlight"; import tailwindcss from "@tailwindcss/vite"; // Single static Astro build that owns both the marketing/blog site and the -// Starlight documentation. Mirrors the migration plan in -// astro-migration.mdx (Phase 2 scaffold): +// Starlight documentation: // // - output: static (no SSR Worker — there is no server/API surface today) // - trailingSlash: "ignore" + build.format: "directory" → directory output @@ -21,8 +20,6 @@ import tailwindcss from "@tailwindcss/vite"; // src/content/docs/docs/** (Starlight-at-a-subpath); do NOT set base:"/docs" // - pagefind:false — keep the existing post-build `npx pagefind --site // dist/client` step as the single owner of the combined index for now -// -// See astro-migration.mdx § "Astro and Starlight configuration". export default defineConfig({ site: "https://gpui-query.freeoxide.com", trailingSlash: "ignore", @@ -76,10 +73,8 @@ export default defineConfig({ href: "https://github.com/freeoxide/gpui-query", }, ], - // Explicit sidebar (not autogenerated) because the current Docusaurus - // docs have a duplicate sidebar_position in `advanced` and many - // root-absolute links. Verified route inventory in astro-migration.mdx - // § "Docs inventory to preserve". + // Explicit sidebar (not autogenerated) so the section order and route + // inventory stay stable regardless of file additions. sidebar: [ { label: "Introduction", link: "/docs/" }, { diff --git a/web/src/components/overrides/Head.astro b/web/src/components/overrides/Head.astro index cfdf6cf..e882736 100644 --- a/web/src/components/overrides/Head.astro +++ b/web/src/components/overrides/Head.astro @@ -12,7 +12,6 @@ // `auto-trailing-slash`), and `@astrojs/sitemap` already advertises the // extensionless form. Stripping `.html` from just `canonical` and `og:url` // keeps the head consistent with the sitemap and the rest of the site. -// See astro-migration.mdx § "SEO parity checks". const head = Astro.locals.starlightRoute.head.map((entry) => { const attrs = entry.attrs ?? {}; diff --git a/web/src/content.config.ts b/web/src/content.config.ts index e3c1ffd..c151fde 100644 --- a/web/src/content.config.ts +++ b/web/src/content.config.ts @@ -7,7 +7,6 @@ import { docsSchema } from "@astrojs/starlight/schema"; // Combined content config. The Starlight `docs` collection owns documentation // under src/content/docs/docs/** (served at /docs/**). The `blog` collection // owns the MDX posts under src/content/blog/** (served at /blog/[slug]). -// See astro-migration.mdx § "Astro and Starlight configuration" and § "Phase 6". export const collections = { docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), blog: defineCollection({ diff --git a/web/src/layouts/BaseLayout.astro b/web/src/layouts/BaseLayout.astro index 9fbf2bb..282c20c 100644 --- a/web/src/layouts/BaseLayout.astro +++ b/web/src/layouts/BaseLayout.astro @@ -4,7 +4,6 @@ // footer, Firebase Analytics, and explicit per-page metadata. // // Docs pages are owned by Starlight (its own layout) and are NOT wrapped here. -// See astro-migration.mdx § "Phase 5: Astro shell and web pages". import "../styles.css"; import Navbar from "../components/navbar.astro"; import Footer from "../components/Footer.astro"; diff --git a/web/src/pages/faq.astro b/web/src/pages/faq.astro index f67928c..abe0bfa 100644 --- a/web/src/pages/faq.astro +++ b/web/src/pages/faq.astro @@ -6,8 +6,7 @@ import { faqCategories as categories, faqItems, faqSubtitle, faqDescription } fr // Native <details>/<summary> instead of a React accordion: the answers stay in // the DOM (crawlable for the FAQPage structured data) with no JS. The Q&A data // itself lives once in ../lib/faq-data and is shared with the /faq.md and -// /faq.txt alt-format generator. See astro-migration.mdx § "Route and content -// mapping". +// /faq.txt alt-format generator. const title = "FAQ - gpui-query"; const description = faqDescription; const jsonLd = faqPage({ diff --git a/web/src/styles/starlight.css b/web/src/styles/starlight.css index 8731df5..2a0d995 100644 --- a/web/src/styles/starlight.css +++ b/web/src/styles/starlight.css @@ -2,7 +2,7 @@ gpui-query — Starlight theme Maps Starlight's --sl-* theme variables onto the shared design tokens (shared/tokens.css) so the docs share the marketing site's palette, fonts, - and sharp-radius identity. See astro-migration.mdx § "Starlight CSS". + and sharp-radius identity. Two facts make this work: 1. Starlight's base styles live in @layer starlight.{base,components} (see the From 81b0a33066fd48ca759aecef83555f9f460ef489 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:41:54 +0200 Subject: [PATCH 007/111] chore: depend on gpui-pre (package) instead of crates.io gpui 0.2.2 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8e0f813..c642bfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,5 +6,5 @@ resolver = "3" [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -gpui = { version = "0.2.2" } +gpui = { package = "gpui-pre", version = "0.3" } # gpui = { git = "https://github.com/zed-industries/zed", rev = "501ab50f9b03f1c3a13df11ade804bbdf11146ff" } From 27c8f18e73b61927d43b8d60dc8544d4bae8aab1 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 18 Sep 2026 10:00:43 +0200 Subject: [PATCH 008/111] feat: build core layer for wasm32 via target-gated ahash rng --- .gitignore | 3 +++ crates/gpui-query/Cargo.toml | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a4c48a1..4b081b4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ Thumbs.db .commandcode +# z-workflow state +.z-workflow/ + # Local Playwright/MCP artifacts .playwright-mcp/ diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index 36e2d00..b2f7057 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -22,9 +22,18 @@ persist = ["client", "hook", "dep:serde_json", "dep:thiserror"] serde = { workspace = true } serde_json = { workspace = true, optional = true } thiserror = { version = "2", optional = true } -ahash = { version = "0.8" } gpui = { workspace = true, optional = true } +# ahash's default `runtime-rng` feature pulls getrandom, which hard-errors on +# wasm32-unknown-unknown. The two declarations are mutually exclusive so the +# native build keeps the default feature set (runtime-rng) while wasm uses +# compile-time RNG. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ahash = { version = "0.8" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +ahash = { version = "0.8", default-features = false, features = ["std", "compile-time-rng"] } + [dev-dependencies] serde_json = { workspace = true } gpui = { workspace = true, features = ["test-support"] } From 2d26fd4f6e9a3c3bfd4cd042ca5f450fed2db219 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 18 Sep 2026 10:13:42 +0200 Subject: [PATCH 009/111] fix: make HttpBackend wasm-compatible via cfg-gated MaybeSend bound --- crates/gpui-query-http/src/backend.rs | 51 +++++++++++++++---- crates/gpui-query-http/src/cache.rs | 4 +- crates/gpui-query-http/src/lib.rs | 2 +- crates/gpui-query-http/src/reqwest_backend.rs | 12 +++-- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/crates/gpui-query-http/src/backend.rs b/crates/gpui-query-http/src/backend.rs index 12fc767..da9c2d4 100644 --- a/crates/gpui-query-http/src/backend.rs +++ b/crates/gpui-query-http/src/backend.rs @@ -6,11 +6,14 @@ //! ([`crate::reqwest_backend::ReqwestBackend`]); any other request library can //! implement this trait instead and feed [`crate::HttpCache::new`]. //! -//! The trait deliberately uses `-> impl Future<Output = …> + Send` rather than -//! `async fn` so the returned futures are guaranteed `Send` and usable from any -//! executor (GPUI's `background_executor`, tokio, etc.). This makes the trait -//! non-object-safe; dispatch is generic (`HttpCache<B: HttpBackend>`), which is -//! intentional and avoids `dyn` + `Pin<Box<dyn Future>>` overhead. +//! The trait deliberately uses `-> impl Future<Output = …> + MaybeSend` +//! (see [`MaybeSend`]) rather than `async fn` so the returned futures are +//! guaranteed `Send` on native targets and usable from any executor (GPUI's +//! `background_executor`, tokio, etc.); on `wasm32` the bound is a no-op +//! because JS interop types — and therefore `reqwest`'s browser-fetch futures +//! — are inherently `!Send`. This makes the trait non-object-safe; dispatch is +//! generic (`HttpCache<B: HttpBackend>`), which is intentional and avoids +//! `dyn` + `Pin<Box<dyn Future>>` overhead. use std::future::Future; @@ -68,15 +71,43 @@ pub struct BackendResponse { pub body: Bytes, } +/// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. +/// +/// [`HttpBackend::fetch`] bounds its returned future by `MaybeSend` so the +/// same trait works everywhere: on native targets the bound is exactly +/// [`Send`] (any executor may move the future across threads), while on +/// `wasm32` browser-fetch backends such as `reqwest`'s are inherently +/// `!Send` (their futures hold JS values) and execution is single-threaded, +/// so no `Send` requirement is imposed. +/// +/// Outside `wasm32` this is blanket-implemented: `T: MaybeSend` if and only +/// if `T: Send`. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} +#[cfg(not(target_arch = "wasm32"))] +impl<T: ?Sized + Send> MaybeSend for T {} + +/// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. +/// +/// On `wasm32` every type implements `MaybeSend`: the browser-fetch backend +/// of `reqwest` (and JS interop types generally) is `!Send` by design, and +/// wasm executes on a single thread, so the `Send` requirement is dropped. +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} +#[cfg(target_arch = "wasm32")] +impl<T: ?Sized> MaybeSend for T {} + /// A library-agnostic conditional `GET` backend. /// /// Implement this for your HTTP client of choice (the crate ships /// [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature) and /// hand an instance to [`crate::HttpCache::new`]. /// -/// The returned future must be `Send` so it can run on any executor; the trait -/// therefore uses `-> impl Future + Send` (not `async fn`). This makes the trait -/// non-object-safe — dispatch is static, via `HttpCache<B: HttpBackend>`. +/// On native targets the returned future must be `Send` so it can run on any +/// executor; the trait therefore uses `-> impl Future + MaybeSend` (not +/// `async fn`), where [`MaybeSend`] is [`Send`] everywhere except `wasm32`. +/// This makes the trait non-object-safe — dispatch is static, via +/// `HttpCache<B: HttpBackend>`. /// /// `fetch` must: /// @@ -91,10 +122,10 @@ pub trait HttpBackend: Send + Sync { /// Perform a conditional `GET` against `url`. /// - /// The returned future must be `Send`. + /// The returned future must be [`MaybeSend`] (`Send` on native targets). fn fetch( &self, url: &str, conditionals: Conditionals, - ) -> impl Future<Output = Result<BackendResponse, Self::Error>> + Send; + ) -> impl Future<Output = Result<BackendResponse, Self::Error>> + MaybeSend; } diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index b1b7e72..80cb291 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -253,7 +253,7 @@ fn policy_from_meta(meta: &CacheMeta) -> CachePolicy { #[cfg(test)] mod tests { use super::*; - use crate::backend::{BackendResponse, Conditionals, HttpBackend}; + use crate::backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; use bytes::Bytes; use http::HeaderMap; use std::collections::VecDeque; @@ -295,7 +295,7 @@ mod tests { &self, _url: &str, _conditionals: Conditionals, - ) -> impl Future<Output = Result<BackendResponse, MockError>> + Send { + ) -> impl Future<Output = Result<BackendResponse, MockError>> + MaybeSend { // Count the call and pop the next canned response. let next = { let mut calls = self.calls.lock().unwrap(); diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 7b90213..3bde117 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -47,7 +47,7 @@ pub mod cache; #[cfg(feature = "reqwest")] pub mod reqwest_backend; -pub use backend::{BackendResponse, Conditionals, HttpBackend}; +pub use backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; pub use cache::{HttpCache, HttpError}; #[cfg(feature = "reqwest")] pub use reqwest_backend::ReqwestBackend; diff --git a/crates/gpui-query-http/src/reqwest_backend.rs b/crates/gpui-query-http/src/reqwest_backend.rs index b482486..0766a7c 100644 --- a/crates/gpui-query-http/src/reqwest_backend.rs +++ b/crates/gpui-query-http/src/reqwest_backend.rs @@ -14,7 +14,7 @@ use std::future::Future; -use crate::backend::{BackendResponse, Conditionals, HttpBackend}; +use crate::backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; /// A [`HttpBackend`] backed by [`reqwest`]. /// @@ -44,11 +44,13 @@ impl HttpBackend for ReqwestBackend { &self, url: &str, conditionals: Conditionals, - ) -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + Send { - // Build the request synchronously (no .await), then return a Send future + ) -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + MaybeSend { + // Build the request synchronously (no .await), then return the future // for the send+collect half. Building eagerly here keeps the returned - // future `Send` even though `reqwest::RequestBuilder` itself is `!Send` - // in some configurations. + // future `Send` on native targets even though `reqwest::RequestBuilder` + // itself is `!Send` in some configurations. On wasm32 the future is + // `!Send` by design (reqwest's browser-fetch backend holds JS values), + // which the `MaybeSend` bound accommodates. let mut req = self.0.get(url); if let Some(etag) = conditionals.if_none_match { req = req.header(reqwest::header::IF_NONE_MATCH, etag); From 61065552b89b391be3f31d4512ed67746073a961 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 18 Sep 2026 10:21:24 +0200 Subject: [PATCH 010/111] ci: add wasm build check workflow and justfile recipe --- .github/workflows/wasm-check.yml | 55 ++++++++++++++++++++++++++++++++ justfile | 11 +++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/wasm-check.yml diff --git a/.github/workflows/wasm-check.yml b/.github/workflows/wasm-check.yml new file mode 100644 index 0000000..989b2fe --- /dev/null +++ b/.github/workflows/wasm-check.yml @@ -0,0 +1,55 @@ +name: Wasm Check + +# Guards the wasm compile boundary so it cannot silently regress: the +# core-only main crate and the http satellite (default and reqwest-backed) +# must build for wasm32-unknown-unknown, and the native all-features build +# must stay green for the GPUI-bound layers (client/hook/persist and the +# persist satellite are native-only and are never built for wasm here). +# Mirrored locally by `just wasm-check`. + +on: + push: + branches: [master] + paths: + - "crates/**" + - "Cargo.toml" + - ".github/workflows/**" + - "justfile" + pull_request: + paths: + - "crates/**" + - "Cargo.toml" + - ".github/workflows/**" + - "justfile" + +concurrency: + group: wasm-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + wasm-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + # No `workspaces` override: cargo runs from the repo root, so the + # root workspace manifest is the right cache key. Cargo.lock is not + # committed; rust-cache keys off the manifests that exist. + - uses: Swatinem/rust-cache@v2 + + - name: Build gpui-query core for wasm32 + run: cargo build --target wasm32-unknown-unknown -p gpui-query --no-default-features --features core + + - name: Build gpui-query-http for wasm32 + run: cargo build --target wasm32-unknown-unknown -p gpui-query-http + + - name: Build gpui-query-http with reqwest for wasm32 + run: cargo build --target wasm32-unknown-unknown -p gpui-query-http --features reqwest + + - name: Build all features (native) + run: cargo build --all-features diff --git a/justfile b/justfile index a3b9c70..7d07356 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,17 @@ test: test-feature feature: cargo test --features "{{ feature }}" +# ---- Wasm ---- + +# Verify the wasm compile boundary (mirrors the Wasm Check CI workflow): +# core-only main crate + http satellite (with and without reqwest) for +# wasm32-unknown-unknown, then the native all-features build +wasm-check: + cargo build --target wasm32-unknown-unknown -p gpui-query --no-default-features --features core + cargo build --target wasm32-unknown-unknown -p gpui-query-http + cargo build --target wasm32-unknown-unknown -p gpui-query-http --features reqwest + cargo build --all-features + # ---- Website (Astro + Starlight) ---- # Install web dependencies From c3b4ebd8e64ae547dd79156362da21782e565511 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 18 Sep 2026 10:27:57 +0200 Subject: [PATCH 011/111] docs: document wasm support boundary and reqwest web backend --- README.md | 2 ++ crates/gpui-query-http/README.md | 25 +++++++++++++++++++++++++ crates/gpui-query/README.md | 2 ++ 3 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 2c4c9c8..2e2bd06 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ To use only the core state machine with no GPUI dependency: gpui-query = { version = "0.2.0", default-features = false, features = ["core"] } ``` +The `core` layer also builds for `wasm32-unknown-unknown` — the crate handles the wasm-specific setup internally (ahash switches to compile-time RNG on wasm targets), so no consumer configuration is needed. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. + ## quick start Set up the `QueryClient` as a GPUI global during app initialization: diff --git a/crates/gpui-query-http/README.md b/crates/gpui-query-http/README.md index 6ab9776..827419c 100644 --- a/crates/gpui-query-http/README.md +++ b/crates/gpui-query-http/README.md @@ -67,6 +67,31 @@ let cache = HttpCache::new(ReqwestBackend::from_client(reqwest::Client::new())); let (body, policy, meta) = cache.fetch("https://example.test/data").await?; ``` +## WebAssembly + +The crate compiles for `wasm32-unknown-unknown` with the default feature set and with the `reqwest` feature — the same feature flags work on both targets. On wasm, `ReqwestBackend` runs on reqwest's browser-fetch backend and TLS is the browser's job; the `rustls-tls` feature that the `reqwest` feature enables for native use is inert there, so it is harmless to leave on. + +For custom backends, `HttpBackend::fetch` bounds its returned future with `MaybeSend` (exported at the crate root) instead of `Send`. On native targets `MaybeSend` is exactly `Send`, so an existing impl written with `+ Send` keeps compiling unchanged — no migration needed. Use `+ MaybeSend` only when a backend must compile on both native and wasm targets: on `wasm32`, browser-fetch futures (reqwest's included) are inherently `!Send` because they hold JS values, so a `+ Send` bound would not compile there. + +```rust +use std::future::Future; +use gpui_query_http::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; + +struct MyBackend; + +impl HttpBackend for MyBackend { + type Error = MyError; + + fn fetch( + &self, + url: &str, + conditionals: Conditionals, + ) -> impl Future<Output = Result<BackendResponse, MyError>> + MaybeSend { + // perform the conditional GET ... + } +} +``` + ## Links - Website: <https://gpui-query.freeoxide.com> diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 94baec2..91db94d 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -27,6 +27,8 @@ If you only want the core state machine without pulling in GPUI: gpui-query = { version = "0.2.0", default-features = false, features = ["core"] } ``` +The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only — they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. + ## Quick start Set up a `QueryClient` as a GPUI global when your app starts: From 587198d1d1fb29c23f1829558955e6363505355e Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 18 Sep 2026 11:10:23 +0200 Subject: [PATCH 012/111] fix: make gpui-query-http publishable and align docs.rs metadata --- crates/gpui-query-http/Cargo.toml | 11 ++++++++++- crates/gpui-query-http/src/lib.rs | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/gpui-query-http/Cargo.toml b/crates/gpui-query-http/Cargo.toml index 65707b7..eef1d05 100644 --- a/crates/gpui-query-http/Cargo.toml +++ b/crates/gpui-query-http/Cargo.toml @@ -22,7 +22,9 @@ default = [] reqwest = ["dep:reqwest"] [dependencies] -gpui-query = { path = "../gpui-query", default-features = false, features = ["core"] } +# `version` is required alongside `path`: `cargo publish` strips the path +# override and the published manifest must reference the crates.io release. +gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["core"] } bytes = "1" http = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true } @@ -32,3 +34,10 @@ thiserror = "2" [dev-dependencies] serde_json = { workspace = true } tokio = { version = "1", features = ["macros", "rt"] } + +# Docs.rs convention (see AGENTS.md / main crate): render with every feature so +# the `reqwest`-gated `reqwest_backend` module and its re-export appear; the +# default-features render would leave dead intra-doc links to them. +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 3bde117..24039c1 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -34,6 +34,10 @@ //! ``` #![deny(missing_docs)] +// docs.rs renders with `--cfg docsrs` (see [package.metadata.docs.rs]); enable +// `#[doc(cfg(...))]` there so the `reqwest`-gated items are annotated with the +// feature that enables them, matching the main crate's convention. +#![cfg_attr(docsrs, feature(doc_cfg))] use std::time::Duration; @@ -45,11 +49,13 @@ use thiserror::Error; pub mod backend; pub mod cache; #[cfg(feature = "reqwest")] +#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] pub mod reqwest_backend; pub use backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; pub use cache::{HttpCache, HttpError}; #[cfg(feature = "reqwest")] +#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] pub use reqwest_backend::ReqwestBackend; /// HTTP cache metadata extracted from a response. From 62983e455a39a6c29206187735ba4a0dc746f4ac Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 00:02:34 +0200 Subject: [PATCH 013/111] fix: make gpui-query-persist publishable and fix docs.rs metadata --- crates/gpui-query-persist/Cargo.toml | 8 +++++++- crates/gpui-query-persist/src/lib.rs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index 2481e2f..3409e74 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -15,7 +15,9 @@ authors = ["hmziqrs"] default = [] [dependencies] -gpui-query = { path = "../gpui-query", default-features = false, features = ["persist", "client", "hook"] } +# `version` is required alongside `path`: `cargo publish` strips the path +# override and the published manifest must reference the crates.io release. +gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["persist", "client", "hook"] } dirs = "6" tempfile = "3" bincode = "1" @@ -26,3 +28,7 @@ thiserror = "2" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } pollster = "0.4" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 0ca7f89..0f50fd6 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -1,6 +1,6 @@ //! Reference disk-based persistence adapter for [`gpui_query`]. //! -//! Provides [`FilePersister`], a [`Persister`](gpui_query::client::Persister) +//! Provides [`FilePersister`], a [`Persister`] //! implementation that atomically writes a [`PersistSnapshot`] to disk and //! tolerantly loads it back, plus a [`NoopPersister`] for tests/disabled modes. //! From 6c7aca756070c51f307ad5e391a03d7fa557596e Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 00:07:24 +0200 Subject: [PATCH 014/111] ci: add cargo test coverage and fix release cache/tag guards --- .github/workflows/cargo-test.yml | 49 +++++++++++++++++++++++++ .github/workflows/changelog-release.yml | 11 +++++- .github/workflows/pr-checks.yml | 6 +++ .github/workflows/release.yml | 7 +++- 4 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cargo-test.yml diff --git a/.github/workflows/cargo-test.yml b/.github/workflows/cargo-test.yml new file mode 100644 index 0000000..3c0203e --- /dev/null +++ b/.github/workflows/cargo-test.yml @@ -0,0 +1,49 @@ +name: Cargo Test + +# Runs the full workspace test suite on Linux — the local mirror is +# `just test`. The GPUI-linked test binaries (client/hook/persist layers and +# the persist satellite) need X11/XCB dev libraries at link time, so the two +# zed script/linux display packages are installed first (libxkbcommon-dev and +# libxcb1-dev arrive transitively). The wasm32 boundary stays build-only in +# wasm-check.yml: the repo has no wasm test targets today and browser-driven +# wasm-bindgen-test needs runner infrastructure that is not warranted. +# Cargo.lock is not committed; rust-cache keys off the manifests that exist. + +on: + push: + branches: [master] + paths: + - "crates/**" + - "Cargo.toml" + - ".github/workflows/**" + - "justfile" + pull_request: + paths: + - "crates/**" + - "Cargo.toml" + - ".github/workflows/**" + - "justfile" + +concurrency: + group: cargo-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + cargo-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install native test dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-xcb-dev libxkbcommon-x11-dev + + # No `workspaces` override: the repo root is the workspace root, so the + # default "." already caches the workspace target dir (same rationale + # as wasm-check.yml; the working-directory trick is not needed here). + - uses: Swatinem/rust-cache@v2 + + - name: Run all tests + run: cargo test --all-features diff --git a/.github/workflows/changelog-release.yml b/.github/workflows/changelog-release.yml index 221322b..8148741 100644 --- a/.github/workflows/changelog-release.yml +++ b/.github/workflows/changelog-release.yml @@ -21,6 +21,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 2 + # Without fetch-tags, a shallow checkout fetches NO tags, so the + # `git tag -l` guard below would always report "not exists" and + # every CHANGELOG-path push would re-attempt the release. + fetch-tags: true - name: Extract version from CHANGELOG id: changelog @@ -109,9 +113,12 @@ jobs: - uses: dtolnay/rust-toolchain@stable + # No `workspaces` override: the repo root is the workspace root and + # rust-cache resolves the default "." against GITHUB_WORKSPACE, not the + # job's working-directory — the old "crates/gpui-query -> target" + # mapping cached a directory cargo never writes. The working-directory + # on the run steps below is unaffected. - uses: Swatinem/rust-cache@v2 - with: - workspaces: "crates/gpui-query -> target" - name: Verify version working-directory: crates/gpui-query diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index f36a7ee..76a9a4a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -6,6 +6,12 @@ on: - "web/**" - "shared/**" - ".github/workflows/pr-checks.yml" + # Sibling web workflows: without these, a PR touching only deploy.yml + # or web-preview.yml would get zero checks. The dry-run below exercises + # the same bun build those workflows run, so their build-affecting + # changes (toolchain versions, build env) still get PR-time signal. + - ".github/workflows/deploy.yml" + - ".github/workflows/web-preview.yml" concurrency: group: pr-checks-${{ github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcca1f2..8799491 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,9 +17,12 @@ jobs: - uses: dtolnay/rust-toolchain@stable + # No `workspaces` override: the repo root is the workspace root and + # rust-cache resolves the default "." against GITHUB_WORKSPACE, not the + # job's working-directory — the old "crates/gpui-query -> target" + # mapping cached a directory cargo never writes. The working-directory + # on the run steps below is unaffected. - uses: Swatinem/rust-cache@v2 - with: - workspaces: "crates/gpui-query -> target" - name: Verify version matches tag working-directory: crates/gpui-query From 58fb3fcf8638658a9bb164def28449500522a695 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 00:13:13 +0200 Subject: [PATCH 015/111] fix: guard publish and web deploy jobs on release decision --- .github/workflows/changelog-release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/changelog-release.yml b/.github/workflows/changelog-release.yml index 8148741..ac95b56 100644 --- a/.github/workflows/changelog-release.yml +++ b/.github/workflows/changelog-release.yml @@ -105,6 +105,10 @@ jobs: publish-main: needs: check-and-release + # Same guard as the check_tag-gated steps above, via the job output — + # `needs:` alone would still run this job on the skip path (tag already + # exists) and re-attempt `cargo publish` on an already-released version. + if: needs.check-and-release.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -134,6 +138,9 @@ jobs: deploy-web: needs: [publish-main, check-and-release] + # Skip-path guard as in publish-main — avoids redundant prod redeploys + # when the CHANGELOG's first version heading is already tagged. + if: needs.check-and-release.outputs.should_release == 'true' runs-on: ubuntu-latest environment: prod defaults: From 6930c557cb87ccb552101bd5426d65b1e925c917 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 00:20:36 +0200 Subject: [PATCH 016/111] fix: silence wasm dead_code warning and simplify intra-doc links --- crates/gpui-query-http/src/cache.rs | 2 +- crates/gpui-query-http/src/lib.rs | 4 ++-- crates/gpui-query/src/core/mutation.rs | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 80cb291..714bf34 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -57,7 +57,7 @@ pub enum HttpError { /// The URL that produced the spurious `304`. url: String, }, - /// A cache [`Mutex`](std::sync::Mutex) was poisoned by a panicking thread. + /// A cache [`Mutex`] was poisoned by a panicking thread. /// /// Rather than panicking the caller (the previous `.expect` behavior), the /// poison is surfaced as a typed error so a poisoned cache fails one diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 24039c1..7b62f63 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -11,7 +11,7 @@ //! //! [`HttpCache`] is generic over a [`HttpBackend`] — a trait that abstracts a //! single conditional `GET`. The crate ships *one* optional backend, -//! [`ReqwestBackend`](crate::reqwest_backend::ReqwestBackend), behind the +//! [`ReqwestBackend`], behind the //! `reqwest` cargo feature; any other request library can implement //! [`HttpBackend`] and plug into [`HttpCache::new`](HttpCache::new) instead. //! `reqwest` is never a hard dependency. @@ -19,7 +19,7 @@ //! # Server wins //! //! Parse the response headers with [`cache_policy_from_headers`], hand the -//! resulting [`CachePolicy`](gpui_query::core::CachePolicy) to +//! resulting [`CachePolicy`] to //! [`Fetched::with_policy`](gpui_query::core::Fetched::with_policy), and the //! resource adopts the server's TTL: //! diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 096d8f6..60c16aa 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -118,6 +118,9 @@ impl<V, T, E> MutationResource<V, T, E> { /// Wall-clock ms of the most recent terminal completion, or `None` if the /// mutation has never completed. Used by `MutationBucket` GC to measure /// recency from completion time rather than insertion time (audit #112). + // Only the `client` layer reads this accessor; core-only builds (e.g. + // wasm32 core) have no caller yet, so silence dead_code there. + #[cfg_attr(not(feature = "client"), allow(dead_code))] pub(crate) fn last_updated_at_ms(&self) -> Option<u64> { self.last_updated_at_ms } From 909c14714afe5cedb44029fc64839225d2de5c74 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 00:25:06 +0200 Subject: [PATCH 017/111] docs: add changelog entries for wasm support work --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc00d29..5882757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +> Wasm32 compile support for `core` and `gpui-query-http`, publish fixes for the satellite crates, and real test coverage in CI. + +### Added + +#### `gpui-query` — wasm32 support for the `core` layer + +- The `core` layer builds for `wasm32-unknown-unknown`: on wasm targets `ahash` switches from runtime RNG to `compile-time-rng` internally (its `runtime-rng` default pulls `getrandom`, which cannot compile there), so no consumer configuration is needed. The `client`, `hook`, and `persist` layers stay native-only — they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. +- The wasm support boundary is documented in the root and main-crate READMEs. + +#### `gpui-query-http` — wasm32 support, including the `reqwest` feature + +- The crate compiles for `wasm32-unknown-unknown` with the default feature set and with the `reqwest` feature — the same feature flags work on both targets. On wasm, `ReqwestBackend` runs on reqwest's browser-fetch backend and TLS is the browser's job. +- `MaybeSend` marker alias for `Send`, relaxed to a no-op on `wasm32` (exported at the crate root): `HttpBackend::fetch` bounds its returned future with `MaybeSend` instead of `Send`. On native targets the bound is exactly `Send`, so existing `+ Send` backend impls keep compiling unchanged; on `wasm32` it drops the requirement, because browser-fetch futures (reqwest's included) are inherently `!Send` — they hold JS values. +- The README gained a WebAssembly section showing how to write a backend that compiles on both native and wasm targets. + +#### CI — wasm compile guard + +- New "Wasm Check" workflow (plus a `just wasm-check` recipe mirroring it) builds the core-only main crate and the `http` satellite — with and without `reqwest` — for `wasm32-unknown-unknown`, then the native all-features build, on every push and pull request, so the wasm boundary cannot silently regress. + +### Changed + +- CI now runs `cargo test --all-features` on every push and pull request (new "Cargo Test" workflow). Previously every workflow was build-only and the full suite ran only locally via `just test`; the job installs the X11 link dependencies (`libx11-xcb-dev`, `libxkbcommon-x11-dev`) that GPUI-linked test binaries need. +- Publish workflows' rust-cache override pointed at a member directory cargo never writes to, so target-dir caching never hit; the override is dropped in favor of the default workspace-root mapping. `gpui-query-legacy` keeps its mapping intentionally — it is excluded from the workspace and is its own workspace root. +- The PR checks workflow now also validates changes to the sibling web deploy workflows (`deploy.yml`, `web-preview.yml`), which previously triggered no checks at all. + +### Fixed + +#### `gpui-query-http` — publish blocker and docs.rs metadata + +- The `gpui-query` dependency now carries `version = "0.2"` alongside its path: `cargo publish` strips path overrides, so the previously versionless dependency made the crate unpublishable. +- docs.rs now renders with every feature and annotates `reqwest`-gated items with the feature that enables them, so `ReqwestBackend` and its module appear with live intra-doc links instead of dead ones. +- Three redundant intra-doc link targets simplified; the rendered docs are unchanged and rustdoc's warnings are gone. + +#### `gpui-query-persist` — publish blocker and docs.rs metadata + +- Same publish blocker fixed: the `gpui-query` path dependency gains `version = "0.2"`, and `cargo publish --dry-run` now verifies the crate against the crates.io release. +- Fleet-consistent docs.rs metadata added (`all-features = true`, `rustdoc-args = ["--cfg", "docsrs"]`); behaviorally a no-op today, as the crate has no optional features. +- One redundant intra-doc link simplified. + +#### `gpui-query` — warning-free core-only builds + +- `core`-only builds no longer warn about the unused `last_updated_at_ms` accessor (only the `client` layer reads it). + +#### CI — release workflow guards + +- Changelog Release now fetches tags on checkout, so the "already released?" guard actually sees existing tags instead of always re-attempting the release. +- The publish and web-deploy jobs run only when the guard decides a release should happen; merging CHANGELOG edits that do not cut a release (such as new `[Unreleased]` entries) no longer re-runs `cargo publish` or redeploys the website. + ## [0.2.0] - 2026-07-21 > Disk persistence, server-driven cache policy, and two new companion crates. From 08accd8b308cd26199a94a59951eb8bae0bd834e Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 09:37:05 +0200 Subject: [PATCH 018/111] chore: release v0.2.1 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5882757..94811d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.2.1] - 2026-09-19 > Wasm32 compile support for `core` and `gpui-query-http`, publish fixes for the satellite crates, and real test coverage in CI. @@ -203,6 +203,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Initial public release +[0.2.1]: https://github.com/freeoxide/gpui-query/releases/tag/v0.2.1 [0.2.0]: https://github.com/freeoxide/gpui-query/releases/tag/v0.2.0 [0.1.4]: https://github.com/freeoxide/gpui-query/releases/tag/v0.1.4 [0.1.3]: https://github.com/freeoxide/gpui-query/releases/tag/v0.1.3 From b7c8459f163035ac03bcccb979591f5bce43eb4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:00:52 +0000 Subject: [PATCH 019/111] chore: bump gpui-query version to v0.2.1 --- README.md | 6 +++--- crates/gpui-query/Cargo.toml | 2 +- crates/gpui-query/README.md | 6 +++--- web/src/content/docs/docs/getting-started/installation.mdx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2e2bd06..8253dbb 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,21 @@ The API mirrors what TanStack Query popularized in the JavaScript ecosystem: `us ```toml [dependencies] -gpui-query = "0.2.0" +gpui-query = "0.2.1" ``` This pulls in the `client` layer (which includes `core`). To use the declarative hooks: ```toml [dependencies] -gpui-query = { version = "0.2.0", features = ["hook"] } +gpui-query = { version = "0.2.1", features = ["hook"] } ``` To use only the core state machine with no GPUI dependency: ```toml [dependencies] -gpui-query = { version = "0.2.0", default-features = false, features = ["core"] } +gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` The `core` layer also builds for `wasm32-unknown-unknown` — the crate handles the wasm-specific setup internally (ahash switches to compile-time RNG on wasm targets), so no consumer configuration is needed. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index b2f7057..27899ef 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui-query" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "TanStack Query-inspired async state management for GPUI" repository = "https://github.com/freeoxide/gpui-query" diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 91db94d..344133c 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -10,21 +10,21 @@ You write a fetcher. The library manages the lifecycle. ```toml [dependencies] -gpui-query = "0.2.0" +gpui-query = "0.2.1" ``` The default feature set includes the `client` layer. To use the declarative view hooks, enable the `hook` feature: ```toml [dependencies] -gpui-query = { version = "0.2.0", features = ["hook"] } +gpui-query = { version = "0.2.1", features = ["hook"] } ``` If you only want the core state machine without pulling in GPUI: ```toml [dependencies] -gpui-query = { version = "0.2.0", default-features = false, features = ["core"] } +gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only — they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. diff --git a/web/src/content/docs/docs/getting-started/installation.mdx b/web/src/content/docs/docs/getting-started/installation.mdx index 6e6b256..df873c5 100644 --- a/web/src/content/docs/docs/getting-started/installation.mdx +++ b/web/src/content/docs/docs/getting-started/installation.mdx @@ -18,7 +18,7 @@ or add it by hand to `Cargo.toml`: ```toml [dependencies] -gpui-query = "0.2.0" +gpui-query = "0.2.1" ``` :::note @@ -40,7 +40,7 @@ The crate is split into four layers, each behind a feature flag: ```toml [dependencies] -gpui-query = { version = "0.2.0", features = ["hook"] } +gpui-query = { version = "0.2.1", features = ["hook"] } ``` ```sh From 1449ef203af1192ff5dcd370c8db6227f83efd8d Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:26:13 +0200 Subject: [PATCH 020/111] chore: bump gpui-query to 0.2.1 on gpui-pre-0.6 --- crates/gpui-query/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index b2f7057..27899ef 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui-query" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "TanStack Query-inspired async state management for GPUI" repository = "https://github.com/freeoxide/gpui-query" From 7b881f20a7b55407fa3f1cff00eb1879a41b1899 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 12:41:35 +0200 Subject: [PATCH 021/111] fix: redact case-variant home paths in error sanitization sanitize matched the lowercased text against the mixed-case needle "/Users/", so macOS home paths leaked through QueryError::sanitized(). Needles are now matched case-insensitively per the documented (?i) semantics; the test that pinned the leak now asserts redaction, plus a new sanitize unit test. Also trims and tightens the core layer: - sanitize: merge duplicate redact helpers into one pass, one lowercase allocation per rule, const needle tables - key: pre-sized single allocation for to_path instead of O(n^2) concat - drop dead format_duration helper and its two unit tests; delegate record_stale_cache_hit to record_cache_hit; fold single-use helpers - strip audit-history comments, tighten doc comments, fix 5 broken rustdoc links in core - gitignore .z-proflow/ state dir --- .gitignore | 3 + crates/gpui-query/src/core/error/sanitize.rs | 231 +++++------------- crates/gpui-query/src/core/error/types.rs | 2 +- crates/gpui-query/src/core/fetched.rs | 10 +- .../src/core/infinite_query/accessors.rs | 38 ++- .../src/core/infinite_query/lifecycle.rs | 204 ++++++---------- .../gpui-query/src/core/infinite_query/mod.rs | 37 +-- .../core/infinite_query/page_management.rs | 42 +--- .../src/core/infinite_query/resource.rs | 57 ++--- crates/gpui-query/src/core/key.rs | 16 +- crates/gpui-query/src/core/mod.rs | 16 +- crates/gpui-query/src/core/mutation.rs | 21 +- crates/gpui-query/src/core/network_mode.rs | 4 +- crates/gpui-query/src/core/policy.rs | 83 ++----- crates/gpui-query/src/core/refetch.rs | 5 +- crates/gpui-query/src/core/request.rs | 86 ++----- crates/gpui-query/src/core/resource.rs | 5 +- .../gpui-query/src/core/resource/accessors.rs | 3 - crates/gpui-query/src/core/resource/cache.rs | 36 +-- .../gpui-query/src/core/resource/lifecycle.rs | 56 ++--- crates/gpui-query/src/core/select.rs | 56 ++--- crates/gpui-query/src/tests/core_cache/mod.rs | 20 +- .../src/tests/core_infinite_query/helpers.rs | 7 +- .../core_infinite_query/initial_state.rs | 2 - .../tests/core_infinite_query/max_pages.rs | 2 +- .../src/tests/core_lifecycle/mod.rs | 2 +- .../policy_and_status_types.rs | 7 - .../tests/core_policy_types/query_error.rs | 16 +- .../tests/core_policy_types/retry_policy.rs | 2 - .../infinite_query_resource_advanced.rs | 2 +- .../query_resource_advanced.rs | 2 +- 31 files changed, 342 insertions(+), 731 deletions(-) diff --git a/.gitignore b/.gitignore index 4b081b4..3b2c1f4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ Thumbs.db # z-workflow state .z-workflow/ +# z-proflow state +.z-proflow/ + # Local Playwright/MCP artifacts .playwright-mcp/ diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index b45b57e..6696d1b 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -1,47 +1,45 @@ -//! Sanitization helpers for redacting sensitive data in error messages. -//! -//! Provides lightweight pattern-matching (no `regex` crate dependency) for -//! redacting connection strings, tokens, file paths, emails, and hex keys. +//! Redaction of sensitive patterns from error messages, without a `regex` +//! dependency. Covers connection strings, bearer tokens, file paths, +//! emails, and long hex runs. -/// Maximum length for sanitized error messages (512 characters). +/// Maximum length for sanitized error messages. pub const SANITIZE_MAX_LEN: usize = 512; +/// Lowercase needles for recognized connection-string schemes. +const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; + +/// Lowercase needles for filesystem path prefixes, including macOS home dirs. +const PATH_NEEDLES: [&str; 4] = ["/home/", "/users/", "/etc/", "/var/"]; + /// Redact known sensitive patterns from a message string and truncate to /// [`SANITIZE_MAX_LEN`]. pub(crate) fn sanitize_message(msg: &str) -> String { use std::borrow::Cow; - // N6/N13: keep a Cow throughout. `replace_regex` returns `Cow<str>` and - // short-circuits to `Cow::Borrowed` when the pattern cannot match, so a - // clean message skips every allocation (the previous code unconditionally - // wrapped each call in `Cow::Owned`, defeating the optimization). + // Cow pipeline: a clean message stays borrowed through every rule and + // never allocates until the final `into_owned`. let mut out: Cow<str> = Cow::Borrowed(msg); - // Redact database connection strings. out = replace_regex( out, r"(?i)(postgres|mysql|mongodb|redis)://\S+", "[REDACTED_CONNECTION]", ); - // Redact bearer/token patterns. out = replace_regex( out, r"(?i)(bearer\s+|token[=:]\s*)\S+", "$1[REDACTED_TOKEN]", ); - // Redact common filesystem paths. out = replace_regex( out, r"(?i)(/home/|/Users/|/etc/|/var/)\S+", "[REDACTED_PATH]", ); - // Redact email addresses. out = replace_regex( out, r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", "[REDACTED_EMAIL]", ); - // Redact long hex sequences (likely API keys or secrets). out = replace_regex(out, r"\b[0-9a-fA-F]{16,}\b", "[REDACTED_HEX]"); let mut s = out.into_owned(); @@ -57,48 +55,26 @@ pub(crate) fn sanitize_message(msg: &str) -> String { s } -/// Redact `pattern` with `replacement` across `input`. -/// -/// Uses a simple approach without pulling in the `regex` crate: manual -/// scan-and-replace for each pattern. This keeps the dependency footprint -/// minimal for a utility that only runs on DevTools / logging paths. -/// -/// N6: returns `Cow<str>` so clean messages skip the full-text copy entirely -/// (the input cow is returned unchanged when no match is possible). Each arm -/// first checks a cheap `contains` guard so the heavier scan only runs when -/// the pattern could plausibly match. Implemented on `Cow<'a, str>` (taking it -/// by value) so the borrowed variant keeps the lifetime of the original -/// message, allowing the chain of reassignments in `sanitize_message` to -/// compile. +/// Apply one redaction rule. The `pattern` string only selects which rule +/// runs; each rule starts with a cheap `contains` guard so clean messages +/// skip the full scan, and returns the input unchanged (still borrowed) +/// when nothing can match. fn replace_regex<'a>( - mut input: std::borrow::Cow<'a, str>, + input: std::borrow::Cow<'a, str>, pattern: &str, replacement: &str, ) -> std::borrow::Cow<'a, str> { - // Operate on the current contents (borrowed or owned) via a single - // `&str` view. When no redaction is needed, return `input` unchanged so - // a borrowed input stays borrowed (N6/N13). let text: &str = &input; - // Lightweight pattern matching without the regex crate. - // We only handle the specific patterns used by `sanitize_message`. + // ASCII lowercasing preserves byte offsets, so positions found in a + // lowercased copy are valid slice indices into `text`. let owned = match pattern { - // Database connection strings. N11: pre-compute the needles once. - p if p.contains("postgres") - || p.contains("mysql") - || p.contains("mongodb") - || p.contains("redis") => - { - // N6: cheap guard — no scheme needle can match, so skip. - if !contains_any_scheme(&text.to_ascii_lowercase()) { + p if p.contains("postgres") => { + let lower = text.to_ascii_lowercase(); + if !SCHEME_NEEDLES.iter().any(|n| lower.contains(n)) { return input; } - redact_url_schemes( - text, - &["postgres", "mysql", "mongodb", "redis"], - replacement, - ) + redact_until_whitespace(text, &lower, &SCHEME_NEEDLES, replacement) } - // Bearer/token patterns. p if p.contains("bearer") || p.contains("token") => { let lower = text.to_ascii_lowercase(); if !lower.contains("bearer") && !lower.contains("token") { @@ -106,60 +82,34 @@ fn replace_regex<'a>( } redact_tokens(text, replacement) } - // Filesystem paths. - p if p.contains("/home/") - || p.contains("/Users/") - || p.contains("/etc/") - || p.contains("/var/") => - { + p if p.contains("/home/") => { let lower = text.to_ascii_lowercase(); - if !lower.contains("/home/") - && !lower.contains("/users/") - && !lower.contains("/etc/") - && !lower.contains("/var/") - { + if !PATH_NEEDLES.iter().any(|n| lower.contains(n)) { return input; } - redact_paths(text, &["/home/", "/Users/", "/etc/", "/var/"], replacement) + redact_until_whitespace(text, &lower, &PATH_NEEDLES, replacement) } - // Email addresses. p if p.contains("@") && p.contains(".") => { if !text.contains('@') { return input; } redact_emails(text, replacement) } - // Long hex sequences. p if p.contains("0-9a-f") => { if !has_long_hex_run(text) { return input; } redact_hex(text, replacement) } - // N30: unknown pattern. Every pattern passed to `replace_regex` - // must have a matching arm — surface a missing arm in debug builds - // instead of silently no-oping. _ => { debug_assert!(false, "replace_regex: unrecognized pattern {pattern:?}"); return input; } }; - // A redaction actually happened; install the owned result. - input = std::borrow::Cow::Owned(owned); - input + std::borrow::Cow::Owned(owned) } -/// N6 guard: whether `lower` (already lowercased) contains any of the -/// recognized URL-scheme needles. -fn contains_any_scheme(lower: &str) -> bool { - // N11: pre-computed const table of the literal "scheme://" needles so we - // avoid re-allocating `format!("{scheme}://")` per scheme per loop - // iteration in `redact_url_schemes`, and reuse it here for the cheap guard. - const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; - SCHEME_NEEDLES.iter().any(|needle| lower.contains(needle)) -} - -/// N6 guard: whether `text` contains a run of 16+ hex digits. +/// Whether `text` contains a run of 16+ hex digits. fn has_long_hex_run(text: &str) -> bool { let mut run = 0usize; for c in text.chars() { @@ -175,33 +125,27 @@ fn has_long_hex_run(text: &str) -> bool { false } -/// Redact URL-like connection strings starting with any of `schemes`. -fn redact_url_schemes(text: &str, _schemes: &[&str], replacement: &str) -> String { - // N11: pre-computed const table of the literal "scheme://" needles instead - // of re-allocating `format!("{scheme}://")` per scheme per loop iteration. - const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; - - let lower = text.to_ascii_lowercase(); +/// Redact every occurrence of any `needle` (located via its lowercase copy +/// `lower`) from the match start through the next whitespace character. +fn redact_until_whitespace( + text: &str, + lower: &str, + needles: &[&str], + replacement: &str, +) -> String { let mut result = String::with_capacity(text.len()); let mut offset = 0; loop { - let mut earliest: Option<usize> = None; - for needle in SCHEME_NEEDLES { - if let Some(rel) = lower[offset..].find(needle) { - let abs = offset + rel; - match earliest { - None => earliest = Some(abs), - Some(ep) if abs < ep => earliest = Some(abs), - _ => {} - } - } - } + let earliest = needles + .iter() + .filter_map(|n| lower[offset..].find(n).map(|rel| offset + rel)) + .min(); match earliest { - Some(abs_pos) => { - let end = text[abs_pos..] - .find(|c: char| c.is_whitespace()) - .map_or(text.len(), |i| abs_pos + i); - result.push_str(&text[offset..abs_pos]); + Some(start) => { + let end = text[start..] + .find(char::is_whitespace) + .map_or(text.len(), |i| start + i); + result.push_str(&text[offset..start]); result.push_str(replacement); offset = end; if offset >= text.len() { @@ -268,53 +212,14 @@ fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { true } -/// Redact filesystem paths starting with any of `prefixes`. -fn redact_paths(text: &str, prefixes: &[&str], replacement: &str) -> String { - let lower = text.to_ascii_lowercase(); - let mut result = String::with_capacity(text.len()); - let mut offset = 0; - loop { - let mut earliest: Option<usize> = None; - for prefix in prefixes { - if let Some(rel) = lower[offset..].find(prefix) { - let abs = offset + rel; - match earliest { - None => earliest = Some(abs), - Some(ep) if abs < ep => earliest = Some(abs), - _ => {} - } - } - } - match earliest { - Some(abs_pos) => { - let end = text[abs_pos..] - .find(|c: char| c.is_whitespace()) - .map_or(text.len(), |i| abs_pos + i); - result.push_str(&text[offset..abs_pos]); - result.push_str(replacement); - offset = end; - if offset >= text.len() { - break; - } - } - None => { - result.push_str(&text[offset..]); - break; - } - } - } - result -} - -/// Redact email addresses (simple heuristic: word@word.word). +/// Redact email addresses (simple heuristic: word@word.tld). fn redact_emails(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); - let mut i = 0; let chars: Vec<char> = text.chars().collect(); let len = chars.len(); + let mut i = 0; while i < len { - // Try to match an email starting at position i. if let Some(email_end) = try_match_email(&chars, i) { result.push_str(replacement); i = email_end; @@ -335,7 +240,7 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { // Local part: alphanumeric + ._%+- let mut i = start; - if i >= len || !chars[i].is_alphanumeric() { + if !chars[i].is_alphanumeric() { return None; } while i < len && (chars[i].is_alphanumeric() || ".%+-".contains(chars[i])) { @@ -356,39 +261,27 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { // Must end with a dot followed by 2+ alpha chars (TLD). let domain_end = i; - if domain_end > start + 2 { - // Walk backwards to find last dot in the matched portion. - let mut last_dot = None; - for j in (start..domain_end).rev() { - if chars[j] == '.' { - last_dot = Some(j); - break; - } - } - if let Some(dot_pos) = last_dot { - let tld_len = domain_end - dot_pos - 1; - if tld_len >= 2 - && chars[dot_pos + 1..domain_end] - .iter() - .all(|c| c.is_alphabetic()) - { - return Some(domain_end); - } - } + if domain_end <= start + 2 { + return None; + } + let dot_pos = (start..domain_end).rev().find(|&j| chars[j] == '.')?; + let tld_len = domain_end - dot_pos - 1; + if tld_len >= 2 && chars[dot_pos + 1..domain_end].iter().all(|c| c.is_alphabetic()) { + Some(domain_end) + } else { + None } - None } /// Redact long hex sequences (16+ hex chars). fn redact_hex(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); - let mut i = 0; let chars: Vec<char> = text.chars().collect(); let len = chars.len(); + let mut i = 0; while i < len { if chars[i].is_ascii_hexdigit() { - // Count consecutive hex chars. let start = i; while i < len && chars[i].is_ascii_hexdigit() { i += 1; @@ -436,6 +329,14 @@ mod tests { assert!(out.contains("[REDACTED_TOKEN]")); } + #[test] + fn redact_users_path_mixed_case() { + let msg = "error in /Users/admin/.env leaked"; + let out = sanitize_message(msg); + assert!(!out.contains("/Users/admin/.env")); + assert!(out.contains("[REDACTED_PATH]")); + } + #[test] fn sanitize_message_truncates_multibyte_on_char_boundary() { let msg = "a".to_string() + &"é".repeat(300); diff --git a/crates/gpui-query/src/core/error/types.rs b/crates/gpui-query/src/core/error/types.rs index f553cc9..e2c3a51 100644 --- a/crates/gpui-query/src/core/error/types.rs +++ b/crates/gpui-query/src/core/error/types.rs @@ -113,7 +113,7 @@ impl QueryError { /// - Email-like strings /// - Long hex sequences (likely API keys) /// - /// Also truncates the message to [`SANITIZE_MAX_LEN`](super::SANITIZE_MAX_LEN) bytes. + /// Also truncates the message to `SANITIZE_MAX_LEN` (512) bytes. pub fn sanitized(&self) -> Self { let redacted = sanitize_message(&self.message); Self { diff --git a/crates/gpui-query/src/core/fetched.rs b/crates/gpui-query/src/core/fetched.rs index d88d2ba..2db0e3f 100644 --- a/crates/gpui-query/src/core/fetched.rs +++ b/crates/gpui-query/src/core/fetched.rs @@ -23,9 +23,9 @@ use serde_json::Value as JsonValue; /// - [`Fetched::new`] — no policy override; the resource keeps the caller's policy. /// - [`Fetched::with_policy`] — override the resource's policy with the server's. /// - [`Fetched::with_meta`] (`persist` feature) — attach opaque metadata that -/// flows into [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) -/// so it can be rehydrated on a cold start (e.g. an HTTP `CacheMeta` for -/// cheap `304` refetches after relaunch). +/// flows into the persistence layer's `PersistedEntry::meta` so it can be +/// rehydrated on a cold start (e.g. an HTTP `CacheMeta` for cheap `304` +/// refetches after relaunch). /// /// `cache_policy: None` (the default) keeps the caller's per-query policy /// unchanged, matching the plain `Result<T, E>` fetcher behavior exactly. @@ -68,8 +68,8 @@ impl<T> Fetched<T> { /// Attach opaque metadata (e.g. a serialized HTTP `CacheMeta`) to this /// fetched value. Requires the `persist` feature; the metadata flows into - /// [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) when the - /// resource is persisted, enabling cold-start revalidation. + /// the persistence layer's `PersistedEntry::meta` when the resource is + /// persisted, enabling cold-start revalidation. #[cfg(feature = "persist")] pub fn with_meta(mut self, meta: JsonValue) -> Self { self.meta = Some(meta); diff --git a/crates/gpui-query/src/core/infinite_query/accessors.rs b/crates/gpui-query/src/core/infinite_query/accessors.rs index 66fb449..9d0707b 100644 --- a/crates/gpui-query/src/core/infinite_query/accessors.rs +++ b/crates/gpui-query/src/core/infinite_query/accessors.rs @@ -14,11 +14,12 @@ use crate::core::{ impl<T, E> InfiniteQueryResource<T, E> { /// All loaded pages, in order from first to last. /// - /// Pages are stored internally as `Arc<T>` (audit #5) so that fetchers can - /// receive a cheap `Arc::clone` via [`first_page_arc`](Self::first_page_arc) / - /// [`last_page_arc`](Self::last_page_arc) instead of copying the page data. - /// Most call sites only need a `&T` view — use [`first_page`](Self::first_page) - /// / [`last_page`](Self::last_page), or iterate with `.iter().map(|a| a.as_ref())`. + /// Pages are stored as `Arc<T>` so fetchers can receive a cheap + /// `Arc::clone` via [`first_page_arc`](Self::first_page_arc) / + /// [`last_page_arc`](Self::last_page_arc) instead of copying the page + /// data. Most call sites only need a `&T` view — use + /// [`first_page`](Self::first_page) / [`last_page`](Self::last_page), or + /// iterate with `.iter().map(|a| a.as_ref())`. /// /// **Note**: When `status()` is `Failure`, previously loaded pages are still /// present and valid — the failure applies only to the most recent page fetch. @@ -43,15 +44,15 @@ impl<T, E> InfiniteQueryResource<T, E> { self.pages.back().map(|a| a.as_ref()) } - /// Cheap `Arc::clone` of the first page, if any (audit #5). + /// Cheap `Arc::clone` of the first page, if any. /// - /// Hand this to a `fetch_previous_page` fetcher instead of cloning the full - /// page data — only the refcount is bumped. + /// Hand this to a `fetch_previous_page` fetcher instead of cloning the + /// full page data — only the refcount is bumped. pub fn first_page_arc(&self) -> Option<Arc<T>> { self.pages.front().cloned() } - /// Cheap `Arc::clone` of the last page, if any (audit #5). + /// Cheap `Arc::clone` of the last page, if any. /// /// Hand this to a `fetch_next_page` fetcher instead of cloning the full /// page data — only the refcount is bumped. @@ -92,8 +93,8 @@ impl<T, E> InfiniteQueryResource<T, E> { /// The fetch direction mode for this query. /// - /// **Audit 3**: Controls the default assumptions for `has_next_page` and - /// `has_previous_page` after construction and after `reset()`. + /// Controls the default `has_next_page` / `has_previous_page` assumptions + /// after construction and after `reset()`. pub fn direction(&self) -> FetchDirection { self.direction } @@ -156,8 +157,8 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Set the retry policy. /// - /// Stored by `use_infinite_query` from [`InfiniteQueryOptions::retry_policy`] - /// so that fetch helpers can read it from the entity. + /// Stored by `use_infinite_query` from its `retry_policy` option so that + /// fetch helpers can read it from the entity. pub fn set_retry_policy(&mut self, policy: RetryPolicy) { self.retry_policy = policy; } @@ -172,14 +173,11 @@ impl<T, E> InfiniteQueryResource<T, E> { self.last_updated_at.map(QueryTimestamp::as_millis) } - /// Cache age in milliseconds (L6). + /// Cache age in milliseconds. /// /// Mirrors [`QueryResource::cache_age_ms`]: returns `None` when there is - /// no recorded `last_updated_at`, and also `None` on clock skew - /// (`now_ms` before the recorded timestamp) via `checked_sub`. Used by - /// `InfiniteQueryBucket::collect_diagnostics` so the infinite diagnostic - /// matches the regular query's `cache_age_ms` behavior (the previous - /// inline `saturating_sub` returned `Some(0)` on skew). + /// no recorded `last_updated_at`, and `None` on clock skew (`now_ms` + /// before the recorded timestamp) via `checked_sub`. /// /// [`QueryResource::cache_age_ms`]: crate::core::QueryResource::cache_age_ms pub fn cache_age_ms(&self, now_ms: u64) -> Option<u64> { @@ -219,7 +217,7 @@ impl<T, E> InfiniteQueryResource<T, E> { /// /// Mirrors `QueryResource::mark_ignored_result` so the client layer's /// bulk-cancel path can bump `ignored_results` for infinite queries the - /// same way it does for regular queries (M5 core half). + /// same way it does for regular queries. pub fn mark_ignored_result(&mut self) { self.ignored_results = self.ignored_results.saturating_add(1); } diff --git a/crates/gpui-query/src/core/infinite_query/lifecycle.rs b/crates/gpui-query/src/core/infinite_query/lifecycle.rs index b5057d9..881a2b4 100644 --- a/crates/gpui-query/src/core/infinite_query/lifecycle.rs +++ b/crates/gpui-query/src/core/infinite_query/lifecycle.rs @@ -11,50 +11,41 @@ use super::FetchDirection; use super::InfiniteQueryResource; /// Direction of an infinite-query page fetch, used internally to share logic -/// between the four `begin_fetch_*` entry points (audit #12). +/// between the four `begin_fetch_*` entry points. /// -/// `pub(super)` so it can back the collapsed `fetching_direction` field on -/// [`InfiniteQueryResource`] (N18). Derives `Serialize`/`Deserialize` because -/// it is now a serde-serialized field on the resource. +/// `pub(super)` because it backs the serde-serialized `fetching_direction` +/// field on [`InfiniteQueryResource`]; a single `Option<PageDirection>` makes +/// the "only one direction in flight" invariant unrepresentable to violate. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(super) enum PageDirection { Next, Previous, } -/// Source of the [`RequestId`] for [`InfiniteQueryResource::begin_fetch`] (audit #12). -/// -/// Keeps the sequencer-based and pre-allocated-id entry points sharing one -/// implementation: the sequencer variant borrows the sequencer and only calls -/// `next_request()` after the early-return guards, preserving the original -/// counter-consumption behavior exactly. +/// Source of the [`RequestId`] for [`InfiniteQueryResource::begin_fetch`]: +/// a caller-supplied sequencer, or an optional pre-generated id with a +/// per-resource fallback. The sequencer variant only calls `next_request()` +/// after the early-return guards, so guards never consume a sequence number. enum MaybeRequestId<'a> { FromSequencer(&'a mut RequestSequencer), Provided(Option<RequestId>), } -// ── Lifecycle ─────────────────────────────────────────────────────────── - impl<T, E> InfiniteQueryResource<T, E> { /// Begin fetching the next page. /// - /// **v2 fix**: Cancels the old signal before creating a new one. + /// Cancels any in-flight request's signal before starting the new one. /// - /// **Cross-direction replacement** (audit 2): When `RequestPolicy::LatestWins` - /// is set and a `begin_fetch_previous` is currently active, calling this - /// method will replace the previous-page request with this next-page request. - /// The old signal is cancelled and the previous-page result will be silently - /// discarded by `complete_page_success` (which returns `false` for stale IDs). - /// This is intentional for `LatestWins` semantics — the most recent direction - /// wins. Callers should check `is_fetching_next_page()` / `is_fetching_previous_page()` - /// before completing if they need to detect direction changes. + /// Under `RequestPolicy::LatestWins`, this replaces an active + /// `begin_fetch_previous` request: the old signal is cancelled and the + /// previous-page result is discarded by `complete_page_success` (it + /// returns `false` for stale IDs). Callers can check + /// `is_fetching_next_page()` / `is_fetching_previous_page()` before + /// completing if they need to detect direction changes. /// - /// **Per-direction `IgnoreWhileLoading` semantics** (audit #87): the guard - /// only applies within the same direction. Beginning a next-page fetch while - /// `is_fetching_previous_page` is active bypasses the `IgnoreWhileLoading` - /// guard and cancels/replaces the in-flight previous fetch — the reverse is - /// also true for `begin_fetch_previous`. See [`begin_fetch_previous`] for the - /// mirror case. + /// The `IgnoreWhileLoading` guard only applies within the same direction: + /// a next-page fetch while a previous-page fetch is active bypasses the + /// guard and replaces it (and vice versa). pub fn begin_fetch_next( &mut self, sequencer: &mut RequestSequencer, @@ -69,24 +60,18 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Begin fetching the previous page. /// - /// **v2 fix**: Cancels the old signal before creating a new one. + /// Cancels any in-flight request's signal before starting the new one. /// - /// **Cross-direction replacement** (audit 2): When `RequestPolicy::LatestWins` - /// is set and a `begin_fetch_next` is currently active, calling this - /// method will replace the next-page request with this previous-page request. - /// The old signal is cancelled and the next-page result will be silently - /// discarded by `complete_page_success` (which returns `false` for stale IDs). - /// This is intentional for `LatestWins` semantics — the most recent direction - /// wins. Callers should check `is_fetching_next_page()` / `is_fetching_previous_page()` - /// before completing if they need to detect direction changes. + /// Under `RequestPolicy::LatestWins`, this replaces an active + /// `begin_fetch_next` request: the old signal is cancelled and the + /// next-page result is discarded by `complete_page_success` (it returns + /// `false` for stale IDs). Callers can check + /// `is_fetching_next_page()` / `is_fetching_previous_page()` before + /// completing if they need to detect direction changes. /// - /// **Per-direction `IgnoreWhileLoading` semantics** (audit #87): the guard - /// only applies within the same direction. Beginning a previous-page fetch - /// while `is_fetching_next_page` is active bypasses the `IgnoreWhileLoading` - /// guard and cancels/replaces the in-flight next fetch — and vice versa for - /// `begin_fetch_next`. This is intentional: a same-direction re-entrancy is - /// suppressed under `IgnoreWhileLoading`, but an opposite-direction fetch is - /// treated as a new request that supersedes the current one. + /// The `IgnoreWhileLoading` guard only applies within the same direction: + /// a previous-page fetch while a next-page fetch is active bypasses the + /// guard and replaces it (and vice versa). pub fn begin_fetch_previous( &mut self, sequencer: &mut RequestSequencer, @@ -99,19 +84,15 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Like [`begin_fetch_next`] but accepts an optional pre-generated `RequestId` - /// instead of a `RequestSequencer`. + /// Like [`begin_fetch_next`](Self::begin_fetch_next) but accepts an optional + /// pre-generated `RequestId` instead of a `RequestSequencer`. /// - /// When `maybe_request_id` is `Some`, uses that ID directly — this is the + /// When `maybe_request_id` is `Some`, uses that ID directly — the /// preferred call when the bucket's co-located sequencer has already - /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`. - /// When `None`, falls back to a transient `RequestSequencer::new()` for - /// compatibility (e.g., when no `QueryClient` is available). - /// - /// Passing the pre-allocated ID through ensures the `RequestId` stored as - /// the resource's `active_request_id` matches the one the bucket's sequencer - /// already consumed, keeping the bucket's monotonic counter consistent with - /// the resource's active request. + /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`, + /// so the resource's `active_request_id` matches the id the bucket already + /// consumed. When `None`, falls back to the resource's own stored + /// sequencer. pub fn begin_fetch_next_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -124,19 +105,15 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Like [`begin_fetch_previous`] but accepts an optional pre-generated - /// `RequestId` instead of a `RequestSequencer`. + /// Like [`begin_fetch_previous`](Self::begin_fetch_previous) but accepts + /// an optional pre-generated `RequestId` instead of a `RequestSequencer`. /// - /// When `maybe_request_id` is `Some`, uses that ID directly — this is the + /// When `maybe_request_id` is `Some`, uses that ID directly — the /// preferred call when the bucket's co-located sequencer has already - /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`. - /// When `None`, falls back to a transient `RequestSequencer::new()` for - /// compatibility (e.g., when no `QueryClient` is available). - /// - /// Passing the pre-allocated ID through ensures the `RequestId` stored as - /// the resource's `active_request_id` matches the one the bucket's sequencer - /// already consumed, keeping the bucket's monotonic counter consistent with - /// the resource's active request. + /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`, + /// so the resource's `active_request_id` matches the id the bucket already + /// consumed. When `None`, falls back to the resource's own stored + /// sequencer. pub fn begin_fetch_previous_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -149,12 +126,7 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Shared implementation behind the four `begin_fetch_*` entry points - /// (audit #12). Behavior-preserving: the public wrappers retain their - /// original signatures. - /// - /// `id_source` is either a live sequencer (for the `_next`/`_previous` - /// variants) or a pre-allocated id (for the `_with_id` variants). + /// Shared implementation behind the four `begin_fetch_*` entry points. fn begin_fetch( &mut self, direction: PageDirection, @@ -180,23 +152,16 @@ impl<T, E> InfiniteQueryResource<T, E> { self.cancelled_count = self.cancelled_count.saturating_add(1); } - // v2 fix: Cancel old signal before replacing + // Cancel old signal before replacing. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } - // N18: collapse the two mutually-exclusive direction bools into a - // single Option<PageDirection>. The single assignment is what makes - // the invariant (only one direction in flight) unbreakable. self.fetching_direction = Some(direction); let request_id = match id_source { MaybeRequestId::FromSequencer(sequencer) => sequencer.next_request(), MaybeRequestId::Provided(maybe_id) => { - // N3: fall back to the resource's own sequencer so transient - // callers without a QueryClient still get monotonic, - // collision-free ids instead of every call producing - // RequestId(1,1). maybe_id.unwrap_or_else(|| self.transient_sequencer.next_request()) } }; @@ -216,20 +181,13 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Accept the current request for two-phase completion. /// /// Returns a [`RequestGuard`] if the request is still active, or `None` - /// if it was replaced or cancelled. The guard is a capability token for - /// the two-phase protocol (validate then complete). - /// - /// This mirrors [`QueryResource::accept_current_request`] for consistency. - /// Use this when you need to inspect or transform data between validation - /// and completion, or when integrating with frameworks that prefer explicit - /// acceptance. + /// if it was replaced or cancelled. A stale/replaced request's result is + /// ignored, bumping `ignored_results`. pub fn accept_current_request(&mut self, request_id: RequestId) -> Option<RequestGuard> { if self.is_current_request(request_id) { self.active_request_id = None; Some(RequestGuard::new(request_id)) } else { - // Mirror QueryResource::accept_current_request: a stale/replaced - // request's result is ignored, so bump the diagnostic counter (N1). self.mark_ignored_result(); None } @@ -237,17 +195,10 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Complete a page fetch with success using a guard (two-phase protocol). /// - /// The guard proves that `accept_current_request` already validated the - /// request is current. - /// - /// **Audit 3**: Uses `VecDeque::push_back` for append and `VecDeque::push_front` - /// for prepend — both O(1) amortized. - /// - /// **N27**: Any pages evicted by `enforce_max_pages_remove_*` are silently - /// dropped here (their `Arc<T>` refcounts are decremented, no leak). This - /// differs from `append_page`/`prepend_page`, which return evicted pages. - /// Returning them would change this method's signature, so the drop is - /// intentional and documented. + /// Appends (`is_next`) or prepends the page in O(1) amortized. Pages + /// evicted by the `max_pages` bound are dropped here (refcounts release, + /// nothing leaks); the `append_page`/`prepend_page` methods are the + /// variants that return evicted pages. pub fn complete_success_with_guard( &mut self, _guard: RequestGuard, @@ -275,14 +226,10 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Complete a page fetch with failure using a guard (two-phase protocol). /// - /// The guard proves that `accept_current_request` already validated the - /// request is current. - /// - /// **Note**: This does NOT clear previously loaded pages. A `Failure` status - /// means the last page fetch failed, but previously loaded pages remain - /// accessible via [`pages`](Self::pages). Use - /// [`is_page_data_valid`](Self::is_page_data_valid) to check whether page - /// data can be relied upon. + /// Does NOT clear previously loaded pages: `Failure` means the last page + /// fetch failed, but loaded pages remain accessible via + /// [`pages`](Self::pages). Use [`is_page_data_valid`](Self::is_page_data_valid) + /// to check whether the page data can be relied upon. pub fn complete_failure_with_guard(&mut self, _guard: RequestGuard, error: E) { self.status = QueryStatus::Failure; self.error = Some(error); @@ -292,15 +239,10 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Complete a page fetch with success. /// - /// Convenience method that accepts and completes in one call. - /// - /// **Audit 3**: Uses `VecDeque::push_back` for append and `VecDeque::push_front` - /// for prepend — both O(1) amortized. - /// - /// **N27**: Any pages evicted by `enforce_max_pages_remove_*` are silently - /// dropped (their `Arc<T>` refcounts are decremented, no leak); see - /// [`complete_success_with_guard`](Self::complete_success_with_guard) for - /// the rationale. + /// Convenience method that accepts and completes in one call. Appends + /// (`is_next`) or prepends the page in O(1) amortized; pages evicted by + /// the `max_pages` bound are dropped (see + /// [`complete_success_with_guard`](Self::complete_success_with_guard)). pub fn complete_page_success( &mut self, request_id: RequestId, @@ -336,13 +278,11 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Complete a page fetch with failure. /// - /// Convenience method that accepts and completes in one call. - /// - /// **Note**: This does NOT clear previously loaded pages. The `Failure` status - /// applies to the most recent page fetch attempt only — previously loaded pages - /// remain accessible via [`pages()`](Self::pages) and are still valid. Use - /// [`is_page_data_valid()`](Self::is_page_data_valid) to check whether page - /// data can be relied upon. + /// Convenience method that accepts and completes in one call. Does NOT + /// clear previously loaded pages: `Failure` applies to the most recent + /// page fetch attempt only. Use + /// [`is_page_data_valid()`](Self::is_page_data_valid) to check whether + /// the page data can be relied upon. pub fn complete_page_failure(&mut self, request_id: RequestId, error: E) -> bool { if self.active_request_id != Some(request_id) { self.ignored_results = self.ignored_results.saturating_add(1); @@ -365,16 +305,12 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Reset to idle, clearing everything. /// - /// **Note** (audit 2): `max_pages` is preserved across resets — if it was - /// changed via `set_max_pages()`, that value persists. - /// - /// **Audit 3**: `has_next_page` and `has_previous_page` are reset according - /// to the current [`FetchDirection`](self.direction): - /// - `ForwardOnly`: `has_next_page = true`, `has_previous_page = false` - /// - `Bidirectional`: both reset to `false` - /// - /// If the resource was previously exhausted, the caller should set the - /// flags again after reset if the direction-based defaults are incorrect. + /// `max_pages` and `direction` are preserved across resets. + /// `has_next_page` / `has_previous_page` are reset to the defaults of the + /// current [`FetchDirection`] (`ForwardOnly` → `true`/`false`, + /// `Bidirectional` → both `false`). If the resource was previously + /// exhausted, set the flags again after reset if the direction-based + /// defaults are wrong. pub fn reset(&mut self) { if let Some(signal) = self.signal.as_ref() { signal.cancel(); @@ -389,8 +325,6 @@ impl<T, E> InfiniteQueryResource<T, E> { self.cancelled_count = 0; self.ignored_results = 0; self.retry_count = 0; - // max_pages is intentionally preserved across resets. - // direction is intentionally preserved across resets. let (has_next, has_prev) = match self.direction { FetchDirection::ForwardOnly => (true, false), FetchDirection::Bidirectional => (false, false), diff --git a/crates/gpui-query/src/core/infinite_query/mod.rs b/crates/gpui-query/src/core/infinite_query/mod.rs index d05bb7a..876aff7 100644 --- a/crates/gpui-query/src/core/infinite_query/mod.rs +++ b/crates/gpui-query/src/core/infinite_query/mod.rs @@ -1,30 +1,11 @@ //! Infinite query resource for managing paginated data. //! -//! **v2 fixes**: -//! - Default `max_pages` is `Some(50)` instead of `None` (prevents unbounded growth) -//! - `enforce_max_pages_remove_front` uses `Vec::drain` instead of O(n²) `remove(0)` -//! - Old signal is cancelled before creating a new one on `begin_fetch_next`/`begin_fetch_previous` -//! -//! **Audit 2 fixes**: -//! - `max_pages` of 0 is treated as unbounded (no eviction) to prevent draining all pages -//! - Cross-direction request replacement (e.g. `begin_fetch_previous` while `begin_fetch_next` is -//! active) is explicitly documented for `RequestPolicy::LatestWins` -//! - `retry_count` and `ignored_results` fields track diagnostics, matching `QueryResource` -//! - `complete_page_success`/`complete_page_failure` increment `ignored_results` when the request -//! ID does not match -//! - `enforce_max_pages_remove_front`/`enforce_max_pages_remove_back` return evicted pages so -//! callers can log or process them -//! - `reset()` preserves `max_pages` and resets `has_next_page` to its default (`true`); -//! consumers should be aware that `has_next_page=true` after reset is an assumption -//! -//! **Audit 3 fixes**: -//! - Internal storage uses `VecDeque` instead of `Vec` so that `prepend_page` / -//! `push_front` is O(1) amortized rather than O(n). `VecDeque::push_front`, -//! `push_back`, `pop_back`, and `drain` are all O(1) amortized or better. -//! - `FetchDirection` controls default assumptions for `has_next_page` and -//! `has_previous_page`. Forward-only queries (the common case) default -//! `has_next_page = true`, while bidirectional queries default both to `false` -//! and require explicit opt-in via the fetcher's `has_more` return value. +//! Page storage is a `VecDeque<Arc<T>>`, so appending and prepending pages +//! are both O(1) amortized. A bounded `max_pages` (default 50) evicts the +//! oldest pages on the opposite side of a push; `set_max_pages(Some(0))` is +//! treated as unbounded. [`FetchDirection`] controls the default +//! `has_next_page` / `has_previous_page` assumptions on construction and +//! after `reset()`. mod accessors; mod lifecycle; @@ -59,7 +40,7 @@ mod tests { let r = make_resource(); assert_eq!(r.status(), QueryStatus::Idle); assert!(r.pages().is_empty()); - assert_eq!(r.max_pages(), Some(50)); // v2: bounded default + assert_eq!(r.max_pages(), Some(50)); } #[test] @@ -250,8 +231,6 @@ mod tests { assert!(!r.is_page_data_valid()); } - // ── Audit 2 tests ───────────────────────────────────────────────── - #[test] fn max_pages_zero_treated_as_unbounded() { let mut r = make_resource(); @@ -389,8 +368,6 @@ mod tests { assert!(r.has_next_page()); } - // ── Audit 3 tests ───────────────────────────────────────────────── - #[test] fn forward_only_defaults_has_next_true() { let r = make_resource(); diff --git a/crates/gpui-query/src/core/infinite_query/page_management.rs b/crates/gpui-query/src/core/infinite_query/page_management.rs index af6d957..b53bd04 100644 --- a/crates/gpui-query/src/core/infinite_query/page_management.rs +++ b/crates/gpui-query/src/core/infinite_query/page_management.rs @@ -31,10 +31,9 @@ impl<T, E> InfiniteQueryResource<T, E> { /// accidentally draining all pages. Callers that want no page retention /// should use `reset()` instead. /// - /// Returns evicted pages (if any) as `Arc<T>` handles so the caller can log - /// or process them without cloning the page data (audit #5). + /// Returns evicted pages (if any) as `Arc<T>` handles so the caller can + /// log or process them without cloning the page data. pub fn set_max_pages(&mut self, max: Option<usize>) -> Vec<Arc<T>> { - // Treat 0 as unbounded to prevent draining all pages. self.max_pages = match max { Some(0) => None, other => other, @@ -44,9 +43,7 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Append a page to the end. /// - /// **Audit 3**: Uses `VecDeque::push_back` — O(1) amortized. - /// - /// Returns evicted pages (if any) as `Arc<T>` handles (audit #5). + /// Returns evicted pages (if any) as `Arc<T>` handles. pub fn append_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_back(Arc::new(page)); self.enforce_max_pages_remove_front() @@ -54,21 +51,15 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Prepend a page to the beginning. /// - /// **Audit 3**: Uses `VecDeque::push_front` — O(1) amortized instead of - /// the previous `Vec::insert(0, page)` which was O(n). - /// - /// Returns evicted pages (if any) as `Arc<T>` handles (audit #5). + /// Returns evicted pages (if any) as `Arc<T>` handles. pub fn prepend_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_front(Arc::new(page)); self.enforce_max_pages_remove_back() } - /// **v2 fix**: Use `Vec::drain` instead of O(n²) `remove(0)`. - /// - /// **Audit 2 fix**: `max_pages` of 0 is treated as unbounded. At least 1 - /// page is always retained. Returns evicted pages for caller inspection. + /// Evict pages from the front until within `max_pages`. /// - /// **Audit 3**: Uses `VecDeque::drain` — O(k) where k is the number of + /// At least 1 page is always retained. O(k) where k is the number of /// evicted pages. pub(super) fn enforce_max_pages_remove_front(&mut self) -> Vec<Arc<T>> { if let Some(max) = self.max_pages @@ -82,27 +73,16 @@ impl<T, E> InfiniteQueryResource<T, E> { /// Evict pages from the back until within `max_pages`. /// - /// **Audit 2 fix**: `max_pages` of 0 is treated as unbounded. At least 1 - /// page is always retained. Returns evicted pages for caller inspection. - /// - /// **Audit 3**: Uses `VecDeque::pop_back` — O(1) per eviction. - /// - /// **Audit 36**: Uses `VecDeque::drain` (matching the front variant) instead - /// of a `while`/`pop_back` loop — O(k) where k is the number of evicted - /// pages. The drained range is reversed so the returned vector preserves the - /// original back-to-front eviction order (most-recently-prepended page first). + /// At least 1 page is always retained. The survivors are the first `max` + /// pages (indices `0..max`), so the drain starts at `max` — not at + /// `len - max`, which would leave too few behind. Drained in reverse so + /// the returned vector preserves back-to-front eviction order + /// (most-recently-prepended page first) without a separate reverse pass. pub(super) fn enforce_max_pages_remove_back(&mut self) -> Vec<Arc<T>> { if let Some(max) = self.max_pages && max > 0 && self.pages.len() > max { - // Evict the oldest `len - max` pages from the back. The pages - // that survive are the first `max` (indices `0..max`), so drain - // from `max..`. The previous `start = len - max` formula drained - // `max` pages from the middle/back and left too few behind. - // Drain in reverse so the returned vector preserves the original - // back-to-front eviction order (most-recently-prepended page - // first) without a separate reverse() pass (N26). return self.pages.drain(max..).rev().collect(); } Vec::new() diff --git a/crates/gpui-query/src/core/infinite_query/resource.rs b/crates/gpui-query/src/core/infinite_query/resource.rs index 57b19fc..9bc27d6 100644 --- a/crates/gpui-query/src/core/infinite_query/resource.rs +++ b/crates/gpui-query/src/core/infinite_query/resource.rs @@ -17,17 +17,13 @@ const DEFAULT_MAX_PAGES: usize = 50; /// Direction mode for an infinite query. /// /// Controls the default assumptions for `has_next_page` and -/// `has_previous_page` on construction and after `reset()`. +/// `has_previous_page` on construction and after `reset()`: /// -/// - **ForwardOnly** (default): `has_next_page` starts `true`, `has_previous_page` starts `false`. -/// This is the common case for feed-style pagination where you only fetch next pages. -/// The `true` default for `has_next_page` assumes more pages exist until the fetcher says -/// otherwise. -/// -/// - **Bidirectional**: Both `has_next_page` and `has_previous_page` start `false`. -/// The query will not attempt to fetch in either direction until the caller explicitly -/// sets `has_next_page(true)` or `has_previous_page(true)`, or the fetcher returns -/// `has_more = true` from a successful completion. +/// - **ForwardOnly** (default): `has_next_page` starts `true` (feed-style +/// pagination assumes more pages exist until the fetcher says otherwise), +/// `has_previous_page` starts `false`. +/// - **Bidirectional**: both start `false`; the query fetches nothing until +/// the caller sets a flag or the fetcher returns `has_more = true`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum FetchDirection { /// Fetch next pages only. `has_next_page` defaults to `true`. @@ -65,21 +61,17 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(super) retry_count: u32, pub(super) has_next_page: bool, pub(super) has_previous_page: bool, - /// Which direction (if any) is currently being fetched. - /// - /// Collapses the previous `is_fetching_next_page` / `is_fetching_previous_page` - /// pair into a single `Option<PageDirection>`, making the mutual-exclusion - /// invariant unbreakable at the type level (N18). The two boolean getters - /// are retained for backwards compatibility. + /// Which direction (if any) is currently being fetched. A single + /// `Option<PageDirection>` (instead of two booleans) makes the + /// "only one direction in flight" invariant hold by construction. pub(super) fetching_direction: Option<super::lifecycle::PageDirection>, pub(super) max_pages: Option<usize>, pub(super) direction: FetchDirection, pub(super) retry_policy: RetryPolicy, - /// Per-resource sequencer used by [`begin_fetch`](Self::begin_fetch_next) - /// when no external id is supplied, so transient callers without a - /// `QueryClient` still get monotonic, collision-free ids instead of every - /// call colliding at `RequestId(1,1)` (N3). `#[serde(skip)]` — runtime - /// state, not persisted. + /// Per-resource sequencer used by the `_with_id` fetch entry points when + /// no external id is supplied, so callers without a `QueryClient` still + /// get monotonic, collision-free ids. `#[serde(skip)]` — runtime state, + /// not persisted. #[serde(skip)] pub(super) transient_sequencer: RequestSequencer, #[serde(skip)] @@ -89,10 +81,10 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(crate) current_task: crate::core::current_task::CurrentTask, } -/// Serde helpers for `VecDeque<Arc<T>>` — serializes as a plain sequence and -/// deserializes into `VecDeque<Arc<T>>`. This keeps the wire format identical -/// to the old `Vec<T>` representation so existing cached data remains -/// compatible (`Arc<T>` serializes transparently as `T`). +/// Serde helpers for `VecDeque<Arc<T>>`: serialize as a plain sequence, +/// deserialize from a plain sequence. The wire format stays identical to the +/// old `Vec<T>` representation (`Arc<T>` serializes transparently as `T`), +/// so previously cached data remains readable. pub(super) mod vec_deque_serde { use std::collections::VecDeque; use std::sync::Arc; @@ -108,10 +100,8 @@ pub(super) mod vec_deque_serde { { let mut seq = serializer.serialize_seq(Some(deque.len()))?; for item in deque { - // Serialize the inner `T` directly (`&**item`) rather than the - // `Arc<T>`. This avoids requiring `Arc<T>: Serialize` (which is only - // available with serde's `rc` feature / certain configs) and keeps - // the wire format identical to the old `Vec<T>` representation. + // Serialize the inner `T` directly rather than the `Arc<T>`: this + // avoids requiring `Arc<T>: Serialize` (serde's `rc` feature). seq.serialize_element(&**item)?; } seq.end() @@ -130,11 +120,10 @@ pub(super) mod vec_deque_serde { impl<T, E> InfiniteQueryResource<T, E> { /// Create a new infinite query resource. /// - /// **v2**: `max_pages` defaults to `Some(50)` to prevent unbounded memory growth. - /// - /// **Audit 3**: Uses `FetchDirection::ForwardOnly` by default, meaning - /// `has_next_page` starts `true`. Use [`new_bidirectional`](Self::new_bidirectional) - /// for queries that paginate in both directions. + /// `max_pages` defaults to `Some(50)` to bound memory growth, and the + /// direction is [`FetchDirection::ForwardOnly`], so `has_next_page` + /// starts `true`. Use [`new_bidirectional`](Self::new_bidirectional) for + /// queries that paginate in both directions. pub fn new( key: impl Into<QueryKey>, cache_policy: CachePolicy, diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 1b34486..886d900 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -85,13 +85,19 @@ impl QueryKey { /// Returns the full key as a double-colon-separated path string. /// /// Useful for diagnostics and DevTools display. Uses `"::"` as the - /// separator to avoid ambiguity when segments contain forward slashes. + /// separator so segments containing forward slashes stay unambiguous. pub fn to_path(&self) -> String { - let mut iter = self.0.iter().map(|s| s.as_ref()); - match iter.next() { - Some(first) => iter.fold(first.to_owned(), |acc, s| acc + "::" + s), - None => String::new(), + const SEP: &str = "::"; + let len = self.0.iter().map(|s| s.len()).sum::<usize>() + + SEP.len() * self.0.len().saturating_sub(1); + let mut path = String::with_capacity(len); + for (i, segment) in self.0.iter().enumerate() { + if i > 0 { + path.push_str(SEP); + } + path.push_str(segment); } + path } /// Returns `true` if this key starts with the given `prefix`. diff --git a/crates/gpui-query/src/core/mod.rs b/crates/gpui-query/src/core/mod.rs index ac3d0d3..d40472a 100644 --- a/crates/gpui-query/src/core/mod.rs +++ b/crates/gpui-query/src/core/mod.rs @@ -63,17 +63,11 @@ pub use select::{MappedQueryResource, SelectTransform}; pub use signal::QuerySignal; pub use status::QueryStatus; -// ── Task storage helper (client feature) ──────────────────────────────── -// -// `gpui::Task<T>` is `Debug` but not `Clone`, `PartialEq`, or `Eq`. Several -// resource structs derive `Clone`/`PartialEq`/`Eq`, so storing a raw -// `Option<Task<()>>` would break those derives when the `client` feature is -// enabled. `CurrentTask` is a thin newtype that implements `Clone` (producing -// an empty handle — the original task keeps running), `PartialEq`/`Eq` -// (treating all instances as equal — task identity does not affect resource -// equality), and `Default` (no task). Dropping the inner `Task` cancels it -// immediately (gpui semantics), so `set` and `abort` simply replace the -// inner value, dropping the previous task. +// `gpui::Task<T>` is `Debug` but not `Clone`/`PartialEq`/`Eq`, and several +// resource structs derive those. `CurrentTask` is a newtype that restores the +// derives: `Clone` yields an empty handle (the original task keeps running), +// all instances compare equal, and dropping the inner `Task` cancels it +// (gpui semantics), so `set` replaces and aborts the previous task. #[cfg(feature = "client")] mod current_task { use gpui::Task; diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 60c16aa..84f4f9e 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -66,15 +66,15 @@ pub struct MutationResource<V, T, E = QueryError> { retry_policy: RetryPolicy, /// Wall-clock ms of the most recent terminal completion (success/failure); /// `None` until the mutation first completes. Read by `MutationBucket`'s GC - /// so recency is measured from completion time, not insertion time - /// (audit #112). `#[serde(skip)]` — runtime state, not persisted. + /// so recency is measured from completion time, not insertion time. + /// `#[serde(skip)]` — runtime state, not persisted. #[serde(skip)] last_updated_at_ms: Option<u64>, #[serde(skip)] signal: Option<QuerySignal>, - /// In-flight background mutation task (audit #6). Stored so that a - /// replacement mutation or entity drop (component unmount) aborts the prior - /// in-flight task instead of leaving it detached. `#[cfg(feature = "client")]` + /// In-flight background mutation task. Stored so that a replacement + /// mutation or entity drop (component unmount) aborts the prior in-flight + /// task instead of leaving it detached. `#[cfg(feature = "client")]` /// because `gpui::Task` is only available with the client feature. #[cfg(feature = "client")] #[serde(skip)] @@ -117,7 +117,7 @@ impl<V, T, E> MutationResource<V, T, E> { /// Wall-clock ms of the most recent terminal completion, or `None` if the /// mutation has never completed. Used by `MutationBucket` GC to measure - /// recency from completion time rather than insertion time (audit #112). + /// recency from completion time rather than insertion time. // Only the `client` layer reads this accessor; core-only builds (e.g. // wasm32 core) have no caller yet, so silence dead_code there. #[cfg_attr(not(feature = "client"), allow(dead_code))] @@ -270,9 +270,8 @@ impl<V, T, E> MutationResource<V, T, E> { self.variables = None; self.retry_count = 0; self.cancelled_count = 0; - // Clear the completion timestamp so MutationBucket GC does not measure - // recency from a stale pre-reset completion (mirrors QueryResource::reset - // and InfiniteQueryResource::reset clearing last_updated_at). + // Clear the completion timestamp so GC does not measure recency from + // a pre-reset completion. self.last_updated_at_ms = None; self.signal = None; } @@ -342,8 +341,8 @@ impl<V, T, E> MutationResource<V, T, E> { #[cfg(feature = "client")] impl<V, T, E> MutationResource<V, T, E> { /// Store a new background mutation task, cancelling any previously stored - /// task (audit #6). Called from the hook spawn sites so a replacement - /// mutation or entity drop aborts the prior in-flight task. + /// task. Called from the hook spawn sites so a replacement mutation or + /// entity drop aborts the prior in-flight task. pub(crate) fn set_current_task(&mut self, task: gpui::Task<()>) { self.current_task.set(task); } diff --git a/crates/gpui-query/src/core/network_mode.rs b/crates/gpui-query/src/core/network_mode.rs index 9f48f58..517a5aa 100644 --- a/crates/gpui-query/src/core/network_mode.rs +++ b/crates/gpui-query/src/core/network_mode.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; /// Controls fetch behavior based on network connectivity. /// -/// Note: In v2, this is defined for forward compatibility. The actual -/// network detection is not yet implemented. +/// Defined for forward compatibility; the actual network detection is not +/// implemented yet. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum NetworkMode { /// Only fetch when online (default). diff --git a/crates/gpui-query/src/core/policy.rs b/crates/gpui-query/src/core/policy.rs index 676b790..de90032 100644 --- a/crates/gpui-query/src/core/policy.rs +++ b/crates/gpui-query/src/core/policy.rs @@ -16,27 +16,22 @@ pub enum CachePolicy { NoCache, /// Cache with a time-to-live. Data is considered fresh within the TTL. /// - /// **Note:** `ttl_ms` should be greater than zero. A `ttl_ms` of `0` is - /// equivalent to [`NoCache`](Self::NoCache) for all practical purposes — - /// data is only "fresh" at the exact instant it is stored, so every - /// `begin_request` call triggers a new fetch. This is not validated at - /// runtime in release builds, but a `debug_assert` will fire in debug - /// builds if `ttl_ms` is zero. + /// `ttl_ms` should be greater than zero; a value of 0 behaves like + /// [`NoCache`](Self::NoCache) because data is only "fresh" at the instant + /// it is stored. Not validated in release builds (a `debug_assert` fires + /// in debug builds). Ttl { ttl_ms: u64 }, /// Return stale data immediately while revalidating in the background. /// - /// Data within `ttl_ms` is served as a fresh cache hit (no refetch). - /// Data between `ttl_ms` and `ttl_ms + stale_ms` is served as stale data - /// **and** a background revalidation is triggered. - /// After `ttl_ms + stale_ms`, data is considered expired and a normal - /// fetch is performed (no stale data served). + /// Data within `ttl_ms` is served as a fresh cache hit (no refetch). Data + /// between `ttl_ms` and `ttl_ms + stale_ms` is served as stale data **and** + /// a background revalidation is triggered. After `ttl_ms + stale_ms`, data + /// is expired and a normal fetch is performed (no stale data served). /// - /// **Note:** `stale_ms` should be greater than zero. Setting `stale_ms` - /// to `0` effectively disables the stale-while-revalidate feature, - /// degenerating to pure TTL behavior (the stale window is an empty set). - /// Both `ttl_ms` and `stale_ms` should be greater than zero. This is not - /// validated at runtime in release builds, but `debug_assert`s will fire - /// in debug builds if either value is zero. + /// Both fields should be greater than zero: `ttl_ms = 0` behaves like + /// [`NoCache`](Self::NoCache), and `stale_ms = 0` degenerates to pure TTL + /// behavior (empty stale window). Not validated in release builds + /// (`debug_assert`s fire in debug builds). StaleWhileRevalidate { ttl_ms: u64, stale_ms: u64 }, } @@ -50,12 +45,9 @@ impl CachePolicy { /// Human-readable label. /// /// Sub-second values are shown with millisecond precision (e.g. "500ms") - /// rather than truncating to "0s" via integer division. - /// - /// Thin wrapper around the [`Display`](std::fmt::Display) impl that - /// allocates a `String`. Prefer `format!("{policy}")` or writing directly - /// to a formatter to avoid the heap allocation for log/diagnostic callers. - // Audit fix #45: keep label for backward compat; Display writes directly. + /// rather than truncating to "0s" via integer division. Allocates a + /// `String`; `format!("{policy}")` writes the same text without the heap + /// allocation. pub fn label(self) -> String { self.to_string() } @@ -157,12 +149,6 @@ impl CachePolicy { } impl std::fmt::Display for CachePolicy { - /// Reproduces the exact strings produced by [`CachePolicy::label`]. - /// - /// The duration formatting is inlined here (mirroring [`format_duration`]) - /// so that no intermediate `String` is allocated when writing to a - /// formatter — the whole point of the `Display` impl. - // Audit fix #45: write directly to the formatter, avoiding String allocs. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NoCache => write!(f, "No cache"), @@ -180,11 +166,8 @@ impl std::fmt::Display for CachePolicy { } } -/// Write a duration (in milliseconds) directly to a formatter, mirroring -/// [`format_duration`] exactly: seconds for `>= 1000ms`, milliseconds otherwise. -/// -/// This avoids the `String` allocation that `format_duration` performs, while -/// producing byte-identical output. +/// Write a duration: seconds for `>= 1000ms`, milliseconds otherwise, so +/// sub-second values do not collapse to "0s" through integer division. fn write_duration(f: &mut std::fmt::Formatter<'_>, ms: u64) -> std::fmt::Result { if ms >= 1_000 { write!(f, "{}s", ms / 1_000) @@ -254,42 +237,10 @@ pub enum QueryBeginResult { IgnoredWhileLoading { active_request_id: RequestId }, } -/// Format a duration in milliseconds as a human-readable string. -/// -/// Shows seconds for values >= 1000ms, milliseconds otherwise. -/// This avoids the misleading "0s" label that integer division produces -/// for sub-second values. -/// Reference formatting impl. The `Display` impls below intentionally inline -/// this logic (writing directly to the `Formatter`) to avoid the `String` -/// allocation; this standalone version is retained as the documented reference -/// and is exercised by the unit tests below. Allowed dead because the lib-only -/// build has no non-test caller. -#[allow(dead_code)] -fn format_duration(ms: u64) -> String { - if ms >= 1_000 { - format!("{}s", ms / 1_000) - } else { - format!("{ms}ms") - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn format_duration_shows_millis_for_subsecond() { - assert_eq!(format_duration(0), "0ms"); - assert_eq!(format_duration(500), "500ms"); - assert_eq!(format_duration(999), "999ms"); - } - - #[test] - fn format_duration_shows_seconds_for_one_second_and_above() { - assert_eq!(format_duration(1_000), "1s"); - assert_eq!(format_duration(60_000), "60s"); - } - #[test] fn ttl_zero_label_uses_ms() { let policy = CachePolicy::Ttl { ttl_ms: 0 }; diff --git a/crates/gpui-query/src/core/refetch.rs b/crates/gpui-query/src/core/refetch.rs index 5d5bea8..5880320 100644 --- a/crates/gpui-query/src/core/refetch.rs +++ b/crates/gpui-query/src/core/refetch.rs @@ -2,9 +2,8 @@ use serde::{Deserialize, Serialize}; /// Trigger configuration for automatic refetching. /// -/// Note: In v2, these triggers are defined but the event system integration -/// (window focus, reconnect) is not yet implemented. This enum exists for -/// forward compatibility and option parsing. +/// The triggers are parsed and stored, but the event system integration +/// (window focus, reconnect) is not implemented yet. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RefetchTrigger { /// Always refetch when the trigger fires. diff --git a/crates/gpui-query/src/core/request.rs b/crates/gpui-query/src/core/request.rs index ec7f937..1d49e6b 100644 --- a/crates/gpui-query/src/core/request.rs +++ b/crates/gpui-query/src/core/request.rs @@ -1,8 +1,5 @@ //! Request lifecycle primitives for the query system. //! -//! This module provides the core types that govern how async requests are -//! identified, sequenced, and completed within the query framework: -//! //! - [`RequestId`] — a unique, ordered identifier for each in-flight request. //! - [`RequestSequencer`] — a monotonic generator of `RequestId` values, scoped //! per resource to guarantee uniqueness even after sequence overflow. @@ -11,23 +8,11 @@ //! - [`QueryTimestamp`] — a millisecond-precision timestamp used for cache //! freshness and staleness calculations. //! -//! # Two-phase completion protocol -//! -//! The query system uses a two-phase protocol to safely complete async work: -//! -//! 1. **Accept**: Call [`QueryResource::accept_current_request`] with a -//! [`RequestId`]. If the request is still active (not replaced or cancelled), -//! this returns `Some(RequestGuard)`. Otherwise it returns `None`. -//! -//! 2. **Complete**: Pass the [`RequestGuard`] (by value) to one of the -//! completion methods: [`QueryResource::complete_success`], -//! [`QueryResource::complete_failure`], -//! [`QueryResource::complete_success_optional`], or -//! [`QueryResource::complete_failure_with_data`]. The guard is consumed, -//! preventing double-completion. -//! -//! Convenience methods like [`QueryResource::complete_current_success`] combine -//! both phases into a single call. +//! The two-phase protocol: accept a [`RequestId`] via +//! [`QueryResource::accept_current_request`](super::QueryResource::accept_current_request) +//! to get a [`RequestGuard`], then pass the guard (by value) to a `complete_*` +//! method. Convenience methods like `complete_current_success` combine both +//! phases into one call. //! //! [`QueryResource`]: super::QueryResource @@ -81,19 +66,15 @@ impl RequestId { /// Human-readable label for diagnostics. /// - /// Thin wrapper around the [`Display`](std::fmt::Display) impl that - /// allocates a `String`. Prefer `format!("{id}")` or writing directly to - /// a formatter to avoid the heap allocation for log/diagnostic callers. - // Audit fix #45: keep label for backward compat; Display writes directly. + /// Allocates a `String`; `format!("{id}")` writes the same text without + /// the heap allocation. pub fn label(self) -> String { self.to_string() } } impl std::fmt::Display for RequestId { - /// Reproduces the exact `"{scope}:{sequence}"` label format. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Audit fix #45: write directly to the formatter, avoiding a String alloc. write!(f, "{}:{}", self.scope_id, self.sequence) } } @@ -102,25 +83,14 @@ impl std::fmt::Display for RequestId { /// /// Each `RequestSequencer` produces a stream of [`RequestId`] values that are /// unique within the resource's lifetime. The sequence counter increments -/// from 1; when it would overflow `u64::MAX`, the scope advances to avoid -/// producing duplicate ids. -/// -/// # Scope advancement -/// -/// When the sequence counter reaches `u64::MAX`, [`next_request`](Self::next_request) -/// calls [`advance_scope`](Self::advance_scope), which increments `scope_id` -/// and resets `next_request_id` to 1. This guarantees uniqueness across -/// the entire lifetime of the sequencer. -/// -/// # Theoretical wrap-around -/// -/// If `scope_id` itself overflows `u64::MAX`, it wraps back to 1 and -/// `next_request_id` is reset to 1. This means a new `RequestId(1, 1)` could -/// theoretically collide with a very old `RequestId(1, 1)` still held by a -/// long-running future. In practice, reaching `u64::MAX` requests per scope -/// is essentially impossible, so this is not a practical concern. For -/// extremely long-lived processes (e.g., a server running for decades), the -/// collision risk remains theoretical but documented here for completeness. +/// from 1; when it would overflow `u64::MAX`, the scope advances via +/// [`advance_scope`](Self::advance_scope), which increments `scope_id` and +/// resets the sequence to 1. +/// +/// If `scope_id` itself overflows, it wraps to 1 and the sequence resets, so +/// a fresh `RequestId(1, 1)` could theoretically collide with a very old one +/// still held by a long-running future. Reaching `u64::MAX` requests per scope +/// is out of reach in practice. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RequestSequencer { pub(crate) scope_id: NonZero<u64>, @@ -144,9 +114,8 @@ impl RequestSequencer { /// Generate the next request id. /// - /// The sequence counter increments with each call. When it reaches - /// `u64::MAX`, the scope advances automatically before the next call - /// produces a duplicate. + /// When the sequence counter reaches `u64::MAX`, the scope advances + /// before the next call can produce a duplicate. pub fn next_request(&mut self) -> RequestId { let request_id = RequestId::scoped(self.scope_id, self.next_request_id); if self.next_request_id == u64::MAX { @@ -158,13 +127,9 @@ impl RequestSequencer { } /// Advance to a new scope when the sequence overflows. - /// - /// Increments `scope_id` via checked addition. If `scope_id` itself - /// overflows (astronomically unlikely), it wraps to 1 and the sequence - /// resets, as documented on the struct. pub fn advance_scope(&mut self) { self.scope_id = NonZero::new(self.scope_id.get().checked_add(1).unwrap_or(1)) - .unwrap_or(NonZero::new(1).unwrap()); + .unwrap_or(NonZero::<u64>::MIN); self.next_request_id = 1; } @@ -212,21 +177,6 @@ impl From<u64> for QueryTimestamp { /// completion method, which enforces the two-phase protocol at the type level: /// once a guard is used, it cannot be used again. /// -/// # Two-phase protocol -/// -/// 1. **Accept**: `resource.accept_current_request(request_id)` validates that -/// the request is still active and returns `Some(RequestGuard)`. -/// 2. **Complete**: `resource.complete_success(guard, data, now_ms)` consumes -/// the guard and applies the result. Attempting to use the guard again is a -/// compile error because it has been moved. -/// -/// # Why not `Copy`? -/// -/// Previous versions derived `Clone` + `Copy`, which allowed the same guard to -/// be passed to multiple `complete_*` calls. While the second call would be a -/// no-op (the resource already cleared `active_request_id`), it was wasteful -/// and could mask bugs. Taking the guard by value prevents this entirely. -/// /// [`QueryResource`]: super::QueryResource /// [`QueryResource::accept_current_request`]: super::QueryResource::accept_current_request #[derive(Debug, PartialEq, Eq)] diff --git a/crates/gpui-query/src/core/resource.rs b/crates/gpui-query/src/core/resource.rs index f108392..17dd083 100644 --- a/crates/gpui-query/src/core/resource.rs +++ b/crates/gpui-query/src/core/resource.rs @@ -39,9 +39,8 @@ pub struct QueryResource<T, E = QueryError> { retry_policy: RetryPolicy, previous_data: Option<T>, /// Per-resource sequencer used by [`begin_request_with_id`](Self::begin_request_with_id) - /// when no external id is supplied, so transient callers without a - /// `QueryClient` still get monotonic, collision-free ids instead of every - /// call colliding at `RequestId(1,1)` (N3). `#[serde(skip)]` — runtime + /// when no external id is supplied, so callers without a `QueryClient` + /// still get monotonic, collision-free ids. `#[serde(skip)]` — runtime /// state, not persisted. #[serde(skip)] transient_sequencer: RequestSequencer, diff --git a/crates/gpui-query/src/core/resource/accessors.rs b/crates/gpui-query/src/core/resource/accessors.rs index 07313e0..cdf99d1 100644 --- a/crates/gpui-query/src/core/resource/accessors.rs +++ b/crates/gpui-query/src/core/resource/accessors.rs @@ -89,9 +89,6 @@ impl<T, E> QueryResource<T, E> { } /// Mutable reference to the cancellation signal. - /// - /// Currently only exercised by tests; gated accordingly to avoid - /// surfacing an unused public API (N23). #[cfg(test)] pub(crate) fn signal_mut(&mut self) -> Option<&mut QuerySignal> { self.signal.as_mut() diff --git a/crates/gpui-query/src/core/resource/cache.rs b/crates/gpui-query/src/core/resource/cache.rs index 8aabb89..dd22701 100644 --- a/crates/gpui-query/src/core/resource/cache.rs +++ b/crates/gpui-query/src/core/resource/cache.rs @@ -16,11 +16,12 @@ impl<T, E> QueryResource<T, E> { /// is within the TTL window. The stale-while-revalidate window is NOT /// considered fresh — it is stale-but-serveable (see [`is_stale_but_serveable`]). /// - /// **Boundary behavior**: data at exactly TTL milliseconds old is considered - /// fresh (`age <= ttl_ms`). Data older than TTL is stale (`age > ttl_ms`). - /// This differs from HTTP `Cache-Control: max-age` where the boundary is - /// exclusive. The inclusive boundary is chosen so that the fresh/stale - /// partition is total: every age is either fresh or stale, with no gap. + /// Data at exactly TTL milliseconds old is considered fresh (`age <= ttl_ms`), + /// unlike HTTP `Cache-Control: max-age` where the boundary is exclusive. + /// The inclusive boundary keeps the fresh/stale partition total: every age + /// is either fresh or stale, with no gap. + /// + /// [`is_stale_but_serveable`]: Self::is_stale_but_serveable pub fn is_cache_fresh(&self, now_ms: u64) -> bool { self.has_data() && self @@ -88,33 +89,20 @@ impl<T, E> QueryResource<T, E> { /// `Success` as expected. pub(crate) fn record_cache_hit(&mut self) { self.cache_hits = self.cache_hits.saturating_add(1); - // Only transition to Success from non-terminal states. - // Failure/Cancelled are terminal — a cache hit on old data should not - // silently clear the error a consumer is already handling. + // Failure/Cancelled are terminal: a hit on old data must not silently + // clear an error the consumer is already handling. if !matches!(self.status, QueryStatus::Failure | QueryStatus::Cancelled) { self.status = QueryStatus::Success; self.error = None; } } - /// Record a stale cache hit (data served from stale window). - /// - /// Increments cache hit counter and transitions status to - /// [`Success`](QueryStatus::Success) **only if the resource is not in a - /// terminal failure state** (`Failure` or `Cancelled`), mirroring - /// [`record_cache_hit`]. The caller is expected to also trigger a - /// background revalidation. + /// Record a stale cache hit (data served from the stale window). /// - /// [`record_cache_hit`]: Self::record_cache_hit + /// Same behavior as [`record_cache_hit`](Self::record_cache_hit); the caller + /// is expected to also trigger a background revalidation. pub(crate) fn record_stale_cache_hit(&mut self) { - self.cache_hits = self.cache_hits.saturating_add(1); - // Mirror record_cache_hit: only transition to Success from - // non-terminal states, so a stale hit does not silently clear a - // failure error the consumer is already handling. - if !matches!(self.status, QueryStatus::Failure | QueryStatus::Cancelled) { - self.status = QueryStatus::Success; - self.error = None; - } + self.record_cache_hit(); } /// Invalidate the cache (clear last-updated timestamp). diff --git a/crates/gpui-query/src/core/resource/lifecycle.rs b/crates/gpui-query/src/core/resource/lifecycle.rs index 5697ab1..575f044 100644 --- a/crates/gpui-query/src/core/resource/lifecycle.rs +++ b/crates/gpui-query/src/core/resource/lifecycle.rs @@ -7,14 +7,9 @@ use crate::core::{ use super::QueryResource; -/// Source of the [`RequestId`] for the shared `begin_request_inner` helper. -/// -/// Mirrors the `MaybeRequestId` pattern already used by -/// `InfiniteQueryResource` to dedup its four entry points. Keeping the two -/// public `begin_request` / `begin_request_with_id` entry points sharing one -/// implementation avoids the ~90% duplication flagged in N4, and threads the -/// stored per-resource sequencer (N3) through the `None` path so transient -/// callers no longer collide at `RequestId(1,1)`. +/// Source of the [`RequestId`] for the shared `begin_request_inner` helper: +/// a caller-supplied sequencer, or an optional pre-generated id with a +/// per-resource fallback. enum MaybeRequestId<'a> { FromSequencer(&'a mut RequestSequencer), Provided(Option<RequestId>), @@ -42,12 +37,11 @@ impl<T, E> QueryResource<T, E> { /// When `maybe_request_id` is `Some`, uses that ID directly (useful when /// the bucket's co-located sequencer has already generated the ID). /// When `None`, falls back to the resource's own stored sequencer so the - /// generated ids are monotonic and collision-free across calls (N3) rather - /// than every call producing a colliding `RequestId(1,1)`. + /// generated ids stay monotonic and collision-free across calls. /// - /// This is the preferred entry point for the hook layer (audit fixes - /// #1/#5/#15/#18): it allows the bucket's persistent sequencer to provide - /// globally unique, monotonically increasing RequestIds. + /// This is the preferred entry point for the hook layer: it lets the + /// bucket's persistent sequencer provide globally unique, monotonically + /// increasing RequestIds. pub fn begin_request_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -62,22 +56,15 @@ impl<T, E> QueryResource<T, E> { } /// Shared implementation behind [`begin_request`](Self::begin_request) and - /// [`begin_request_with_id`](Self::begin_request_with_id) (N4). - /// - /// `id_source` selects where the request id comes from: an external - /// sequencer (for `begin_request`) or a pre-allocated id with a - /// per-resource fallback (for `begin_request_with_id`). The fallback uses - /// the resource's own stored sequencer (N3) instead of a fresh - /// `RequestSequencer::new()`. + /// [`begin_request_with_id`](Self::begin_request_with_id). fn begin_request_inner( &mut self, now_ms: u64, fetch_mode: QueryFetchMode, mut id_source: MaybeRequestId, ) -> QueryBeginResult { - // Helper that resolves the next id from whichever source we were given, - // evaluated lazily so early-return guards never consume a sequence - // number (preserving the original counter-consumption behavior). + // Resolve the next id lazily so early-return guards never consume a + // sequence number. macro_rules! next_id { () => {{ match &mut id_source { @@ -152,13 +139,11 @@ impl<T, E> QueryResource<T, E> { /// Internal: transition to a loading state. /// - /// **v2 fix**: Cancels the old signal before creating a new one, - /// so in-flight fetchers for replaced requests can abort early. - /// - /// Note: This method performs no guard against the current status. Under - /// `LatestWins` policy, a second call while already `LoadingEmpty` is - /// intentional — it cancels the old request and starts a new one. The old - /// request's async task holds a stale `RequestId` and will be rejected by + /// Cancels the old signal before creating a new one, so in-flight + /// fetchers for replaced requests can abort early. Under `LatestWins`, + /// a second call while already `LoadingEmpty` is intentional: it cancels + /// the old request and starts a new one. The old request's async task + /// holds a stale `RequestId` and will be rejected by /// `accept_current_request()`. pub(crate) fn begin_loading(&mut self, request_id: RequestId, now_ms: u64) -> QueryStatus { let status = if self.has_data() { @@ -171,7 +156,7 @@ impl<T, E> QueryResource<T, E> { self.started_at = Some(QueryTimestamp::from(now_ms)); self.error = None; - // v2 fix: Cancel the OLD signal before replacing it. + // Cancel the OLD signal before replacing it. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } @@ -262,17 +247,14 @@ impl<T, E> QueryResource<T, E> { /// Reset the resource back to idle, clearing state and diagnostic counters. /// - /// **v2 fix**: Cancels the signal before clearing it. - /// /// **Preserves**: `cache_policy`, `request_policy`, `retry_policy`, and `key`. /// These are considered configuration, not runtime state, and persist across /// resets. Use `QueryResource::new()` to create a fully fresh resource with /// default policies. /// - /// Calling `reset()` on an already-Idle resource resets diagnostic counters - /// (`cache_hits`, `cancelled_count`, `ignored_results`, `retry_count`) to zero. - /// This is intentional — `reset()` always resets counters regardless of current - /// state. If counter preservation is needed, read them before calling `reset()`. + /// Counters (`cache_hits`, `cancelled_count`, `ignored_results`, + /// `retry_count`) are always reset regardless of current state. If counter + /// preservation is needed, read them before calling `reset()`. pub fn reset(&mut self) { // Cancel signal before dropping if let Some(signal) = self.signal.as_ref() { diff --git a/crates/gpui-query/src/core/select.rs b/crates/gpui-query/src/core/select.rs index 45d1780..00e14af 100644 --- a/crates/gpui-query/src/core/select.rs +++ b/crates/gpui-query/src/core/select.rs @@ -100,7 +100,7 @@ impl<T, U> std::fmt::Debug for SelectTransform<T, U> { impl<T, U> PartialEq for SelectTransform<T, U> { fn eq(&self, other: &Self) -> bool { // Closures have no PartialEq; compare by shared pointer identity, - // mirroring QuerySignal's Arc::ptr_eq approach (signal.rs). + // the same approach QuerySignal uses. Arc::ptr_eq(&self.transform, &other.transform) } } @@ -137,10 +137,10 @@ impl<T, U> SelectTransform<T, U> { /// /// # Storage /// -/// Source data is held as `Option<Arc<T>>` (audit #20) so that cloning a -/// `MappedQueryResource` (e.g. for derived views) is a cheap `Arc::clone` -/// rather than a full copy of `T`. `Arc<T>` is `Send + Sync` exactly when `T` -/// is, so the existing bounds are preserved. +/// Source data is held as `Option<Arc<T>` so cloning a `MappedQueryResource` +/// (e.g. for derived views) is a cheap `Arc::clone` rather than a full copy +/// of `T`. `Arc<T>` is `Send + Sync` exactly when `T` is, so the existing +/// bounds are preserved. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MappedQueryResource<T, U, E> { source_data: Option<Arc<T>>, @@ -151,9 +151,8 @@ pub struct MappedQueryResource<T, U, E> { impl<T, U, E> MappedQueryResource<T, U, E> { /// Create a new mapped resource. /// - /// Takes ownership of the source data as `Option<Arc<T>>` (audit #20). For - /// the common case of constructing from a plain `T`, wrap it with - /// `Some(Arc::new(t))`. + /// Takes the source data as `Option<Arc<T>>`. For the common case of + /// constructing from a plain `T`, wrap it with `Some(Arc::new(t))`. pub fn new(source_data: Option<Arc<T>>, transform: SelectTransform<T, U>) -> Self { Self { source_data, @@ -164,11 +163,9 @@ impl<T, U, E> MappedQueryResource<T, U, E> { /// Apply the transform to get the selected data. /// - /// **Note (audit #3):** This re-applies the transform closure on every call. - /// `MappedQueryResource` is a derived view with no separate output cache — it - /// stores only the source data and the transform function. If the transform is - /// expensive and you need the result multiple times in a single render pass - /// (e.g., once for display and once for an equality check), cache the result + /// Re-applies the transform closure on every call; this type is a derived + /// view with no separate output cache. If the transform is expensive and + /// you need the result multiple times in a single render pass, cache it /// in a local variable: /// /// ``` @@ -181,11 +178,10 @@ impl<T, U, E> MappedQueryResource<T, U, E> { /// // use `data` freely below /// ``` /// - /// For lightweight transforms (field access, counting, simple projections) the - /// cost is negligible and no caching is needed. + /// For lightweight transforms (field access, counting, simple projections) + /// the cost is negligible and no caching is needed. pub fn data(&self) -> Option<U> { - // `source_data` is `Option<Arc<T>>`; deref the Arc so the transform - // still receives `&T` as documented (audit #20). + // Deref the Arc so the transform still receives `&T` as documented. self.source_data .as_ref() .map(|d| self.transform.apply(d.as_ref())) @@ -198,23 +194,19 @@ impl<T, U, E> MappedQueryResource<T, U, E> { /// Read-only access to the source data. /// - /// Returns `Option<&T>` by dereferencing the stored `Arc<T>` (audit #20). - /// Keeping the `&T` return type (rather than `&Arc<T>`) is the least - /// disruptive choice: existing callers compare the pointed-to `T` and do - /// not need to change. Used by the hook layer to detect when the source - /// has changed before re-storing. + /// Returns `Option<&T>` by dereferencing the stored `Arc<T>`. Used by the + /// hook layer to detect when the source has changed before re-storing. pub fn source_data(&self) -> Option<&T> { self.source_data.as_ref().map(|arc| arc.as_ref()) } /// Cheaply hand out the cached source `Arc<T>` as an owned value. /// - /// Returns `Option<Arc<T>>` via a refcount bump (`Arc::clone`) — no `T` - /// clone. Audit H1: the hook layer uses this to compare the cached source - /// against a fresh read WITHOUT cloning `T` on unchanged notifications. - /// Because the returned `Arc<T>` is owned, the mapped borrow ends with - /// this call, so a subsequent `entity.read_with` does not create the - /// nested borrow that audit #115 removed. + /// Returns `Option<Arc<T>>` via a refcount bump — no `T` clone. The hook + /// layer uses this to compare the cached source against a fresh read + /// without cloning `T` on unchanged notifications. The returned `Arc<T>` + /// is owned, so the mapped borrow ends with this call and a subsequent + /// `entity.read_with` does not create a nested borrow. pub fn source_arc(&self) -> Option<Arc<T>> { self.source_data.clone() } @@ -222,10 +214,10 @@ impl<T, U, E> MappedQueryResource<T, U, E> { /// Update the source data from the underlying query resource. /// /// Call this when the source `QueryResource` changes (fetch completes, - /// cache invalidation, etc.) to keep the mapped view in sync. The transform - /// is not applied here — it is applied lazily when [`data()`](Self::data) - /// is called. Takes `Option<Arc<T>>` so callers can hand over a cheap - /// `Arc::clone` instead of cloning the full `T` (audit #20). + /// cache invalidation, etc.) to keep the mapped view in sync. The + /// transform is not applied here — it is applied lazily when + /// [`data()`](Self::data) is called. Takes `Option<Arc<T>>` so callers + /// can hand over a cheap `Arc::clone` instead of cloning the full `T`. pub fn update_source(&mut self, data: Option<Arc<T>>) { self.source_data = data; } diff --git a/crates/gpui-query/src/tests/core_cache/mod.rs b/crates/gpui-query/src/tests/core_cache/mod.rs index 94a9511..c5ce6bd 100644 --- a/crates/gpui-query/src/tests/core_cache/mod.rs +++ b/crates/gpui-query/src/tests/core_cache/mod.rs @@ -19,10 +19,8 @@ use crate::tests::test_support::*; // ── Named time constants ──────────────────────────────────────────────── // -// All TTL/SWR tests seed data at STORED_AT_MS and reason about boundary -// offsets from there. Naming these values makes the age arithmetic -// self-documenting rather than forcing the reader to reverse-engineer -// magic numbers. +// Tests seed data at STORED_AT_MS and reason about boundary offsets from +// there, so the age arithmetic reads without magic numbers. /// The `stored_at` timestamp used by every seeded cache entry (ms). pub(crate) const STORED_AT_MS: u64 = 1_000; @@ -34,13 +32,12 @@ pub(crate) const TTL_MS: u64 = 1_000; pub(crate) const STALE_MS: u64 = 2_000; /// Total validity window for the SWR resource (TTL + stale). -pub(crate) const SWR_TOTAL_MS: u64 = TTL_MS + STALE_MS; // 3_000 +pub(crate) const SWR_TOTAL_MS: u64 = TTL_MS + STALE_MS; -// Derived boundary offsets from STORED_AT_MS: -pub(crate) const AT_TTL_BOUNDARY: u64 = STORED_AT_MS + TTL_MS; // 2_000 — exactly at TTL edge -pub(crate) const ONE_MS_PAST_TTL: u64 = AT_TTL_BOUNDARY + 1; // 2_001 — just past TTL -pub(crate) const AT_SWR_BOUNDARY: u64 = STORED_AT_MS + SWR_TOTAL_MS; // 4_000 — exactly at total edge -pub(crate) const ONE_MS_PAST_SWR: u64 = AT_SWR_BOUNDARY + 1; // 4_001 — fully expired +pub(crate) const AT_TTL_BOUNDARY: u64 = STORED_AT_MS + TTL_MS; // 2_000 +pub(crate) const ONE_MS_PAST_TTL: u64 = AT_TTL_BOUNDARY + 1; // 2_001 +pub(crate) const AT_SWR_BOUNDARY: u64 = STORED_AT_MS + SWR_TOTAL_MS; // 4_000 +pub(crate) const ONE_MS_PAST_SWR: u64 = AT_SWR_BOUNDARY + 1; // 4_001 // ── Helpers ────────────────────────────────────────────────────────────── @@ -60,9 +57,6 @@ pub(crate) fn swr_resource() -> QueryResource<&'static str> { } pub(crate) fn nocache_test_resource() -> QueryResource<&'static str> { - // Audit fix #122: delegate to the shared `nocache_resource` helper in - // test_support rather than rebuilding the resource inline, so there is a - // single source of truth for the NoCache + LatestWins test resource. nocache_resource("nocache-test") } diff --git a/crates/gpui-query/src/tests/core_infinite_query/helpers.rs b/crates/gpui-query/src/tests/core_infinite_query/helpers.rs index 043dc1d..9356d90 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/helpers.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/helpers.rs @@ -29,11 +29,8 @@ const PAGE_LABELS: [&str; 64] = [ "page55", "page56", "page57", "page58", "page59", "page60", "page61", "page62", "page63", ]; -/// Default page-content generator used by [`load_n_pages`]. -/// -/// Produces `vec!["page{i}"]` for page index `i`, matching the historical -/// behavior of `load_n_pages` before it was parameterized. Uses static labels -/// (no allocation). +/// Default page-content generator used by [`load_n_pages`]: `vec!["page{i}"]` +/// for page index `i`, with static labels (no allocation). fn default_page(i: usize) -> Vec<&'static str> { vec![PAGE_LABELS[i]] } diff --git a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs index d260c54..6b24e90 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs @@ -22,13 +22,11 @@ fn new_resource_has_idle_state_with_empty_pages() { assert!(r.started_at_ms().is_none()); assert!(r.last_updated_at_ms().is_none()); - // v2: ForwardOnly defaults assert!(r.has_next_page()); assert!(!r.has_previous_page()); assert!(!r.is_fetching_next_page()); assert!(!r.is_fetching_previous_page()); - // v2: bounded default assert_eq!(r.max_pages(), Some(50)); assert_eq!(r.direction(), FetchDirection::ForwardOnly); diff --git a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs index 5cbaa7a..b1b7789 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs @@ -52,7 +52,7 @@ fn max_pages_evicts_newest_page_on_prepend() { fn max_pages_zero_treated_as_unbounded() { let mut r = load_n_pages(3); - // v2 audit 2: Some(0) is treated as None (unbounded) — no eviction + // Some(0) is treated as unbounded — no eviction r.set_max_pages(Some(0)); assert_eq!(r.max_pages(), None); assert_eq!(r.page_count(), 3); diff --git a/crates/gpui-query/src/tests/core_lifecycle/mod.rs b/crates/gpui-query/src/tests/core_lifecycle/mod.rs index 29c1477..46a99f9 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/mod.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/mod.rs @@ -1,4 +1,4 @@ -//! Comprehensive tests for the core lifecycle of QueryResource (v2). +//! Comprehensive tests for the QueryResource lifecycle. //! //! Covers all state transitions, cancellation, stale request rejection, //! reset, retry counter management, signal lifecycle, and request policies. diff --git a/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs b/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs index 5be112f..9f980ae 100644 --- a/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs +++ b/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs @@ -5,9 +5,6 @@ use crate::core::*; use crate::tests::test_support::assert_serde_roundtrip; use std::num::NonZero; -// The serde-roundtrip helper is now shared from `test_support` (extends audit -// #129 / T10); the four enum roundtrip tests below call it directly. - // ═══════════════════════════════════════════════════════════════════════════ // QueryStatus // ═══════════════════════════════════════════════════════════════════════════ @@ -49,7 +46,6 @@ fn query_status_is_pending() { #[test] fn query_status_serde_roundtrip() { - // Audit fix #55: table-driven via the shared roundtrip helper. assert_serde_roundtrip(&[ QueryStatus::Idle, QueryStatus::LoadingEmpty, @@ -157,7 +153,6 @@ fn mutation_status_labels() { #[test] fn mutation_status_serde_roundtrip() { - // Audit fix #55: table-driven via the shared roundtrip helper. assert_serde_roundtrip(&[ MutationStatus::Idle, MutationStatus::Loading, @@ -239,7 +234,6 @@ fn cache_policy_ttl_is_expired_past_ttl() { #[test] fn cache_policy_serde_roundtrip() { - // Audit fix #55: table-driven via the shared roundtrip helper. assert_serde_roundtrip(&[ CachePolicy::NoCache, CachePolicy::Ttl { ttl_ms: 5_000 }, @@ -287,7 +281,6 @@ fn request_policy_labels() { #[test] fn request_policy_serde_roundtrip() { - // Audit fix #55: table-driven via the shared roundtrip helper. assert_serde_roundtrip(&[RequestPolicy::LatestWins, RequestPolicy::IgnoreWhileLoading]); } diff --git a/crates/gpui-query/src/tests/core_policy_types/query_error.rs b/crates/gpui-query/src/tests/core_policy_types/query_error.rs index 3221e59..dc2ffe3 100644 --- a/crates/gpui-query/src/tests/core_policy_types/query_error.rs +++ b/crates/gpui-query/src/tests/core_policy_types/query_error.rs @@ -76,7 +76,6 @@ fn query_error_as_ref_str() { #[test] fn query_error_serde_roundtrip() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ QueryError::transport("connection refused"), QueryError::cancelled("aborted"), @@ -143,19 +142,12 @@ fn query_error_sanitized_home_path() { #[test] fn query_error_sanitized_users_path_uppercase() { - // NOTE: The sanitizer lowercases the text for matching but the prefix - // "/Users/" contains uppercase, so the case-insensitive find may not match - // depending on the input. Verify the actual behavior: + // Path matching is case-insensitive, so the macOS "/Users/" prefix must + // be redacted just like "/home/". let err = QueryError::unknown("error in /Users/admin/.env leaked"); let clean = err.sanitized(); - // The redact_paths function lowercases the text but tries to find the - // mixed-case prefix "/Users/" in the lowercased version — which won't match. - // This is a known limitation of the sanitizer for mixed-case path prefixes. - // The path should still appear in the output (not redacted) in this case. - assert!( - clean.message().contains("/Users/admin/.env"), - "mixed-case /Users/ prefix not redacted by current implementation" - ); + assert!(!clean.message().contains("/Users/admin/.env")); + assert!(clean.message().contains("[REDACTED_PATH]")); } #[test] diff --git a/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs b/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs index eaca8a1..5303d4c 100644 --- a/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs +++ b/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs @@ -120,7 +120,6 @@ fn retry_policy_should_retry_zero_max() { #[test] fn retry_policy_serde_roundtrip() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ RetryPolicy::new(5) .with_delay(200) @@ -167,7 +166,6 @@ fn refetch_trigger_equality_and_copy() { #[test] fn refetch_trigger_serde_roundtrip() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ RefetchTrigger::Always, RefetchTrigger::IfStale, diff --git a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs index b487757..4e2dafb 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs @@ -1,6 +1,6 @@ //! Tests for InfiniteQueryResource advanced scenarios. //! -//! Covers untested paths: +//! Covers: //! - InfiniteQueryResource cross-direction replacement //! - InfiniteQueryResource cache_policy and request_policy setters //! - InfiniteQueryResource retry_policy and set_retry_policy diff --git a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs index 1c0a60a..8a91c8a 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs @@ -1,6 +1,6 @@ //! Tests for QueryResource advanced scenarios. //! -//! Covers untested paths: +//! Covers: //! - Error recovery: Failure -> Success, Failure -> Cancel, Cancel -> Success //! - signal_mut accessor //! - set_retry_policy and retry_policy interaction From cd9ec227ea882e0e69186bcd5b497360758e0e0c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 13:10:31 +0200 Subject: [PATCH 022/111] fix: saturate gc thresholds and prune stale persisted metadata - gc_threshold * SUCCESS_GC_MULTIPLIER now saturates; with_gc_time(u64::MAX) no longer panics (debug) or wraps into a smaller success threshold that mass-evicts cached entries (release) - gc_with_time prunes persisted_meta down to keys still present in a query or infinite bucket, closing unbounded growth from churned Fetched::meta keys - perf: for_each_matching_entry collects upgraded entities in one pass; invalidate_matching only updates entities with a live last_updated_at (no observer notify storm on bulk invalidation); prepare_fetch/prefetch merge into a single entity.update lock cycle - collapse QueryBucket/InfiniteQueryBucket duplication into a generic ResourceBucket<R> + BucketResource trait in bucket/shared.rs; public API unchanged, ops/infinite_bucket are thin facades - strip audit/history comments from client + scoped tests, tighten docs to 1-3 lines; drop dead resource_with_sequencer test helper tests: 819 passed / 0 failed (unchanged); clippy -D warnings clean; client-scope rustdoc warnings at 0 --- .../src/client/bucket/erased_ops.rs | 112 ++---- crates/gpui-query/src/client/bucket/mod.rs | 27 +- crates/gpui-query/src/client/bucket/ops.rs | 266 +------------ crates/gpui-query/src/client/bucket/shared.rs | 368 +++++++++++++++++- crates/gpui-query/src/client/bucket/types.rs | 62 +-- crates/gpui-query/src/client/devtools.rs | 55 +-- crates/gpui-query/src/client/erased.rs | 84 ++-- .../gpui-query/src/client/infinite_bucket.rs | 362 +++-------------- .../src/client/infinite_mutation_ops.rs | 74 +--- crates/gpui-query/src/client/lifecycle.rs | 273 ++++--------- crates/gpui-query/src/client/mod.rs | 213 +++------- .../gpui-query/src/client/mutation_bucket.rs | 233 ++--------- .../gpui-query/src/client/mutation_signal.rs | 28 +- crates/gpui-query/src/client/observer.rs | 52 +-- .../gpui-query/src/client/prepared_fetch.rs | 58 +-- crates/gpui-query/src/client/time.rs | 28 +- .../src/tests/coverage_gaps/concurrency.rs | 3 +- .../src/tests/coverage_gaps/gap_tests.rs | 74 +--- .../src/tests/coverage_gaps/gc_eviction.rs | 8 +- .../src/tests/coverage_gaps/property_based.rs | 5 - .../tests/integration_client/client_basics.rs | 8 +- .../tests/integration_client/data_access.rs | 5 +- .../invalidation_reset_gc.rs | 26 +- .../src/tests/integration_client/mod.rs | 33 +- .../integration_client/mutations_lifecycle.rs | 5 +- crates/gpui-query/src/tests/test_support.rs | 48 +-- 26 files changed, 788 insertions(+), 1722 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/erased_ops.rs b/crates/gpui-query/src/client/bucket/erased_ops.rs index a60177c..2535d6f 100644 --- a/crates/gpui-query/src/client/bucket/erased_ops.rs +++ b/crates/gpui-query/src/client/bucket/erased_ops.rs @@ -1,8 +1,4 @@ //! `ErasedBucket` trait implementation for `QueryBucket`. -//! -//! Contains garbage collection, bulk invalidation/reset/cancel, and -//! diagnostic collection — all methods dispatched through the type-erased -//! bucket trait. use gpui::App; @@ -23,66 +19,42 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB self } - /// Garbage-collect stale query resources. - /// - /// Reads entity state directly via `entity.read(cx)` (CL2/#106 fix) — - /// the cached `StatusSnapshot` was never refreshed from production and - /// was therefore stale. Evicts entries where: - /// 1. The weak reference is dead (entity was collected), OR - /// 2. The resource is in an evictable state (`Idle`, `Failure`, or - /// `Cancelled` — #107) and data age exceeds `gc_time_ms`, OR - /// 3. The resource is `Success` and data age exceeds - /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`, OR - /// 4. The resource is `Success` with a `StaleWhileRevalidate` policy - /// and data age exceeds the total valid window. - /// - /// Resources that are actively loading are always retained. - /// - /// Uses `HashMap::retain()` to avoid the intermediate `Vec<QueryKey>` - /// allocation. fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { - QueryBucket::gc(self, now_ms, gc_time_ms, cx); + self.inner.gc(now_ms, gc_time_ms, cx); } fn count(&self) -> usize { - self.entries.len() + self.inner.entries.len() } - /// Collect keys (cheap Arc increments) then upgrade individually, deferring - /// the `upgrade()` cost and avoiding upgrades for entities that may have - /// been collected between collection and update. fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { - entity.update(cx, |resource, _| resource.invalidate()); + self.inner.for_each_matching_entry(filter, cx, |entity, cx| { + // invalidate() only clears last_updated_at; skip the update (which + // notifies observers even on a no-op) when it is already None. + let needs_invalidate = + entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); + if needs_invalidate { + entity.update(cx, |resource, _| resource.invalidate()); + } }); } fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { + self.inner.for_each_matching_entry(filter, cx, |entity, cx| { entity.update(cx, |resource, _| resource.reset()); }); } fn remove_matching(&mut self, filter: &QueryKeyFilter) { - self.entries.retain(|k, _| !filter.matches(k)); + self.inner.entries.retain(|k, _| !filter.matches(k)); } - /// Cancel in-flight requests for entries matching the filter. - /// - /// **M7 (deliberate trade-off, documented)**: this does a - /// `read_with`-then-`update` (two entity lock acquisitions) per match rather - /// than a single unconditional `update`. The reason is that - /// `entity.update` *always* notifies observers even when the closure mutates - /// nothing, so updating every matching entry would spam observers with - /// no-op notifications for entries that aren't loading. Instead we read the - /// authoritative `is_loading()` flag (the M2 entry `loading` mirror is - /// *intentionally not consulted here* — a stale mirror could skip an - /// in-flight cancel) and only pay for the `update` when we will actually - /// mutate. This is the accepted form of the audit's refined fix (option a/b). + /// `entity.update` notifies observers even when the closure mutates + /// nothing, so gate on the authoritative `is_loading()` read (the entry + /// mirror could be stale and skip an in-flight cancel). fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { - let is_loading = entity.read_with(cx, |r, _| r.is_loading()); - if is_loading { + self.inner.for_each_matching_entry(filter, cx, |entity, cx| { + if entity.read_with(cx, |r, _| r.is_loading()) { entity.update(cx, |resource, _| { if let Some(signal) = resource.signal() { signal.cancel(); @@ -93,43 +65,22 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB }); } - /// Push each live entry's diagnostic into `out` instead of allocating a - /// fresh `Vec`. fn collect_diagnostics_into(&self, now_ms: u64, cx: &App, out: &mut Vec<QueryDiagnostic>) { - for (key, entry) in self.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - out.push(QueryDiagnostic { - key: key.to_path(), - status: resource.status(), - cache_policy: resource.cache_policy().label(), - cache_age_ms: resource.cache_age_ms(now_ms), - cache_hits: resource.cache_hits(), - retry_count: resource.retry_count(), - }); - } + self.inner.collect_diagnostics_into(now_ms, cx, out); } - /// Lightweight key/status pairs (#9). Pushes each live entry's `(key, - /// status)` pair into `out`, avoiding the `String` allocations of - /// `cache_policy`/`retry_count` and the `now_ms` syscall for callers that - /// only need the key and status (e.g. `dehydrate`). #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(String, crate::core::QueryStatus)>) { - for (key, entry) in self.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - out.push((key.to_path(), resource.status())); - } + self.inner.collect_key_status_into(cx, out); + } + + #[cfg(feature = "persist")] + fn contains_key(&self, key: &crate::core::QueryKey) -> bool { + self.inner.entries.contains_key(key) } - /// Value-carrying variant for persistence. For each `Success` entry whose - /// `(T, E)` has a registered serializer, push `(key, PersistedEntry)` into - /// `out`. Entries without a serializer (or not in `Success`) are skipped. + /// For each `Success` entry whose `T` has a registered serializer, push + /// `(key, PersistedEntry)` into `out`; everything else is skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -143,14 +94,12 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB ) { use crate::core::QueryStatus; - // Serialization depends on the data type `T` (not the error type `E`), - // so look up the registry by `TypeId::of::<T>()` — the same key used by - // `SerializerRegistry::register::<T>` (via `register_serializer::<T, E>`). + // Serializers are registered by `T` alone, not the `(T, E)` pair. let type_id = std::any::TypeId::of::<T>(); let Some(serialize_fn) = serializers.get(type_id) else { return; }; - for (key, entry) in self.entries.iter() { + for (key, entry) in self.inner.entries.iter() { let Some(entity) = entry.entity.upgrade() else { continue; }; @@ -161,10 +110,9 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB let Some(data) = resource.data() else { continue; }; + // Downcast failure is unreachable by construction (see + // `SerializerRegistry::register`); skip rather than persist junk. let Some(value) = serialize_fn(data as &dyn std::any::Any) else { - // Downcast failed (unreachable by construction; see - // `SerializerRegistry::register`). Skip the entry rather than - // persisting a placeholder. continue; }; out.push(( diff --git a/crates/gpui-query/src/client/bucket/mod.rs b/crates/gpui-query/src/client/bucket/mod.rs index be3dc0d..036cf8a 100644 --- a/crates/gpui-query/src/client/bucket/mod.rs +++ b/crates/gpui-query/src/client/bucket/mod.rs @@ -1,26 +1,9 @@ -//! Type-partitioned bucket for query resources. +//! Type-partitioned buckets for query resources. //! -//! **v2 improvements**: -//! - Uses `AHashMap` instead of `std::collections::HashMap` -//! - Co-locates `RequestSequencer` with entity in `BucketEntry` -//! - Collect-then-update pattern avoids nested entity borrows -//! -//! **Audit fixes (findings 1-5)**: -//! - Uses `WeakEntity` to avoid preventing GC of unused resources (finding 3) -//! - Enforces minimum GC time of 1000ms to avoid aggressive eviction (finding 1) -//! - Implements actual policy updates and calls them from `get_or_create` (findings 2, 5) -//! -//! **Audit 3 fixes (this pass)**: -//! - GC reads entity state directly via `entity.read(cx)` instead of a cached -//! `StatusSnapshot` (CL2/#106). The snapshot machinery was never refreshed -//! from production, so it was stale — GC now reads the source of truth. -//! - `observer_count` removed (#8): it was never incremented from production -//! hooks (Drop has no `cx` in GPUI), so it was always 0. `WeakEntity::upgrade()` -//! is the sole liveness probe. -//! - `retain`/`release`/`update_status_snapshot` removed as dead code (#75). -//! - Opportunistic GC trigger added (CL1/#105): see `bucket::shared`. -//! - `MIN_GC_TIME_MS` exported once from `types` and reused everywhere (#69). -//! - Shared constants/helpers extracted to `bucket::shared` (#10, conservative). +//! `ResourceBucket` in `shared` holds the machinery shared by +//! [`QueryBucket`] and [`InfiniteQueryBucket`](crate::client::InfiniteQueryBucket): +//! weak-entity entries with co-located request sequencers, capacity-bounded +//! eviction, GC, bulk key-filter operations, and diagnostics. mod erased_ops; mod ops; diff --git a/crates/gpui-query/src/client/bucket/ops.rs b/crates/gpui-query/src/client/bucket/ops.rs index ad11e9f..19c7f53 100644 --- a/crates/gpui-query/src/client/bucket/ops.rs +++ b/crates/gpui-query/src/client/bucket/ops.rs @@ -1,284 +1,44 @@ -//! Core operations for `QueryBucket`: construction, get-or-create, and -//! sequencer access. +//! The `(T, E)`-typed query bucket: a facade over [`ResourceBucket`]. -use ahash::AHashMap; -use gpui::{App, AppContext as _}; +use gpui::{App, Entity}; use crate::core::{ - CachePolicy, QueryKey, QueryResource, QueryStatus, RequestPolicy, RequestSequencer, + CachePolicy, QueryKey, QueryResource, RequestPolicy, RequestSequencer, }; -use super::types::{BucketEntry, DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS}; +use super::shared::ResourceBucket; /// Type-partitioned storage for query resources of a specific `(T, E)` type pair. pub struct QueryBucket<T, E> { - pub(crate) entries: AHashMap<QueryKey, BucketEntry<T, E>>, - /// Maximum number of entries allowed in this bucket. - /// When exceeded, the oldest entry (by `last_updated_ms`) is evicted. - pub(crate) max_entries: usize, + pub(crate) inner: ResourceBucket<QueryResource<T, E>>, } impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> QueryBucket<T, E> { - /// Create a new bucket with the default max entry limit. pub(crate) fn new() -> Self { Self { - entries: AHashMap::new(), - max_entries: DEFAULT_MAX_ENTRIES, + inner: ResourceBucket::new(), } } - /// Evict the oldest (least-recently-updated) entry to make room for a new one. - /// - /// Called when `get_or_create` would exceed `max_entries`. Selects the - /// entry with the smallest mirrored `last_updated_ms`. Entries that are - /// mirrored as loading are skipped (#109) so in-flight requests are never - /// evicted. - /// - /// **M2 (O(n)→O(1) entity reads)**: the scan reads only the entry MIRRORS - /// (cheap field reads + `WeakEntity::upgrade` liveness, no `entity.read`), - /// then performs **one** `entity.read` on the single winner to confirm - /// `!is_loading()` (guards #109 against a stale mirror where a fetch began - /// after the last refresh) and read the authoritative - /// `last_updated_at_ms`. If the winner is actually loading, its mirror is - /// marked and we re-pick. Typical cost: 1 entity read. - /// - /// The chosen key is cloned once (#59) rather than on every iteration. - pub(crate) fn evict_oldest(&mut self, cx: &App) { - loop { - // Mirror scan: NO entity.read here. Pick min mirror-timestamp - // among entries whose mirror says !loading AND whose weak ref is - // still live (a dead entry is GC's job, not eviction's, but it - // also can't be the "oldest live" winner). - let target = self - .entries - .iter() - .filter_map(|(key, entry)| { - if entry.loading { - return None; - } - entry.entity.upgrade()?; - Some((key, entry.last_updated_ms.unwrap_or(0))) - }) - .min_by_key(|&(_, age)| age); - - let Some((key, _)) = target else { - // Every live entry is mirrored as loading: nothing safe to evict. - return; - }; - - // Single confirm read on the winner. Cloned out of the shared - // borrow so `remove` can take `&mut self.entries` (E0502). - let key = key.clone(); - let still_loading = self - .entries - .get(&key) - .and_then(|e| e.entity.upgrade()) - .map(|entity| entity.read(cx).is_loading()); - - match still_loading { - Some(true) => { - // Mirror was stale: a fetch began after the last refresh. - // Mark it and re-pick so #109 is honored. - if let Some(entry) = self.entries.get_mut(&key) { - entry.loading = true; - } - continue; - } - _ => { - // Confirmed not loading (or already dead/collected between - // the scan and the confirm): evict. - self.entries.remove(&key); - return; - } - } - } - } - - /// Get an existing entity or create a new one. - /// - /// If the key already exists and the weak reference can be upgraded, the - /// existing entity is returned. If the entity was already collected (all - /// strong references dropped), the stale entry is replaced with a fresh one. - /// - /// When the key already exists and the policies differ from the stored - /// resource's current policies, the resource is updated in-place via - /// `set_cache_policy` / `set_request_policy`. - /// - /// When creating a new entry would exceed `max_entries`, the oldest entry - /// is evicted first (finding 4 fix). pub(crate) fn get_or_create( &mut self, key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy, cx: &mut App, - ) -> gpui::Entity<QueryResource<T, E>> { - // Audit fix #58: previously the dead-ref path hashed the key up to 3x - // (get → remove → insert). We collapse it to 2x: one probe to resolve - // the hit/dead/miss outcome, then a single `insert` for the create - // path. `entry()` cannot span the eviction (its borrow of - // `self.entries` conflicts with `self.evict_oldest`'s borrow of `self`), - // so the dead/miss path re-probes via `insert` — best-effort, noted in - // the audit. The common alive-hit path is still a single hash. - if let Some(entry) = self.entries.get_mut(&key) { - if let Some(entity) = entry.entity.upgrade() { - // M2: refresh the mirror from the same read we already do for - // the policy check (zero extra reads). - let (needs_update, last_updated, loading) = entity.read_with(cx, |resource, _| { - let needs_update = resource.cache_policy() != cache_policy - || resource.request_policy() != request_policy; - ( - needs_update, - resource.last_updated_at_ms(), - resource.is_loading(), - ) - }); - entry.last_updated_ms = last_updated; - entry.loading = loading; - if needs_update { - entity.update(cx, |resource, _| { - resource.set_cache_policy(cache_policy); - resource.set_request_policy(request_policy); - }); - } - return entity; - } - // Dead occupant: fall through to the insert path, which overwrites - // the stale entry in place. Length is unchanged so no eviction. - } else if self.entries.len() >= self.max_entries { - // Vacant and at capacity: evict before creating. - self.evict_oldest(cx); - } - - let entity = cx.new(|_| QueryResource::new(key.clone(), cache_policy, request_policy)); - self.entries.insert( - key, - BucketEntry { - entity: entity.downgrade(), - sequencer: RequestSequencer::new(), - // Fresh resource: never completed, not loading. - last_updated_ms: None, - loading: false, - }, - ); - entity - } - - // (Audit cleanup: `maybe_gc` was dead code — never called from production. - // The real opportunistic trigger is `QueryClient::maybe_opportunistic_gc`, - // which drives `gc` directly every `GC_INTERVAL` ops. Removed alongside - // `should_run_opportunistic_gc` and the now-write-only `last_gc_ms` field.) - - /// Get an existing entity by key. - /// - /// Returns `None` if the key is not in the bucket or if the weak reference - /// can no longer be upgraded (the entity was collected). - pub(crate) fn get(&self, key: &QueryKey) -> Option<gpui::Entity<QueryResource<T, E>>> { - self.entries.get(key).and_then(|e| e.entity.upgrade()) + ) -> Entity<QueryResource<T, E>> { + self.inner.get_or_create(key, cache_policy, request_policy, cx) } - /// All entities in this bucket that are still alive. - /// - /// Allocates a `Vec` per call — callers on hot render paths should cache - /// the result (#60). - pub(crate) fn all_entities(&self) -> Vec<gpui::Entity<QueryResource<T, E>>> { - self.entries - .values() - .filter_map(|e| e.entity.upgrade()) - .collect() + pub(crate) fn get(&self, key: &QueryKey) -> Option<Entity<QueryResource<T, E>>> { + self.inner.get(key) } - /// Get a mutable reference to the sequencer for an entry. - /// - /// Returns `None` if the key is not found. pub(crate) fn sequencer_mut(&mut self, key: &QueryKey) -> Option<&mut RequestSequencer> { - self.entries.get_mut(key).map(|e| &mut e.sequencer) + self.inner.sequencer_mut(key) } - /// **L9**: shared "collect matching keys -> upgrade weak ref -> per-entry - /// action" driver used by `invalidate_matching` / `reset_matching` / - /// `cancel_matching` (the erased-trait impls in `erased_ops.rs`). - /// - /// `remove_matching` is *not* routed through here — it is just - /// `HashMap::retain`, with no upgrade or `update`. - /// - /// The `action` closure receives the upgraded strong entity and the `cx`, - /// so each caller chooses its own lock pattern: `invalidate`/`reset` do a - /// single unconditional `entity.update`, while `cancel` does a - /// `read_with`-then-`update`-only-if-loading (see M7). Semantics are - /// byte-identical to the prior inlined loops: same filter, same per-key - /// `get` + `upgrade`, same per-entry closure body. - pub(crate) fn for_each_matching_entry( - &mut self, - filter: &crate::core::QueryKeyFilter, - cx: &mut App, - mut action: impl FnMut(&gpui::Entity<QueryResource<T, E>>, &mut App), - ) { - let keys: Vec<QueryKey> = self - .entries - .keys() - .filter(|key| filter.matches(key)) - .cloned() - .collect(); - - for key in keys { - if let Some(entry) = self.entries.get(&key) - && let Some(entity) = entry.entity.upgrade() - { - action(&entity, cx); - } - } - } - - /// Run garbage collection on this bucket. - /// - /// Reads entity state directly via `entity.read(cx)` (CL2/#106) rather - /// than trusting a cached snapshot. The snapshot machinery was never - /// refreshed from production hooks, so it was always stale. - pub(crate) fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { - let gc_time_ms = gc_time_ms.max(MIN_GC_TIME_MS); - let gc_threshold = gc_time_ms; - let success_threshold = gc_threshold * (super::types::SUCCESS_GC_MULTIPLIER as u64); - - self.entries.retain(|_key, entry| { - let Some(entity) = entry.entity.upgrade() else { - return false; - }; - let resource = entity.read(cx); - - // M2: refresh the eviction mirror from this read (gc walks every - // entry anyway, so this is the canonical refresh point). - entry.last_updated_ms = resource.last_updated_at_ms(); - entry.loading = resource.is_loading(); - - if resource.is_loading() { - return true; - } - - let status = resource.status(); - - let age_ms = resource - .last_updated_at_ms() - .map(|updated| now_ms.saturating_sub(updated)) - .unwrap_or(gc_threshold); - - if status == QueryStatus::Success { - let cache_policy = resource.cache_policy(); - if cache_policy.can_serve_stale() && !cache_policy.is_expired(age_ms) { - return true; - } - return age_ms < success_threshold; - } - - let evictable = matches!( - status, - QueryStatus::Idle | QueryStatus::Failure | QueryStatus::Cancelled - ); - if !evictable { - return true; - } - - age_ms < gc_threshold - }); + pub(crate) fn all_entities(&self) -> Vec<Entity<QueryResource<T, E>>> { + self.inner.all_entities() } } diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 215036a..a9d5142 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -1,21 +1,351 @@ -//! Shared constants and helpers for bucket implementations. +//! Shared bucket machinery. //! -//! This module hosts logic that is structurally duplicated across -//! `QueryBucket`, `InfiniteQueryBucket`, and `MutationBucket` (finding #10). -//! The collapse is intentionally conservative: only clearly-safe shared -//! constants and pure helpers live here. A full generic `Bucket<K, R>` -//! collapse was deemed too risky for this pass. - -/// Opportunistic GC interval — run GC every `GC_INTERVAL` insertions. -/// -/// This makes GC actually run in production without requiring hooks to call -/// `gc()` explicitly (CL1/#105). The interval is chosen to amortize GC cost: -/// at 64 insertions between sweeps, the per-insert overhead is one `len()` -/// check and one modulo. -/// -/// (Audit cleanup: `GC_MIN_ENTRIES` and `should_run_opportunistic_gc` were dead -/// code — `QueryClient::maybe_opportunistic_gc` drives GC directly with -/// `GC_INTERVAL` and `MIN_GC_TIME_MS` and never called the shared helper. Both -/// were removed; `GC_INTERVAL` is retained because `maybe_opportunistic_gc` -/// reads it.) +//! `ResourceBucket<R>` holds everything `QueryBucket` and +//! `InfiniteQueryBucket` do identically (get-or-create, eviction, GC, bulk +//! matching, diagnostics); the two public bucket types are thin facades that +//! only add their erased-trait impls and persistence specifics. + +use ahash::AHashMap; +use gpui::{App, AppContext as _, Entity}; + +use crate::client::devtools::QueryDiagnostic; +use crate::core::{ + CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, QueryResource, QueryStatus, + RequestPolicy, +}; + +use super::types::{BucketEntry, DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; + +/// Run GC every this many resource operations so it fires in production +/// without anyone calling `gc()` by hand. pub(crate) const GC_INTERVAL: usize = 64; + +/// The resource surface `ResourceBucket` needs; implemented for both query +/// resource kinds. Prefixed names keep the delegating impls unambiguous. +pub(crate) trait BucketResource { + fn new_resource(key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy) + -> Self; + fn resource_status(&self) -> QueryStatus; + fn resource_is_loading(&self) -> bool; + fn resource_last_updated(&self) -> Option<u64>; + fn resource_cache_policy(&self) -> CachePolicy; + fn resource_request_policy(&self) -> RequestPolicy; + fn set_resource_cache_policy(&mut self, policy: CachePolicy); + fn set_resource_request_policy(&mut self, policy: RequestPolicy); + fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64>; + fn resource_cache_hits(&self) -> u64; + fn resource_retry_count(&self) -> u32; +} + +impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { + fn new_resource( + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + ) -> Self { + Self::new(key, cache_policy, request_policy) + } + fn resource_status(&self) -> QueryStatus { + self.status() + } + fn resource_is_loading(&self) -> bool { + self.is_loading() + } + fn resource_last_updated(&self) -> Option<u64> { + self.last_updated_at_ms() + } + fn resource_cache_policy(&self) -> CachePolicy { + self.cache_policy() + } + fn resource_request_policy(&self) -> RequestPolicy { + self.request_policy() + } + fn set_resource_cache_policy(&mut self, policy: CachePolicy) { + self.set_cache_policy(policy); + } + fn set_resource_request_policy(&mut self, policy: RequestPolicy) { + self.set_request_policy(policy); + } + fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64> { + self.cache_age_ms(now_ms) + } + fn resource_cache_hits(&self) -> u64 { + self.cache_hits() + } + fn resource_retry_count(&self) -> u32 { + self.retry_count() + } +} + +impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { + fn new_resource( + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + ) -> Self { + Self::new(key, cache_policy, request_policy) + } + fn resource_status(&self) -> QueryStatus { + self.status() + } + fn resource_is_loading(&self) -> bool { + self.is_loading() + } + fn resource_last_updated(&self) -> Option<u64> { + self.last_updated_at_ms() + } + fn resource_cache_policy(&self) -> CachePolicy { + self.cache_policy() + } + fn resource_request_policy(&self) -> RequestPolicy { + self.request_policy() + } + fn set_resource_cache_policy(&mut self, policy: CachePolicy) { + self.set_cache_policy(policy); + } + fn set_resource_request_policy(&mut self, policy: RequestPolicy) { + self.set_request_policy(policy); + } + fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64> { + self.cache_age_ms(now_ms) + } + fn resource_cache_hits(&self) -> u64 { + self.cache_hits() + } + fn resource_retry_count(&self) -> u32 { + self.retry_count() + } +} + +/// Key-partitioned storage for one resource type, shared by the query and +/// infinite-query buckets. +pub(crate) struct ResourceBucket<R> { + pub(crate) entries: AHashMap<QueryKey, BucketEntry<R>>, + pub(crate) max_entries: usize, +} + +impl<R: BucketResource + 'static> ResourceBucket<R> { + pub(crate) fn new() -> Self { + Self { + entries: AHashMap::new(), + max_entries: DEFAULT_MAX_ENTRIES, + } + } + + /// Get an existing entity or create a new one. + /// + /// Live entries get their policies refreshed in place when they differ. + /// A dead weak reference is overwritten in place (length unchanged, no + /// eviction); a vacant insert at capacity evicts the oldest entry first. + pub(crate) fn get_or_create( + &mut self, + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + cx: &mut App, + ) -> Entity<R> { + if let Some(entry) = self.entries.get_mut(&key) { + if let Some(entity) = entry.entity.upgrade() { + let (needs_update, last_updated, loading) = + entity.read_with(cx, |resource, _| { + let needs_update = resource.resource_cache_policy() != cache_policy + || resource.resource_request_policy() != request_policy; + ( + needs_update, + resource.resource_last_updated(), + resource.resource_is_loading(), + ) + }); + entry.last_updated_ms = last_updated; + entry.loading = loading; + if needs_update { + entity.update(cx, |resource, _| { + resource.set_resource_cache_policy(cache_policy); + resource.set_resource_request_policy(request_policy); + }); + } + return entity; + } + } else if self.entries.len() >= self.max_entries { + self.evict_oldest(cx); + } + + let entity = cx.new(|_| R::new_resource(key.clone(), cache_policy, request_policy)); + self.entries.insert( + key, + BucketEntry { + entity: entity.downgrade(), + sequencer: crate::core::RequestSequencer::new(), + last_updated_ms: None, + loading: false, + }, + ); + entity + } + + /// Evict the least-recently-updated entry to make room for a new one. + /// + /// The scan reads only the mirrors plus weak-ref liveness, then confirms + /// `!is_loading()` on the winner with one entity read (the mirror can be + /// stale if a fetch began after the last refresh). Each retry marks the + /// stale mirror and re-picks, so the candidate set strictly shrinks. + pub(crate) fn evict_oldest(&mut self, cx: &App) { + loop { + let target = self + .entries + .iter() + .filter_map(|(key, entry)| { + if entry.loading { + return None; + } + entry.entity.upgrade()?; + Some((key, entry.last_updated_ms.unwrap_or(0))) + }) + .min_by_key(|&(_, age)| age); + + let Some((key, _)) = target else { + return; // every live entry is loading: nothing safe to evict + }; + + let key = key.clone(); + let still_loading = self + .entries + .get(&key) + .and_then(|e| e.entity.upgrade()) + .map(|entity| entity.read(cx).resource_is_loading()); + + match still_loading { + Some(true) => { + if let Some(entry) = self.entries.get_mut(&key) { + entry.loading = true; + } + } + _ => { + self.entries.remove(&key); + return; + } + } + } + } + + pub(crate) fn get(&self, key: &QueryKey) -> Option<Entity<R>> { + self.entries.get(key).and_then(|e| e.entity.upgrade()) + } + + pub(crate) fn sequencer_mut(&mut self, key: &QueryKey) -> Option<&mut crate::core::RequestSequencer> { + self.entries.get_mut(key).map(|e| &mut e.sequencer) + } + + pub(crate) fn all_entities(&self) -> Vec<Entity<R>> { + self.entries + .values() + .filter_map(|e| e.entity.upgrade()) + .collect() + } + + /// Collect matching entities up front (one pass), then run `action` on + /// each outside the map borrow. GPUI defers observer effects to the + /// outermost update, so no action can re-enter this bucket mid-loop. + pub(crate) fn for_each_matching_entry( + &mut self, + filter: &QueryKeyFilter, + cx: &mut App, + mut action: impl FnMut(&Entity<R>, &mut App), + ) { + let entities: Vec<Entity<R>> = self + .entries + .iter() + .filter(|(key, _)| filter.matches(key)) + .filter_map(|(_, entry)| entry.entity.upgrade()) + .collect(); + + for entity in &entities { + action(entity, cx); + } + } + + /// Evict dead weak references plus terminal entries past their age + /// window. Loading resources always survive; `Success` survives while its + /// cache policy can still serve it and until + /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`; `Idle`/`Failure`/`Cancelled` + /// survive `gc_time_ms`. Entries without a completion timestamp count as + /// fully aged. + pub(crate) fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { + let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); + let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); + + self.entries.retain(|_key, entry| { + let Some(entity) = entry.entity.upgrade() else { + return false; + }; + let resource = entity.read(cx); + + // GC walks every entry, so it is the canonical mirror refresh point. + entry.last_updated_ms = resource.resource_last_updated(); + entry.loading = resource.resource_is_loading(); + + if resource.resource_is_loading() { + return true; + } + + let status = resource.resource_status(); + + let age_ms = resource + .resource_last_updated() + .map(|updated| now_ms.saturating_sub(updated)) + .unwrap_or(gc_threshold); + + if status == QueryStatus::Success { + let cache_policy = resource.resource_cache_policy(); + if cache_policy.can_serve_stale() && !cache_policy.is_expired(age_ms) { + return true; + } + return age_ms < success_threshold; + } + + if !matches!( + status, + QueryStatus::Idle | QueryStatus::Failure | QueryStatus::Cancelled + ) { + return true; + } + + age_ms < gc_threshold + }); + } + + pub(crate) fn collect_diagnostics_into( + &self, + now_ms: u64, + cx: &App, + out: &mut Vec<QueryDiagnostic>, + ) { + for (key, entry) in self.entries.iter() { + let Some(entity) = entry.entity.upgrade() else { + continue; + }; + let resource = entity.read(cx); + out.push(QueryDiagnostic { + key: key.to_path(), + status: resource.resource_status(), + cache_policy: resource.resource_cache_policy().label(), + cache_age_ms: resource.resource_cache_age_ms(now_ms), + cache_hits: resource.resource_cache_hits(), + retry_count: resource.resource_retry_count(), + }); + } + } + + /// Key/status pairs without the per-entry allocations of full + /// diagnostics; used by `dehydrate`. + #[cfg(feature = "persist")] + pub(crate) fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(String, QueryStatus)>) { + for (key, entry) in self.entries.iter() { + let Some(entity) = entry.entity.upgrade() else { + continue; + }; + let resource = entity.read(cx); + out.push((key.to_path(), resource.resource_status())); + } + } +} diff --git a/crates/gpui-query/src/client/bucket/types.rs b/crates/gpui-query/src/client/bucket/types.rs index c677052..3ddc8fc 100644 --- a/crates/gpui-query/src/client/bucket/types.rs +++ b/crates/gpui-query/src/client/bucket/types.rs @@ -1,61 +1,31 @@ -//! Core types and constants for the query bucket. +//! Core types and constants for the resource buckets. use gpui::WeakEntity; -use crate::core::{QueryResource, RequestSequencer}; +use crate::core::RequestSequencer; -/// Minimum GC time in milliseconds. -/// -/// A `gc_time_ms` of 0 would cause every `Idle` and `Failure` resource to be -/// evicted on every GC pass (since `age_ms >= 0` is always true for unsigned -/// values). This effectively disables caching for non-active resources. -/// Enforcing a 1-second minimum prevents this footgun. +/// Floor for `gc_time_ms`. A value of 0 (or anything below this) would evict +/// every `Idle`/`Failure` resource on every GC pass, since `age >= 0` always +/// holds for unsigned ages. pub(crate) const MIN_GC_TIME_MS: u64 = 1_000; -/// Default maximum number of entries per bucket. -/// -/// Prevents unbounded memory growth from malicious or buggy components that -/// register unlimited unique query keys. When the limit is reached, the -/// oldest (least-recently-updated) entry is evicted to make room. +/// Entries per bucket before the oldest one is evicted. Bounds memory when a +/// component registers unbounded unique keys. pub(crate) const DEFAULT_MAX_ENTRIES: usize = 10_000; -/// Multiplier applied to `gc_time_ms` to determine the maximum age for -/// `Success` resources before they become eligible for eviction. -/// -/// A `Success` resource whose data age exceeds -/// `SUCCESS_GC_MULTIPLIER * gc_time_ms` will be evicted even though it -/// holds valuable data. This prevents memory leaks from queries that -/// succeeded once but were never observed again. +/// `Success` resources survive this many times `gc_time_ms` before GC may +/// evict them, so valuable data outlives transient failures. pub(crate) const SUCCESS_GC_MULTIPLIER: u32 = 2; -/// Entry co-locating weak entity reference and sequencer. -/// -/// Uses `WeakEntity` instead of `Entity` so that the bucket does not prevent -/// GPUI from garbage-collecting unused query resources. The weak reference is -/// upgraded on access; if the entity was already collected, the entry is -/// treated as missing and re-created on next `get_or_create`. -/// -/// GC reads entity state directly via `entity.read(cx)` (CL2/#106 fix) rather -/// than trusting a cached snapshot, so no status snapshot is stored here. -/// -/// # M2 eviction mirror +/// Weak entity handle co-located with the key's request sequencer. /// -/// `last_updated_ms` and `loading` are a *mirror* of the entity's live -/// `last_updated_at_ms()` / `is_loading()` state, refreshed wherever the -/// bucket already reads the entity (zero extra reads). `evict_oldest` scans -/// these cheap fields + `WeakEntity::upgrade` liveness — with **one** -/// `entity.read` on the winning entry to confirm `!is_loading()` (guards audit -/// #109 against a stale mirror where a fetch began after the last refresh) and -/// read the authoritative timestamp — turning the previous O(n) entity reads -/// into O(1) typical. -pub(crate) struct BucketEntry<T, E> { - pub entity: WeakEntity<QueryResource<T, E>>, +/// `last_updated_ms` / `loading` mirror the entity's +/// `last_updated_at_ms()` / `is_loading()` and are refreshed whenever the +/// bucket already reads the entity, so `evict_oldest` can scan cheap fields +/// and do a single confirming entity read on its winner. +pub(crate) struct BucketEntry<R> { + pub entity: WeakEntity<R>, pub sequencer: RequestSequencer, - /// Mirror of `QueryResource::last_updated_at_ms()`. `None` until first - /// refresh after a terminal completion (or for a freshly-created Idle - /// resource that has never completed). pub(crate) last_updated_ms: Option<u64>, - /// Mirror of `QueryResource::is_loading()`. `false` until a fetch is - /// observed to start; refreshed on every bucket read of the entity. pub(crate) loading: bool, } diff --git a/crates/gpui-query/src/client/devtools.rs b/crates/gpui-query/src/client/devtools.rs index e826cc6..339d5a2 100644 --- a/crates/gpui-query/src/client/devtools.rs +++ b/crates/gpui-query/src/client/devtools.rs @@ -1,11 +1,4 @@ //! Diagnostic types for query and mutation DevTools. -//! -//! **v2 improvements**: -//! - `QueryDiagnostic.key` uses `to_path()` for full key display (not just first segment) -//! - `MutationDiagnostic` added for mutation devtools support -//! -//! **Audit 3 additions**: -//! - `DehydratedState` and `DehydratedEntry` for state serialization #[cfg(feature = "persist")] use std::any::TypeId; @@ -30,8 +23,6 @@ pub struct QueryDiagnostic { } /// Diagnostic information about a single mutation resource. -/// -/// **v2 new**: v1 had no mutation diagnostics. #[derive(Clone, Debug)] pub struct MutationDiagnostic { /// Optional key associated with this mutation. @@ -55,48 +46,34 @@ pub struct ClientDiagnostic { pub mutations: Vec<MutationDiagnostic>, } -// ── Dehydration / Hydration types (Audit 3, Finding 8) ──────────────── -// -// Gated behind `persist`: these types back the legacy `dehydrate`/`hydrate`/ -// `persist`/`restore` methods and the `QueryPersister` trait, all of which are -// `persist`-only. The metadata-only diagnostic types above stay ungated. +// Dehydration types, gated behind `persist` alongside the +// dehydrate/hydrate/persist/restore methods and the `QueryPersister` trait. -/// A single entry in a dehydrated query cache snapshot. -/// -/// Each entry represents one cached query resource, identified by its key -/// and the `TypeId` of its `(T, E)` type pair. -/// -/// The `kind` field distinguishes regular queries, infinite queries, and -/// mutations, allowing consumers to deserialize appropriately. -/// -/// **Audit fix #L14**: the `data_json: Option<String>` field was removed — -/// `dehydrate()` always populated it with `None`, so it was 24 bytes/entry of -/// dead weight. Typed data serialization (when it lands) will be added as a -/// real field, not a permanently-`None` placeholder. +/// A single entry in a dehydrated query cache snapshot, identified by its +/// key and the `TypeId` of its `(T, E)` type pair. `kind` distinguishes +/// queries, infinite queries, and mutations so consumers can deserialize +/// appropriately. #[cfg(feature = "persist")] #[derive(Clone, Debug)] pub struct DehydratedEntry { /// Full key path (e.g., "users::42::posts"). pub key: String, - /// `TypeId` of the `(T, E)` (query) or `(T, E)` (infinite query) type pair. - /// Used to match entries to concrete types during hydration. + /// `TypeId` of the resource's `(T, E)` type pair; used to match entries + /// to concrete types during hydration. pub type_id: TypeId, - /// Whether this entry is a regular query, an infinite query, or a mutation. + /// Whether this entry is a query, an infinite query, or a mutation. pub kind: &'static str, } -/// A portable snapshot of all cached query state. -/// -/// Produced by [`QueryClient::dehydrate`](super::QueryClient::dehydrate) and -/// consumed by [`QueryClient::hydrate`](super::QueryClient::hydrate). Can be -/// persisted to disk or sent over a network for state restoration. -/// -/// # Type erasure +/// A portable snapshot of all cached query state, produced by +/// [`QueryClient::dehydrate`](super::QueryClient::dehydrate) and consumed by +/// [`QueryClient::hydrate`](super::QueryClient::hydrate). Persist it to disk +/// or send it over a network for state restoration. /// /// Because `QueryClient` uses type-erased buckets, `DehydratedState` stores -/// `TypeId` values but cannot perform typed deserialization internally. -/// Callers that know the concrete `T` and `E` types should iterate `entries` -/// and use `QueryClient::set_query_data` for each matching entry. +/// `TypeId` values but cannot deserialize typed data itself: callers that +/// know the concrete types should iterate `entries` and use +/// `QueryClient::set_query_data` for each matching entry. /// /// # Example /// diff --git a/crates/gpui-query/src/client/erased.rs b/crates/gpui-query/src/client/erased.rs index 29c0fef..649bd75 100644 --- a/crates/gpui-query/src/client/erased.rs +++ b/crates/gpui-query/src/client/erased.rs @@ -1,16 +1,9 @@ //! Type-erased bucket traits and persistence adapter. //! -//! These traits allow `QueryClient` to store heterogeneous query and mutation -//! buckets in a single `AHashMap<TypeId, Box<dyn Erased*>>` map, dispatching -//! to concrete types only when the caller provides generic parameters. -//! -//! # Feature gating -//! -//! The persistence-only surface (`collect_key_status_into`, the -//! value-carrying `collect_persistable_into`, and the legacy synchronous -//! [`QueryPersister`] trait) is gated behind the `persist` feature. The -//! non-persistence methods (`gc`, `invalidate_matching`, `diagnostics`, …) -//! remain ungated so the default build is unchanged. +//! These traits let `QueryClient` store heterogeneous buckets in +//! `AHashMap<TypeId, Box<dyn Erased*>>` maps, dispatching to concrete types +//! only when the caller provides generic parameters. The persistence-only +//! surface is gated behind the `persist` feature. use crate::client::devtools::{MutationDiagnostic, QueryDiagnostic}; #[cfg(feature = "persist")] @@ -19,10 +12,6 @@ use crate::core::QueryKeyFilter; #[cfg(feature = "persist")] use crate::core::{MutationStatus, QueryStatus}; -// `current_time_ms` moved to `client/time.rs` (ungated) so the `persist` -// feature gate on this module's persistence symbols does not drag the GC -// clock helper behind a `cfg`. See [`crate::client::time::current_time_ms`]. - /// Type-erased bucket trait for storage in a homogeneous map. pub(crate) trait ErasedBucket { fn as_any(&self) -> &dyn std::any::Any; @@ -33,24 +22,17 @@ pub(crate) trait ErasedBucket { fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); fn remove_matching(&mut self, filter: &QueryKeyFilter); fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - /// Push each live entry's diagnostic into the caller-supplied `out` Vec - /// instead of allocating a fresh `Vec` per bucket. Callers - /// (`QueryClient::diagnostics`) can pre-size a single destination Vec and - /// let every bucket push into it, avoiding the per-bucket allocation + - /// `extend` a returning variant would force. + /// Push each live entry's diagnostic into the caller-supplied Vec so + /// `QueryClient::diagnostics` can pre-size one destination instead of + /// allocating per bucket. fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); - /// Push each live entry's `(key, status)` pair into `out` without building - /// full `QueryDiagnostic`s (#9). Used by `dehydrate`, which only needs the - /// key and status, avoiding the per-entry allocations of - /// [`collect_diagnostics_into`](ErasedBucket::collect_diagnostics_into). + /// Key/status pairs without the per-entry allocations of full + /// diagnostics; used by `dehydrate`. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); /// Push each `Success` entry's `(key, entry)` pair into `out`, serializing - /// the typed data via the caller-supplied [`SerializerRegistry`]. Entries - /// whose `T` has no registered serializer are skipped (metadata-only - /// fallback, matching the legacy `dehydrate` behavior). Used by - /// [`persist_with`](crate::client::QueryClient::persist_with) to build a - /// value-carrying [`PersistSnapshot`](crate::client::PersistSnapshot). + /// via the caller-supplied registry. Entries whose `T` has no registered + /// serializer are skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -59,6 +41,10 @@ pub(crate) trait ErasedBucket { now_ms: u64, out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, ); + /// Whether the bucket currently holds `key`. Used to prune the + /// persistence metadata map of keys whose entries were evicted. + #[cfg(feature = "persist")] + fn contains_key(&self, key: &crate::core::QueryKey) -> bool; } /// Type-erased infinite query bucket trait. @@ -74,12 +60,11 @@ pub(crate) trait ErasedInfiniteBucket { /// Push each live entry's diagnostic into `out`. See /// [`ErasedBucket::collect_diagnostics_into`]. fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); - /// Push each live entry's `(key, status)` pair into `out` without building - /// full `QueryDiagnostic`s (#9). See + /// Key/status pairs without full diagnostics; used by `dehydrate`. See /// [`ErasedBucket::collect_key_status_into`]. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - /// Value-carrying variant for persistence. See + /// Value-carrying persistence variant. See /// [`ErasedBucket::collect_persistable_into`]. #[cfg(feature = "persist")] fn collect_persistable_into( @@ -89,6 +74,10 @@ pub(crate) trait ErasedInfiniteBucket { now_ms: u64, out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, ); + /// Whether the bucket currently holds `key`. See + /// [`ErasedBucket::contains_key`]. + #[cfg(feature = "persist")] + fn contains_key(&self, key: &crate::core::QueryKey) -> bool; } /// Type-erased mutation bucket trait. @@ -97,33 +86,22 @@ pub(crate) trait ErasedMutationBucket { fn as_any_mut(&mut self) -> &mut dyn std::any::Any; fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &gpui::App); fn count(&self) -> usize; - /// Push each live entry's `MutationDiagnostic` into `out` instead of - /// allocating a fresh `Vec` per bucket. See + /// Push each live entry's `MutationDiagnostic` into `out`. See /// [`ErasedBucket::collect_diagnostics_into`] for the rationale. fn collect_diagnostics_into(&self, cx: &gpui::App, out: &mut Vec<MutationDiagnostic>); - /// Push each live entry's `(key, status)` pair into `out` without building - /// full `MutationDiagnostic`s (#9). `key` is `None` for keyless mutations, - /// mirroring [`MutationDiagnostic::key`]. Used by `dehydrate`. + /// Key/status pairs without full diagnostics (`key` is `None` for keyless + /// mutations); used by `dehydrate`. #[cfg(feature = "persist")] - fn collect_key_status_into( - &self, - cx: &gpui::App, - out: &mut Vec<(Option<String>, MutationStatus)>, - ); + fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(Option<String>, MutationStatus)>); } -/// Legacy synchronous persistence adapter trait for query cache persistence -/// across app restarts. -/// -/// **Note**: this is the shipped metadata-only skeleton. The richer async, -/// value-carrying surface lives in [`crate::client::persist`] (the -/// [`Persister`](crate::client::persist::Persister) trait + -/// [`persist_with`](crate::client::QueryClient::persist_with)). This trait is -/// retained for the existing `dehydrate`/`hydrate`/`persist`/`restore` methods -/// and is feature-gated behind `persist`. +/// Legacy synchronous persistence adapter trait for the metadata-only +/// `dehydrate`/`hydrate`/`persist`/`restore` methods. The richer async +/// value-carrying surface is [`Persister`](crate::client::Persister) plus +/// [`persist_with`](crate::client::QueryClient::persist_with). /// -/// Implementations can store cached data in any backend (filesystem, database, etc.). -/// Entries are serialized as JSON strings to avoid generic bounds on the persister. +/// Entries are serialized as JSON strings to avoid generic bounds on the +/// persister; implementations can target any backend. /// /// # Example /// diff --git a/crates/gpui-query/src/client/infinite_bucket.rs b/crates/gpui-query/src/client/infinite_bucket.rs index 923c178..6a4029e 100644 --- a/crates/gpui-query/src/client/infinite_bucket.rs +++ b/crates/gpui-query/src/client/infinite_bucket.rs @@ -1,281 +1,56 @@ //! Type-partitioned bucket for infinite query resources. //! -//! Mirrors [`QueryBucket`] but for [`InfiniteQueryResource`]. Co-locates a -//! [`RequestSequencer`] with each entity so that request IDs are monotonic -//! across the lifetime of the resource (audit fix: persistent sequencer). -//! -//! **Audit fixes (this pass)**: -//! - GC reads entity state directly via `entity.read(cx)` (CL2/#106) instead -//! of a stale `StatusSnapshot`. -//! - `max_entries` cap + `evict_oldest` added (#1) — previously successful -//! infinite resources were never evicted, causing unbounded memory growth. -//! - `Cancelled` added to the evictable set (#107). -//! - `retain`/`release`/`observer_count`/`update_status_snapshot` removed -//! (#8, #75) — `observer_count` was never incremented from production. -//! - `invalidate_matching` collects `QueryKey`s instead of pinning strong -//! `Entity` handles (#18). -//! - Opportunistic GC trigger added (CL1/#105). +//! Shares its machinery with [`QueryBucket`] through +//! [`ResourceBucket`](super::bucket::shared::ResourceBucket); only the erased +//! trait impl and the first-page persistence path are specific to infinite +//! queries. -use ahash::AHashMap; -use gpui::{App, AppContext as _, WeakEntity}; +use gpui::{App, Entity}; -use super::bucket::types::{DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; use crate::core::{ - CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, QueryStatus, RequestPolicy, + CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, RequestPolicy, RequestSequencer, }; -/// Entry co-locating weak entity reference and sequencer. -/// -/// `last_updated_ms` / `loading` form the M2 eviction mirror — see -/// [`BucketEntry`](super::bucket::types::BucketEntry) for the design. -struct InfiniteBucketEntry<T, E> { - entity: WeakEntity<InfiniteQueryResource<T, E>>, - sequencer: RequestSequencer, - last_updated_ms: Option<u64>, - loading: bool, -} +use super::bucket::shared::ResourceBucket; +use super::devtools::QueryDiagnostic; +use super::ErasedInfiniteBucket; /// Type-partitioned storage for infinite query resources of a specific `(T, E)` type pair. pub struct InfiniteQueryBucket<T, E> { - entries: AHashMap<QueryKey, InfiniteBucketEntry<T, E>>, - /// Maximum number of entries allowed in this bucket (#1 fix). - max_entries: usize, + entries: ResourceBucket<InfiniteQueryResource<T, E>>, } impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> InfiniteQueryBucket<T, E> { pub(crate) fn new() -> Self { Self { - entries: AHashMap::new(), - max_entries: DEFAULT_MAX_ENTRIES, + entries: ResourceBucket::new(), } } - /// Get an existing entity or create a new one. - /// - /// If the key already exists and the weak reference can be upgraded, returns - /// the existing entity. If the entity was collected, the stale entry is replaced. - /// - /// When the key already exists and the policies differ, updates in-place. pub(crate) fn get_or_create( &mut self, key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy, cx: &mut App, - ) -> gpui::Entity<InfiniteQueryResource<T, E>> { - // Audit fix #58: collapse the dead-ref path from get → remove → insert - // (3 hashes) to get → insert-overwrite (2 hashes). `entry()` cannot - // span the eviction (borrow conflict with `self.evict_oldest`), so the - // create path re-probes via `insert` — best-effort per the audit. - if let Some(entry) = self.entries.get_mut(&key) { - if let Some(entity) = entry.entity.upgrade() { - // M2: refresh the mirror from the same read we already do for - // the policy check (zero extra reads). - let (needs_update, last_updated, loading) = entity.read_with(cx, |resource, _| { - let needs_update = resource.cache_policy() != cache_policy - || resource.request_policy() != request_policy; - ( - needs_update, - resource.last_updated_at_ms(), - resource.is_loading(), - ) - }); - entry.last_updated_ms = last_updated; - entry.loading = loading; - if needs_update { - entity.update(cx, |resource, _| { - resource.set_cache_policy(cache_policy); - resource.set_request_policy(request_policy); - }); - } - return entity; - } - // Dead occupant: fall through to the insert path, which overwrites - // the stale entry in place. Length unchanged, no eviction. - } else if self.entries.len() >= self.max_entries { - self.evict_oldest(cx); - } - - let entity = - cx.new(|_| InfiniteQueryResource::new(key.clone(), cache_policy, request_policy)); - self.entries.insert( - key, - InfiniteBucketEntry { - entity: entity.downgrade(), - sequencer: RequestSequencer::new(), - last_updated_ms: None, - loading: false, - }, - ); - entity + ) -> Entity<InfiniteQueryResource<T, E>> { + self.entries.get_or_create(key, cache_policy, request_policy, cx) } - /// Evict the oldest (least-recently-updated) entry to make room (#1 fix). - /// - /// **M2 (O(n)→O(1) entity reads)**: scans entry MIRRORS (no `entity.read`) - /// plus `WeakEntity::upgrade` liveness, then one `entity.read` on the - /// winner to confirm `!is_loading()` (guards #109 against a stale mirror) - /// and read the authoritative timestamp. Mirrors - /// [`QueryBucket::evict_oldest`](super::QueryBucket::evict_oldest). - pub(crate) fn evict_oldest(&mut self, cx: &App) { - loop { - let target = self - .entries - .iter() - .filter_map(|(key, entry)| { - if entry.loading { - return None; - } - entry.entity.upgrade()?; - Some((key, entry.last_updated_ms.unwrap_or(0))) - }) - .min_by_key(|&(_, age)| age); - - let Some((key, _)) = target else { - return; - }; - - let key = key.clone(); - let still_loading = self - .entries - .get(&key) - .and_then(|e| e.entity.upgrade()) - .map(|entity| entity.read(cx).is_loading()); - - match still_loading { - Some(true) => { - // Stale mirror: a fetch began after the last refresh. Mark - // and re-pick so #109 is honored. - if let Some(entry) = self.entries.get_mut(&key) { - entry.loading = true; - } - continue; - } - _ => { - self.entries.remove(&key); - return; - } - } - } + pub(crate) fn get(&self, key: &QueryKey) -> Option<Entity<InfiniteQueryResource<T, E>>> { + self.entries.get(key) } - // (Audit cleanup: `maybe_gc` was dead code — never called from production. - // The real trigger is `QueryClient::maybe_opportunistic_gc`. Removed with - // `should_run_opportunistic_gc` and the now-write-only `last_gc_ms` field.) - - /// Get an existing entity by key. - pub(crate) fn get(&self, key: &QueryKey) -> Option<gpui::Entity<InfiniteQueryResource<T, E>>> { - self.entries.get(key).and_then(|e| e.entity.upgrade()) - } - - /// Get a mutable reference to the sequencer for an entry. pub(crate) fn sequencer_mut(&mut self, key: &QueryKey) -> Option<&mut RequestSequencer> { - self.entries.get_mut(key).map(|e| &mut e.sequencer) - } - - /// All entities in this bucket that are still alive. - /// - /// Allocates a `Vec` per call — callers on hot render paths should cache - /// the result (#60). - pub(crate) fn all_entities(&self) -> Vec<gpui::Entity<InfiniteQueryResource<T, E>>> { - self.entries - .values() - .filter_map(|e| e.entity.upgrade()) - .collect() - } - - /// Run garbage collection on this bucket. - /// - /// Reads entity state directly via `entity.read(cx)` (CL2/#106). Evicts - /// `Idle`/`Failure`/`Cancelled` entries older than `gc_time_ms` and - /// `Success` entries older than `SUCCESS_GC_MULTIPLIER * gc_time_ms` (#1). - pub(crate) fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { - let gc_time_ms = gc_time_ms.max(MIN_GC_TIME_MS); - let gc_threshold = gc_time_ms; - let success_threshold = gc_threshold * (SUCCESS_GC_MULTIPLIER as u64); - - self.entries.retain(|_key, entry| { - let Some(entity) = entry.entity.upgrade() else { - return false; - }; - let resource = entity.read(cx); - - // M2: refresh the eviction mirror from this read (gc walks every - // entry anyway, so this is the canonical refresh point). - entry.last_updated_ms = resource.last_updated_at_ms(); - entry.loading = resource.is_loading(); - - if resource.is_loading() { - return true; - } - - let status = resource.status(); - - let age_ms = resource - .last_updated_at_ms() - .map(|updated| now_ms.saturating_sub(updated)) - .unwrap_or(gc_threshold); - - if status == QueryStatus::Success { - let cache_policy = resource.cache_policy(); - if cache_policy.can_serve_stale() && !cache_policy.is_expired(age_ms) { - return true; - } - return age_ms < success_threshold; - } - - let evictable = matches!( - status, - QueryStatus::Idle | QueryStatus::Failure | QueryStatus::Cancelled - ); - if !evictable { - return true; - } - - age_ms < gc_threshold - }); + self.entries.sequencer_mut(key) } - /// **L9**: shared "collect matching keys -> upgrade weak ref -> per-entry - /// action" driver used by `invalidate_matching` / `reset_matching` / - /// `cancel_matching` (the erased-trait impl below). Mirrors - /// [`QueryBucket::for_each_matching_entry`](super::QueryBucket::for_each_matching_entry). - /// - /// `remove_matching` is *not* routed through here — it is just - /// `HashMap::retain`. The `action` closure receives the upgraded strong - /// entity and `cx` so each caller picks its own lock pattern - /// (unconditional `update` for invalidate/reset; `read_with`-then-`update` - /// only-if-loading for cancel — see M7). Byte-identical to the prior - /// inlined loops. - fn for_each_matching_entry( - &mut self, - filter: &QueryKeyFilter, - cx: &mut App, - mut action: impl FnMut(&gpui::Entity<InfiniteQueryResource<T, E>>, &mut App), - ) { - let keys: Vec<QueryKey> = self - .entries - .keys() - .filter(|key| filter.matches(key)) - .cloned() - .collect(); - - for key in keys { - if let Some(entry) = self.entries.get(&key) - && let Some(entity) = entry.entity.upgrade() - { - action(&entity, cx); - } - } + pub(crate) fn all_entities(&self) -> Vec<Entity<InfiniteQueryResource<T, E>>> { + self.entries.all_entities() } } -// Implement the erased trait so InfiniteQueryBucket can live in QueryClient's -// heterogeneous map. -use super::ErasedInfiniteBucket; -use super::devtools::QueryDiagnostic; - impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedInfiniteBucket for InfiniteQueryBucket<T, E> { @@ -288,54 +63,41 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI } fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { - InfiniteQueryBucket::gc(self, now_ms, gc_time_ms, cx); + self.entries.gc(now_ms, gc_time_ms, cx); } fn count(&self) -> usize { - self.entries.len() + self.entries.entries.len() } - /// Collect `QueryKey`s (cheap Arc increments) and upgrade individually - /// inside the loop (#18 fix) — avoids pinning strong `Entity` handles in - /// a `Vec` while iterating the map. fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { - entity.update(cx, |resource, _| resource.invalidate()); + self.entries.for_each_matching_entry(filter, cx, |entity, cx| { + // Skip the notify when last_updated_at is already None (invalidate + // only clears that one field). + let needs_invalidate = + entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); + if needs_invalidate { + entity.update(cx, |resource, _| resource.invalidate()); + } }); } fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { + self.entries.for_each_matching_entry(filter, cx, |entity, cx| { entity.update(cx, |resource, _| resource.reset()); }); } fn remove_matching(&mut self, filter: &QueryKeyFilter) { - self.entries.retain(|k, _| !filter.matches(k)); + self.entries.entries.retain(|k, _| !filter.matches(k)); } - /// Cancel in-flight requests for entries matching the filter. - /// - /// **L15**: uses `is_loading()` directly (matching - /// [`QueryBucket::cancel_matching`](super::bucket::erased_ops)) rather than - /// the equivalent `status().is_loading()` — one style across both buckets. - /// - /// **M5**: after `signal.cancel()`, calls `resource.mark_ignored_result()` - /// so the infinite resource bumps `ignored_results` exactly like the regular - /// query path (the core half of M5 added the accessor). - /// - /// **M7 (deliberate trade-off, documented)**: `read_with`-then-`update` - /// (two lock acquisitions) per match, matching `QueryBucket::cancel_matching`. - /// `entity.update` always notifies observers even on a no-op closure, so - /// updating every match would spam observers for non-loading entries. We - /// gate on the authoritative `entity.read_with` `is_loading()` (NOT the M2 - /// entry `loading` mirror, which could be stale and skip an in-flight - /// cancel) and only `update` when we will actually mutate. Accepted form - /// of the audit's refined fix. + /// Gate on the authoritative `is_loading()` read; see + /// `QueryBucket::cancel_matching`. Also bumps `ignored_results` so + /// cancelled infinite fetches match the regular query path. fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_matching_entry(filter, cx, |entity, cx| { - let is_loading = entity.read_with(cx, |r, _| r.is_loading()); - if is_loading { + self.entries.for_each_matching_entry(filter, cx, |entity, cx| { + if entity.read_with(cx, |r, _| r.is_loading()) { entity.update(cx, |resource, _| { if let Some(signal) = resource.signal() { signal.cancel(); @@ -346,45 +108,23 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI }); } - /// Push each live entry's diagnostic into `out` instead of allocating a - /// fresh `Vec`. fn collect_diagnostics_into(&self, now_ms: u64, cx: &App, out: &mut Vec<QueryDiagnostic>) { - for (key, entry) in self.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - // L6: use the accessor (checked_sub → None on clock skew) so - // the diagnostic matches QueryBucket's cache_age_ms behavior. - let age_ms = resource.cache_age_ms(now_ms); - out.push(QueryDiagnostic { - key: key.to_path(), - status: resource.status(), - cache_policy: resource.cache_policy().label(), - cache_age_ms: age_ms, - cache_hits: resource.cache_hits(), - retry_count: resource.retry_count(), - }); - } + self.entries.collect_diagnostics_into(now_ms, cx, out); } - /// Lightweight key/status pairs (#9). Pushes each live entry's `(key, - /// status)` pair into `out`. #[cfg(feature = "persist")] - fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(String, QueryStatus)>) { - for (key, entry) in self.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - out.push((key.to_path(), resource.status())); - } + fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(String, crate::core::QueryStatus)>) { + self.entries.collect_key_status_into(cx, out); } - /// Value-carrying variant for persistence. Infinite resources dehydrate - /// their first page only (the full page Vec is opaque to core; a richer - /// multi-page serialization can layer on top of `meta` later). Entries - /// without a registered serializer, or not in `Success`, are skipped. + #[cfg(feature = "persist")] + fn contains_key(&self, key: &crate::core::QueryKey) -> bool { + self.entries.entries.contains_key(key) + } + + /// Persists the first page only; the full page vector is opaque here. + /// Entries without a registered serializer, or not in `Success`, are + /// skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -396,13 +136,14 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI crate::client::persist::PersistedEntry, )>, ) { - // Serialization depends on the data type `T` (not the error type `E`); - // matches `SerializerRegistry::register::<T>` (via `register_serializer::<T, E>`). + use crate::core::QueryStatus; + + // Serializers are registered by `T` alone, not the `(T, E)` pair. let type_id = std::any::TypeId::of::<T>(); let Some(serialize_fn) = serializers.get(type_id) else { return; }; - for (key, entry) in self.entries.iter() { + for (key, entry) in self.entries.entries.iter() { let Some(entity) = entry.entity.upgrade() else { continue; }; @@ -413,9 +154,6 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI let Some(page) = resource.first_page_arc() else { continue; }; - // Serialize the single `Arc<T>` page as the opaque value. - // `None` only if the downcast failed (unreachable by construction; - // see `SerializerRegistry::register`) — skip in that case. let Some(value) = serialize_fn(&*page as &dyn std::any::Any) else { continue; }; diff --git a/crates/gpui-query/src/client/infinite_mutation_ops.rs b/crates/gpui-query/src/client/infinite_mutation_ops.rs index 3d04b21..ee7b286 100644 --- a/crates/gpui-query/src/client/infinite_mutation_ops.rs +++ b/crates/gpui-query/src/client/infinite_mutation_ops.rs @@ -1,9 +1,4 @@ //! Infinite query, mutation, and bulk operations on `QueryClient`. -//! -//! This module contains `impl QueryClient` methods for: -//! - Infinite query resource management and lookups -//! - Mutation registration and lookups -//! - Bulk operations (invalidate/reset/remove/cancel) across all bucket types use std::any::TypeId; @@ -35,8 +30,6 @@ impl QueryClient { } /// Get or create an infinite query resource with explicit policies. - /// - /// Audit 3 fix (findings 3, 4): Graceful downcast recovery. pub fn infinite_resource_with_policies< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -53,11 +46,8 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(InfiniteQueryBucket::<T, E>::new())); - // M4: single downcast via the shared helper (redundant TypeId - // pre-check dropped). let typed = Self::infinite_bucket_or_recreate::<T, E>(bucket); let entity = typed.get_or_create(key.into(), cache_policy, request_policy, cx); - // Audit fix CL1/#105: opportunistically run GC on this op. self.maybe_opportunistic_gc(cx); entity } @@ -75,13 +65,8 @@ impl QueryClient { } /// Use the infinite query bucket's co-located sequencer to generate a - /// `RequestId` for an infinite query key. - /// - /// Returns `None` if no bucket entry exists for the key. The sequencer is - /// advanced in-place so subsequent calls produce monotonically increasing IDs. - /// This is the infinite query equivalent of [`next_request_id_for_key`](Self::next_request_id_for_key). - /// - /// Audit 3 fix (findings 3, 4): Graceful downcast recovery. + /// `RequestId` for a key; the infinite-query counterpart of + /// [`next_request_id_for_key`](Self::next_request_id_for_key). pub fn next_request_id_for_infinite_key< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -91,8 +76,6 @@ impl QueryClient { ) -> Option<crate::core::RequestId> { let type_id = TypeId::of::<(T, E)>(); let bucket = self.infinite_buckets.get_mut(&type_id)?; - // M4: single downcast via the shared helper (redundant TypeId - // pre-check dropped). let typed = Self::infinite_bucket_or_recreate::<T, E>(bucket); typed.sequencer_mut(key).map(|seq| seq.next_request()) } @@ -115,8 +98,6 @@ impl QueryClient { // ── Mutation operations ───────────────────────────────────────────── /// Register a mutation entity. - /// - /// Audit 3 fix (findings 3, 4): Graceful downcast recovery. pub fn register_mutation< V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, @@ -132,16 +113,10 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(MutationBucket::<V, T, E>::new())); - // M6: cache now_ms once and thread it into `insert` (avoids a second - // `current_time_ms` syscall inside `insert`); the same value is reused - // by `maybe_opportunistic_gc` below. + // One clock read shared by insert and the opportunistic GC below. let now_ms = crate::client::time::current_time_ms(); - // M4: single downcast via the shared helper (redundant TypeId - // pre-check dropped). let typed = Self::mutation_bucket_or_recreate::<V, T, E>(bucket); typed.insert(entity, now_ms, cx); - // Audit fix CL1/#105: opportunistically run GC on this op so - // completed mutations are eventually evicted without manual gc() calls. self.maybe_opportunistic_gc(cx); } @@ -163,12 +138,6 @@ impl QueryClient { // ── Bulk operations ───────────────────────────────────────────────── - /// Apply an operation `f` to every query bucket (regular + infinite). - /// **L10**: extracted to kill the 4x duplicated - /// `for buckets … for infinite_buckets …` pair in the bulk-op methods - /// below. `f` is called once per regular bucket (as `Left`) and once per - /// infinite bucket (as `Right`); callers match on the side to invoke the - /// correct trait method. fn for_each_query_bucket_mut<F>(&mut self, mut f: F) where F: FnMut(EitherBucket<'_>), @@ -181,9 +150,7 @@ impl QueryClient { } } - /// Invalidate queries matching the filter. - /// - /// Uses collect-then-update pattern to avoid nested entity borrows. + /// Invalidate queries matching the filter (data is kept but marked stale). pub fn invalidate_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.invalidate_matching(filter, cx), @@ -191,7 +158,7 @@ impl QueryClient { }); } - /// Reset queries matching the filter. + /// Reset queries matching the filter (data and status cleared). pub fn reset_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.reset_matching(filter, cx), @@ -199,7 +166,7 @@ impl QueryClient { }); } - /// Remove queries matching the filter. + /// Remove queries matching the filter from the cache entirely. pub fn remove_queries(&mut self, filter: &QueryKeyFilter) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.remove_matching(filter), @@ -207,16 +174,12 @@ impl QueryClient { }); } - /// Cancel in-flight requests matching the filter (Audit 3, Finding 5). - /// - /// Iterates all query and infinite query buckets, finds resources with active - /// requests, and cancels them with a [`QueryError::cancelled`] error. This is - /// essential for cleanup when navigating away from a page or when bulk - /// cancellation is needed. + /// Cancel in-flight requests matching the filter, cancelling their + /// signals with a [`QueryError::cancelled`](crate::core::QueryError::cancelled) + /// error. Essential for cleanup when navigating away from a page. /// - /// Equivalent to TanStack Query's `queryClient.cancelQueries()`. Individual - /// `QueryResource::cancel()` exists but this is the bulk cancellation method - /// on the client. + /// The bulk counterpart of `QueryResource::cancel()`, equivalent to + /// TanStack Query's `queryClient.cancelQueries()`. pub fn cancel_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.cancel_matching(filter, cx), @@ -224,14 +187,11 @@ impl QueryClient { }); } - // ── Erased-bucket recovery helpers (M4) ────────────────────────────── - // - // Mirrors `QueryClient::bucket_or_recreate` in `mod.rs` for the infinite - // and mutation maps: downcast once (the redundant TypeId pre-check is - // dropped — `downcast_mut` checks it internally) and recreate the bucket - // in place on the (impossible) mismatch. Kills the 3x duplicated recovery - // blocks that lived in `infinite_resource_with_policies`, - // `next_request_id_for_infinite_key`, and `register_mutation`. + // ── Erased-bucket recovery helpers ────────────────────────────────── + + // Downcast counterparts of `bucket_or_recreate` for the infinite and + // mutation maps: recreate in place on the (unreachable) mismatch instead + // of panicking. fn infinite_bucket_or_recreate< T: Clone + Send + Sync + 'static, @@ -283,7 +243,7 @@ impl QueryClient { } } -/// One side of a query bucket iteration (L10). +/// One side of a query bucket iteration. enum EitherBucket<'a> { Query(&'a mut dyn crate::client::erased::ErasedBucket), Infinite(&'a mut dyn crate::client::erased::ErasedInfiniteBucket), diff --git a/crates/gpui-query/src/client/lifecycle.rs b/crates/gpui-query/src/client/lifecycle.rs index 7d7053e..2baba99 100644 --- a/crates/gpui-query/src/client/lifecycle.rs +++ b/crates/gpui-query/src/client/lifecycle.rs @@ -1,13 +1,5 @@ //! Lifecycle operations on `QueryClient`: GC, diagnostics, serialization, -//! persistence, and imperative fetch/prefetch. -//! -//! This module contains `impl QueryClient` methods for: -//! - Garbage collection (`gc`, `gc_with_time`) -//! - Test helpers for deterministic GC (snapshot updates, retain/release) -//! - Diagnostics -//! - Dehydration/hydration for state serialization -//! - Persistence via `QueryPersister` -//! - Imperative fetch and prefetch operations +//! legacy persistence, and imperative fetch/prefetch. use gpui::App; @@ -29,24 +21,19 @@ impl QueryClient { /// Run garbage collection on all buckets. /// - /// Calls `current_time_ms()` internally to get the current time. If you - /// already have a cached time value, use [`gc_with_time`] to avoid the - /// syscall overhead (Audit 3, Finding 2). + /// Calls `current_time_ms()` internally; if you already have a cached + /// time value, use [`gc_with_time`](Self::gc_with_time) to avoid the + /// syscall. pub fn gc(&mut self, cx: &App) { let now_ms = current_time_ms(); self.gc_with_time(now_ms, cx); } - /// Run garbage collection with a pre-computed time value (Audit 3, Finding 2). + /// Run garbage collection with a pre-computed time value (milliseconds + /// since the UNIX epoch), amortizing `SystemTime::now()` across calls. /// - /// Use this when you call GC frequently and want to amortize the cost of - /// `SystemTime::now()` across multiple calls. The `now_ms` parameter should - /// be milliseconds since the UNIX epoch (as returned by [`current_time_ms`]). - /// - /// **L5**: sets `self.last_gc_ms = now_ms` at the top so a *manual* GC call - /// debounces the next opportunistic GC sweep (otherwise the caller's - /// explicit `gc()` would not push back the `MIN_GC_TIME_MS` window and the - /// next op could immediately re-trigger GC). + /// Also stamps `last_gc_ms` so a manual GC debounces the next + /// opportunistic sweep. pub fn gc_with_time(&mut self, now_ms: u64, cx: &App) { self.last_gc_ms = now_ms; for bucket in self.buckets.values_mut() { @@ -58,37 +45,29 @@ impl QueryClient { for bucket in self.mutation_buckets.values_mut() { bucket.gc(now_ms, self.gc_time_ms, cx); } + // Metadata for keys whose entries were evicted can never be collected + // again; drop it so churning keys cannot grow the map without bound. + #[cfg(feature = "persist")] + if let Some(meta) = self.persisted_meta.as_mut() { + meta.retain(|key, _| { + self.buckets.values().any(|b| b.contains_key(key)) + || self.infinite_buckets.values().any(|b| b.contains_key(key)) + }); + } } - // ── Test helpers ─────────────────────────────────────────────────── - // - // The previous `update_*_snapshot` / `retain_*` / `release_*` helpers were - // removed: GC now reads live entity state directly via `entity.read(cx)` - // (audit #CL2/#106), so there is no cached `StatusSnapshot` to set; and - // `observer_count` was removed (audit #8) in favor of `WeakEntity::upgrade()` - // liveness, so there is no retain/release to drive. Tests that need a - // specific GC state now simply transition the entity itself (e.g. - // `apply_success`, `begin_fetch_next`) — GC observes that real state. - - // ── Diagnostics (Audit 3, Finding 7) ──────────────────────────────── + // ── Diagnostics ───────────────────────────────────────────────────── /// Get diagnostics for all queries and mutations. /// - /// Returns aggregate counts and per-resource diagnostic details. The - /// `queries` and `mutations` vectors are populated by iterating all bucket - /// entries, upgrading weak references, and reading entity state. Dead - /// entries (collected entities) are skipped. - /// - /// **Audit 3 fix**: Previously returned empty `queries: Vec::new()` and - /// `mutations: Vec::new()` vectors. Now fully populates per-resource - /// diagnostics via `collect_diagnostics` on each erased bucket. + /// Returns aggregate counts plus per-resource details, collected by + /// iterating bucket entries, upgrading weak references, and reading + /// entity state. Dead entries (collected entities) are skipped, so the + /// counts are an upper bound on the returned vectors. pub fn diagnostics(&self, cx: &App) -> ClientDiagnostic { let now_ms = current_time_ms(); - // L1: pre-size the diagnostic Vecs from the bucket `count()` sums so the - // per-bucket `collect_diagnostics_into` pushes (L3) don't repeatedly - // reallocate the destination Vec as it grows. `count()` is - // `entries.len()` — exact for live entries, an upper bound for the - // diagnostics (dead entries are skipped), so this never under-allocates. + // Pre-size from the bucket counts (entries.len()) so the per-bucket + // pushes never reallocate. let mut query_count = 0; let mut mutation_count = 0; for bucket in self.buckets.values() { @@ -103,9 +82,6 @@ impl QueryClient { let mut queries = Vec::with_capacity(query_count); let mut mutations = Vec::with_capacity(mutation_count); - // L3: push each bucket's diagnostics straight into the single pre-sized - // Vec via the sink variant — avoids the per-bucket `Vec` allocation + - // `extend` that the returning `collect_diagnostics` variant forces. for bucket in self.buckets.values() { bucket.collect_diagnostics_into(now_ms, cx, &mut queries); } @@ -124,28 +100,19 @@ impl QueryClient { } } - // ── Serialization / Hydration (Audit 3, Finding 8) ────────────────── + // ── Serialization / hydration ─────────────────────────────────────── - /// Serialize all cached query state into a portable format. + /// Serialize cached query state into a portable format: keys, status, + /// and type information for every live `Success` resource (other + /// statuses are skipped). The resulting [`DehydratedState`] can be + /// persisted or restored via [`hydrate`](Self::hydrate). /// - /// Extracts all live query resources, recording their keys, status, and - /// type information. The resulting [`DehydratedState`] can be persisted - /// to disk or stored for later restoration via [`hydrate`]. - /// - /// Only resources with `Success` status are included. Resources in - /// `Idle`, `Loading`, `Failure`, or `Cancelled` states are skipped. - /// - /// **Note**: Full data serialization requires type-specific code at the - /// call site. Use `get_query_data::<T, E>(key, cx)` to extract typed - /// data and serialize it externally. The `DehydratedState` provides - /// the metadata (keys, type IDs) needed for typed restoration. + /// Full data serialization needs type-specific code at the call site: + /// use [`get_query_data`](Self::get_query_data) to extract typed data + /// and serialize it externally. `DehydratedState` carries the metadata + /// (keys, type IDs) needed for typed restoration. #[cfg(feature = "persist")] pub fn dehydrate(&self, cx: &App) -> DehydratedState { - // L2: pre-size the entries Vec from the bucket `count()` sums. Only - // `Success` entries are pushed, so this is an upper bound — never - // under-allocates, avoids reallocation churn as entries accumulate. - // (The three maps hold different erased trait objects, so they are - // summed separately rather than chained.) let cap = self.buckets.values().map(|b| b.count()).sum::<usize>() + self .infinite_buckets @@ -159,28 +126,6 @@ impl QueryClient { .sum::<usize>(); let mut entries = Vec::with_capacity(cap); - // Audit fix #L13: collapse all three loops (query / infinite / mutation) - // into a single helper. The previous shape used a `push_status_queries` - // closure that handled only the two query-shaped loops (both - // `Vec<(String, QueryStatus)>`) and left the mutation loop inlined - // separately — its items are `(Option<String>, MutationStatus)`, so it - // couldn't reuse the closure. `push_status` below is generic over the - // status type and the success sentinel, so all three kinds share one - // code path. The emitted `DehydratedState` JSON shape is byte-identical - // to the previous implementation (only entries whose real status equals - // the success sentinel are pushed). - // - // Audit fix #L14: the `data_json` field is gone (it was always `None`), - // so we no longer pass the dead initializer here. - // - // Audit fix #94 / #9: this still drives the lightweight `collect_key_status` - // (key + status only), skipping the per-entry allocations that - // `collect_diagnostics` builds (`cache_policy`, `cache_age_ms`, - // `cache_hits`, `retry_count`). - // - // Audit fix #113: mutations are included; keyless mutations are skipped - // (a keyless mutation can't be meaningfully addressed for typed - // restoration). fn push_status<S>( entries: &mut Vec<DehydratedEntry>, type_id: std::any::TypeId, @@ -199,11 +144,8 @@ impl QueryClient { } } - // L3: reuse two buffers across all buckets instead of allocating a fresh - // `Vec` per bucket (the returning `collect_key_status` variant). Each - // bucket appends into the shared buffer via the sink; the buffer is - // drained per bucket so it never grows unbounded and the keys move - // (no clone) into `entries`. + // Two scratch buffers reused across buckets; drained per bucket so + // they never grow and the keys move into `entries` without cloning. let mut q_pairs: Vec<(String, QueryStatus)> = Vec::new(); let mut m_pairs: Vec<(Option<String>, MutationStatus)> = Vec::new(); @@ -243,67 +185,44 @@ impl QueryClient { /// Restore query state from a previously dehydrated snapshot. /// - /// Full hydration requires type-specific deserialization. The `DehydratedState` - /// contains `type_id` keys but downcasting requires knowing the concrete types - /// at the call site. Callers should iterate `state.entries` and call - /// `set_query_data::<T, E>()` for each entry where they know the types. - /// - /// This method is provided as a hook point for typed hydration and to - /// document the intended API shape matching TanStack Query's + /// Full hydration requires type-specific deserialization: + /// `DehydratedState` stores `type_id` keys, but downcasting needs the + /// concrete types at the call site. Callers should iterate + /// `state.entries` and call `set_query_data::<T, E>()` for each entry + /// whose types they know. This hook point mirrors TanStack Query's /// `queryClient.hydrate()`. #[cfg(feature = "persist")] - pub fn hydrate(&mut self, _state: DehydratedState, _cx: &mut App) { - // Full hydration requires type-specific deserialization. The DehydratedState - // contains type_id keys but downcasting requires knowing the concrete types - // at the call site. Callers should iterate state.entries and call - // set_query_data::<T, E> for each entry where they know the types. - } + pub fn hydrate(&mut self, _state: DehydratedState, _cx: &mut App) {} - // ── Persistence (Audit 3, Finding 9) ──────────────────────────────── + // ── Persistence ───────────────────────────────────────────────────── - /// Persist all cached data using the provided persister. - /// - /// Dehydrates the current state and saves it via the persister. This can - /// be called periodically (e.g., during GC) or on app shutdown to ensure - /// cached data survives across app restarts. + /// Persist the dehydrated state via the provided persister. Can be + /// called periodically (e.g. during GC) or on app shutdown. #[cfg(feature = "persist")] pub fn persist(&self, persister: &dyn QueryPersister, cx: &App) { let state = self.dehydrate(cx); persister.save(state.entries); } - /// Restore cached data from a persister. - /// - /// Loads entries from the persister. Since type information is erased in - /// the persister, callers must iterate and restore typed data themselves - /// using `set_query_data`. This method loads the raw entries and returns - /// them for inspection and typed restoration. - /// - /// **L4**: this is an *associated* function rather than a method — it does - /// not read any `&self` state, so callers invoke it as - /// `QueryClient::restore(&persister)` instead of `client.restore(...)`, - /// avoiding the need for a borrow on the client. + /// Load entries from a persister. Type information is erased in the + /// persister, so callers iterate and restore typed data themselves via + /// `set_query_data`. An associated function: it reads no client state, + /// so it needs no borrow on the client. #[cfg(feature = "persist")] pub fn restore(persister: &dyn QueryPersister) -> Vec<DehydratedEntry> { persister.load() } - // ── Imperative fetch (Audit 3, Finding 10) ────────────────────────── + // ── Imperative fetch ──────────────────────────────────────────────── - /// Prepare an imperative fetch for a query key, creating the resource if needed. + /// Prepare an imperative fetch for a query key, creating the resource if + /// needed, and begin a forced request. Returns a [`PreparedFetch`] with + /// the entity, request ID, and signal; the caller runs the fetcher and + /// completes the request via `complete_success` / `complete_failure`. /// - /// This creates (or reuses) the resource entity and begins a forced request, - /// returning a [`PreparedFetch`] containing the entity, request ID, and signal. - /// The caller is responsible for calling the fetcher and completing the request - /// using `complete_fetch` or by directly calling `complete_current_success` / - /// `complete_current_failure` on the entity. - /// - /// This is the equivalent of TanStack Query's `queryClient.fetchQuery()`. + /// The equivalent of TanStack Query's `queryClient.fetchQuery()`. /// Unlike `use_query`, this does not subscribe or create an observer. /// - /// Returns `None` if the cache is fresh (cache hit) and no fetch is needed. - /// In that case, use `get_query_data` to read the cached data. - /// /// # Example /// /// ```no_run @@ -336,18 +255,10 @@ impl QueryClient { let key = key.into(); let entity = self.resource::<T, E>(key.clone(), cx); let now_ms = current_time_ms(); - - // Get or create a request ID via the bucket's sequencer let request_id = self.next_request_id_for_key::<T, E>(&key); - // Begin the request on the resource purely for its side effect. - // **L7**: the previous code captured a `started` boolean from - // `begin_request_with_id`, matched it exhaustively, and then discarded - // it via `let _ = started;` — `prepare_fetch_query` (force mode) - // always returns a `PreparedFetch` regardless, so the value was - // useless. We now call `begin_request_with_id` for its side effect - // only, dropping the dead match + binding. - entity.update(cx, |resource, _| { + // Begin the request and pull the signal from the same update. + let (request_id, signal) = entity.update(cx, |resource, _| { if let Some(rid) = request_id { let _ = resource.begin_request_with_id( Some(rid), @@ -355,10 +266,6 @@ impl QueryClient { crate::core::QueryFetchMode::Force, ); } - }); - - // Re-read to get the signal and request ID - let (request_id, signal) = entity.read_with(cx, |resource, _| { let rid = resource.active_request_id()?; let signal = resource.signal().cloned()?; Some((rid, signal)) @@ -372,23 +279,17 @@ impl QueryClient { }) } - // ── Prefetch (Audit 3, Finding 11) ────────────────────────────────── + // ── Prefetch ──────────────────────────────────────────────────────── - /// Prepare a prefetch for a key that will be needed soon. - /// - /// Creates the resource entity (or reuses an existing one) and begins a - /// request if the cache is stale or empty. The resource is NOT subscribed - /// -- no observer is attached. When a component later calls `use_query` - /// with the same key, it will find the prefetched data in the cache. - /// - /// This is the equivalent of TanStack Query's `queryClient.prefetchQuery()`. - /// - /// If the resource already has fresh data (cache hit), returns `None`. - /// Use `prepare_fetch_query` with forced mode to override this behavior. + /// Prepare a prefetch for a key that will be needed soon: creates (or + /// reuses) the resource and begins a request if the cache is stale or + /// empty. No observer is attached; a later `use_query` with the same key + /// finds the prefetched data. /// - /// Returns a [`PreparedFetch`] containing the entity, request ID, and - /// signal. The caller is responsible for calling the fetcher and completing - /// the request. + /// The equivalent of TanStack Query's `queryClient.prefetchQuery()`. + /// Returns `None` on a fresh cache hit (use + /// [`get_query_data`](Self::get_query_data) to read it) or when the + /// request policy ignored the start. pub fn prepare_prefetch_query< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -403,34 +304,28 @@ impl QueryClient { let entity = self.resource_with_policies::<T, E>(key.clone(), cache_policy, request_policy, cx); let now_ms = current_time_ms(); - - // Get request ID from sequencer let request_id = self.next_request_id_for_key::<T, E>(&key); - // Begin the request (respects cache policy — will skip if fresh) - let started = entity.update(cx, |resource, _| { - if let Some(rid) = request_id { - let result = resource.begin_request_with_id( - Some(rid), - now_ms, - crate::core::QueryFetchMode::Normal, - ); - matches!( - result, - crate::core::QueryBeginResult::Started { .. } - | crate::core::QueryBeginResult::StaleCacheHit { .. } - ) - } else { - false + // Normal mode respects the cache policy; only Started and + // StaleCacheHit mean a fetch is actually wanted. + let (request_id, signal) = entity.update(cx, |resource, _| { + let started = match request_id { + Some(rid) => { + matches!( + resource.begin_request_with_id( + Some(rid), + now_ms, + crate::core::QueryFetchMode::Normal + ), + crate::core::QueryBeginResult::Started { .. } + | crate::core::QueryBeginResult::StaleCacheHit { .. } + ) + } + None => false, + }; + if !started { + return None; } - }); - - if !started { - return None; - } - - // Re-read to get the signal and request ID - let (request_id, signal) = entity.read_with(cx, |resource, _| { let rid = resource.active_request_id()?; let signal = resource.signal().cloned()?; Some((rid, signal)) diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index 1af652a..d7178da 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -1,22 +1,6 @@ -//! Layer 1: GPUI `QueryClient` — global registry for query resources. -//! -//! `QueryClient` is a GPUI [`Global`] that manages type-partitioned buckets -//! for queries, mutations, and observers. It provides bulk operations like -//! `invalidate_queries`, `cancel_queries`, and garbage collection. -//! -//! # Audit 3 fixes -//! -//! - `gc()` accepts optional `now_ms` parameter via `gc_with_time()` to avoid -//! redundant syscalls (finding 2) -//! - `expect()` on TypeId downcast replaced with graceful recovery + type name -//! in error message (findings 3, 4) -//! - `cancel_queries()` added for bulk in-flight request cancellation (finding 5) -//! - `get_query_data()` / `set_query_data()` for ergonomic cache access (finding 6) -//! - `diagnostics()` now populates per-resource diagnostic details (finding 7) -//! - `dehydrate()` / `hydrate()` for state serialization across restarts (finding 8) -//! - `QueryPersister` trait and `persist()` / `restore()` for pluggable persistence (finding 9) -//! - `fetch_query()` for imperative one-shot fetches (finding 10) -//! - `prefetch_query()` for background cache warming (finding 11) +//! GPUI `QueryClient`: a [`Global`] registry managing type-partitioned +//! buckets for queries, mutations, and observers, with bulk operations +//! (invalidation, cancellation, GC) on top. mod bucket; mod devtools; @@ -67,15 +51,9 @@ use crate::core::{CachePolicy, QueryKey, QueryResource, RequestPolicy}; /// Global registry for query and mutation resources. /// -/// Implements [`Global`] so it can be set once with `cx.set_global(QueryClient::default())` -/// and accessed from any component via `cx.global::<QueryClient>()`. -/// -/// # v2 Improvements -/// -/// - `Default` impl (no required params) -/// - `AHashMap` for ~2x faster lookups on trusted keys -/// - Actual mutation GC (not a no-op) -/// - Collect-then-update pattern to avoid nested entity borrows +/// Implements [`Global`] so it can be set once with +/// `cx.set_global(QueryClient::default())` and accessed from any component +/// via `cx.global::<QueryClient>()`. pub struct QueryClient { pub(crate) buckets: AHashMap<TypeId, Box<dyn ErasedBucket>>, pub(crate) infinite_buckets: AHashMap<TypeId, Box<dyn ErasedInfiniteBucket>>, @@ -84,45 +62,33 @@ pub struct QueryClient { pub(crate) default_request_policy: RequestPolicy, pub(crate) gc_time_ms: u64, /// Typed-serializer registry for the value-carrying persistence path - /// (`persist` feature). Populated by `register_serializer::<T, E>`. + /// (`persist` feature), populated by `register_serializer::<T, E>`. #[cfg(feature = "persist")] pub(crate) serializers: Option<crate::client::persist::SerializerRegistry>, - /// Typed-deserializer registry for [`hydrate`] (`persist` feature). - /// Populated by `register_deserializer::<T, E>`. + /// Typed-deserializer registry for [`hydrate`] (`persist` feature), + /// populated by `register_deserializer::<T, E>`. #[cfg(feature = "persist")] pub(crate) deserializers: Option<crate::client::persist::DeserializerRegistry>, - /// Per-key opaque metadata captured from `Fetched::meta` at fetch - /// completion (`persist` feature), surfaced into - /// [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) at - /// collect time so HTTP `CacheMeta` and similar can round-trip through a - /// cold start. Entries for evicted keys are simply ignored at collect time. + /// Opaque per-key metadata captured from `Fetched::meta` at fetch + /// completion, surfaced into `PersistedEntry::meta` at collect time so + /// HTTP `CacheMeta` and similar round-trip through a cold start. Pruned + /// of evicted keys by GC. #[cfg(feature = "persist")] pub(crate) persisted_meta: Option<std::collections::HashMap<crate::core::QueryKey, serde_json::Value>>, - /// Operation counter for opportunistic GC (audit CL1/#105). The GC - /// subsystem fires every `GC_INTERVAL` resource/mutation operations so it - /// actually runs in production without requiring hooks to call `gc()`. + /// Operation counter driving opportunistic GC every `GC_INTERVAL` ops. op_count: u64, - /// Wall-clock ms of the last opportunistic GC sweep. Combined with the op - /// counter, this debounces GC so a burst of insertions (or a fast test that - /// creates many resources within `MIN_GC_TIME_MS`) does not trigger GC. - /// - /// **L11**: initialized to `0` (rather than `current_time_ms()`) so - /// `QueryClient` construction does not perform a syscall. The - /// `MIN_GC_TIME_MS` debounce in `maybe_opportunistic_gc` still suppresses - /// GC for the first ~1s of life because the very first sweep sets this to - /// the real clock on its way through. + /// Wall-clock ms of the last GC sweep; GC runs at most once per + /// `MIN_GC_TIME_MS`. `0` means "not yet seeded" (avoids a syscall at + /// construction; the first reach seeds it and skips that sweep). last_gc_ms: u64, } impl Global for QueryClient {} impl Default for QueryClient { - /// **Audit fix #21**: Explicit `Default` impl that sets `gc_time_ms` to - /// `300_000` (5 minutes), matching `with_policies`. The previous derive - /// produced `gc_time_ms: 0`, which silently disabled GC — every - /// non-loading Idle/Failure resource would be evicted on every pass. - /// All other field defaults are identical to what the derive produced. + /// `gc_time_ms` defaults to 300_000 (5 minutes), matching + /// [`with_policies`](Self::with_policies). fn default() -> Self { Self { buckets: AHashMap::new(), @@ -166,17 +132,16 @@ impl QueryClient { /// /// Values below 1000ms are clamped to 1000ms during GC to prevent /// aggressive eviction of all Idle/Failure resources on every GC pass. + /// A value of 0 disables GC entirely. pub fn with_gc_time(mut self, gc_time_ms: u64) -> Self { self.gc_time_ms = gc_time_ms; self } /// Record opaque metadata (e.g. a serialized HTTP `CacheMeta`) for `key`, - /// captured from a fetcher's [`Fetched::meta`](crate::core::Fetched) at - /// completion. Surfaced into - /// [`PersistedEntry::meta`](crate::client::persist::PersistedEntry) at - /// collect time so the metadata round-trips through persistence. `persist` - /// feature only. + /// captured from a fetcher's `Fetched::meta` at completion and surfaced + /// into `PersistedEntry::meta` so it round-trips through persistence. + /// `persist` feature only. #[cfg(feature = "persist")] pub(crate) fn record_meta(&mut self, key: crate::core::QueryKey, meta: serde_json::Value) { self.persisted_meta @@ -184,23 +149,10 @@ impl QueryClient { .insert(key, meta); } - /// Opportunistic GC trigger (audit CL1/#105). Runs GC every `GC_INTERVAL` - /// operations so the GC subsystem actually fires in production without - /// requiring hooks to call `gc()` explicitly. Without this trigger the - /// (now correct, live-state-reading) GC never runs in production, which - /// would render the memory-bound fixes (#1, #2, #8, #91, #108) academic. - /// - /// Debounced by BOTH operation count (every `GC_INTERVAL` ops) and wall - /// clock time (no sweep within `MIN_GC_TIME_MS` of the last). `gc_time_ms` - /// of 0 disables GC entirely. - /// - /// **L11**: `last_gc_ms` starts at `0` (no `current_time_ms` syscall at - /// construction). To preserve the "no GC in the first ~1s of life" - /// debounce that the prior `current_time_ms()` initialization provided, - /// the sentinel `0` is treated as "uninitialized": the first time - /// `maybe_opportunistic_gc` reaches the time check, it seeds `last_gc_ms` - /// to `now_ms` and skips that sweep, so a fast test that creates many - /// resources in well under a second never triggers GC. + /// GC trigger for resource-creating ops: runs GC every `GC_INTERVAL` + /// operations, at most once per `MIN_GC_TIME_MS`, so the GC subsystem + /// fires in production without hooks calling `gc()` explicitly. + /// `gc_time_ms` of 0 disables GC entirely. fn maybe_opportunistic_gc(&mut self, cx: &App) { if self.gc_time_ms == 0 { return; @@ -210,8 +162,6 @@ impl QueryClient { return; } let now_ms = current_time_ms(); - // L11: seed the debounce window on first reach instead of syscalling - // at construction. if self.last_gc_ms == 0 { self.last_gc_ms = now_ms; return; @@ -241,10 +191,8 @@ impl QueryClient { /// Get or create a query resource with explicit policies. /// - /// Audit 3 fix (findings 3, 4): Uses graceful downcast recovery instead - /// of `expect()`. On type mismatch, logs the type name and creates a - /// fresh bucket, preventing application crashes from hypothetical - /// TypeId collisions. + /// A bucket downcast mismatch (impossible while `TypeId` keys are + /// sound) replaces the bucket instead of panicking. pub fn resource_with_policies< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -261,15 +209,8 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(QueryBucket::<T, E>::new())); - // M4: `bucket_or_recreate` downcasts once; `downcast_mut` already - // performs the TypeId check internally, so the prior redundant - // `bucket.type_id() != expected` pre-check is dropped (it was the - // double-check that audit fix #11 left in). On the (impossible) - // mismatch we log + swap in a fresh bucket + return it, all in one - // place — killing the 5x duplicated recovery block across the client. let typed = Self::bucket_or_recreate::<T, E>(bucket); let entity = typed.get_or_create(key.into(), cache_policy, request_policy, cx); - // Audit fix CL1/#105: opportunistically run GC on this op. self.maybe_opportunistic_gc(cx); entity } @@ -298,15 +239,10 @@ impl QueryClient { .and_then(|b| b.get(key)) } - /// Use the bucket's co-located sequencer to generate a `RequestId` for a key. - /// - /// Returns `None` if no bucket entry exists for the key. The sequencer is - /// advanced in-place (mutated) so subsequent calls produce monotonically - /// increasing IDs. This is the fix for audit findings #1/#5/#15/#18: - /// using the bucket's persistent sequencer instead of a transient one - /// prevents every request from getting the same `RequestId(1, 1)`. - /// - /// Audit 3 fix (findings 3, 4): Graceful downcast recovery. + /// Use the bucket's co-located sequencer to generate a `RequestId` for a + /// key. Returns `None` if no bucket entry exists for the key. The + /// sequencer is persistent, so IDs stay monotonic for the entry's + /// lifetime. pub fn next_request_id_for_key< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -316,23 +252,15 @@ impl QueryClient { ) -> Option<crate::core::RequestId> { let type_id = TypeId::of::<(T, E)>(); let bucket = self.buckets.get_mut(&type_id)?; - // M4: single downcast via the shared helper (redundant TypeId - // pre-check dropped). let typed = Self::bucket_or_recreate::<T, E>(bucket); typed.sequencer_mut(key).map(|seq| seq.next_request()) } - // ── Erased-bucket recovery helper (M4) ────────────────────────────── + // ── Erased-bucket recovery helper ─────────────────────────────────── - /// Downcast an erased query bucket to `&mut QueryBucket<T, E>`, recreating - /// it in place on the (impossible) type mismatch. - /// - /// **M4**: this replaces the 5x duplicated `TypeId` pre-check, `eprintln`, - /// fresh-bucket, and `downcast_mut` match block. `Any::downcast_mut` checks - /// `TypeId` internally, so the explicit pre-check was redundant; we now - /// downcast once and, only on the (impossible-after-construction) `None`, - /// log, swap in a fresh typed bucket, and downcast *that* (which always - /// succeeds). No production panic. + /// Downcast an erased bucket to `&mut QueryBucket<T, E>`. On a mismatch + /// (unreachable while `TypeId` keys are sound) the bucket is replaced + /// with a fresh typed one rather than panicking. fn bucket_or_recreate<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( bucket: &mut Box<dyn ErasedBucket>, ) -> &mut QueryBucket<T, E> { @@ -347,30 +275,23 @@ impl QueryClient { std::any::type_name::<(T, E)>() ); *bucket = Box::new(QueryBucket::<T, E>::new()); - debug_assert!( - bucket - .as_any_mut() - .downcast_mut::<QueryBucket<T, E>>() - .is_some(), - "QueryBucket downcast failed after fresh reconstruction" - ); } - // Unwrap is infallible here: either the original downcast succeeded, - // or we just replaced `*bucket` with a freshly-constructed typed one. + // Infallible: either the original downcast succeeded, or we just + // replaced the bucket with a freshly constructed typed one. bucket .as_any_mut() .downcast_mut::<QueryBucket<T, E>>() .expect("QueryBucket downcast succeeds after bucket_or_recreate") } - // ── Data accessors (Audit 3, Finding 6) ───────────────────────────── + // ── Data accessors ────────────────────────────────────────────────── - /// Read the cached data for a query key directly, without going through a hook. + /// Read the cached data for a query key directly, without going through + /// a hook. Returns `None` if no resource exists for the key, the entity + /// was collected, or the resource has not completed a fetch. /// - /// Returns `None` if no resource exists for the key, the entity was collected, - /// or the resource has no data (has not completed a fetch). - /// - /// This is the ergonomic equivalent of TanStack Query's `queryClient.getQueryData(key)`. + /// The ergonomic equivalent of TanStack Query's + /// `queryClient.getQueryData(key)`. pub fn get_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, key: &QueryKey, @@ -380,21 +301,12 @@ impl QueryClient { entity.read_with(cx, |resource, _| resource.data().cloned()) } - /// Read the cached data for a query key via a borrow callback, with NO - /// clone of `T` (audit fix #L12). - /// - /// This is the zero-clone counterpart to [`get_query_data`](Self::get_query_data): - /// instead of returning `Option<T>` (which clones the value out of the - /// resource), it hands `f` a `&T` for the duration of the call. Use this - /// when the caller only needs to *inspect* the cached data (e.g. compute a - /// derived value, render a summary) and would otherwise pay for a full - /// `T::clone()` it discards immediately. + /// Read the cached data via a borrow callback, with no clone of `T`. /// - /// Returns `None` if no resource exists for the key, the entity was - /// collected, or the resource has no data. Returns `Some(R)` (the value - /// produced by `f`) otherwise. `T` and `E` are unchanged from - /// `get_query_data`; `R` is the closure's return type and is independent of - /// `T`, so it does not shadow the crate's `T`/`E` conventions. + /// The zero-clone counterpart to [`get_query_data`](Self::get_query_data): + /// `f` receives `&T` for the duration of the call, for callers that only + /// inspect the data and would discard a full `T::clone()`. Returns + /// `None` under the same conditions as `get_query_data`. pub fn with_query_data< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -409,14 +321,13 @@ impl QueryClient { entity.read_with(cx, |resource, _| resource.data().map(f)) } - /// Write data directly into the cache for a query key, creating the resource - /// if it does not already exist. + /// Write data directly into the cache for a query key, creating the + /// resource if it does not already exist. The previous data is saved for + /// rollback via `rollback_to_previous()`. The write does not change the + /// resource's status or timestamp. /// - /// This is the ergonomic equivalent of TanStack Query's `queryClient.setQueryData(key, data)`. - /// The resource's previous data is saved for rollback via `rollback_to_previous()`. - /// The data is set via `set_data()` which saves previous data but does not - /// change the resource's status or timestamp. Use this for optimistic updates - /// and manual cache manipulation where you control the lifecycle. + /// The ergonomic equivalent of TanStack Query's + /// `queryClient.setQueryData(key, data)`. pub fn set_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &mut self, key: impl Into<QueryKey>, @@ -427,16 +338,12 @@ impl QueryClient { let entity = self.resource::<T, E>(key, cx); entity.update(cx, |resource, cx| { resource.set_data(data); - // B2: bump the precise dirty signal so `persist_with` schedules a - // save. `default_global` creates the marker if absent AND pushes - // GPUI's `NotifyGlobalObservers` effect (see gpui `App::default_global`), - // which wakes the `observe_global::<CacheMutation>` observer in - // `persist_with`. It is infallible, so the no-`persist_with` build's - // `set_query_data` path never panics on an absent marker. + // Bump the dirty signal so `persist_with` schedules a save. + // `default_global` seeds the marker if absent and pushes GPUI's + // NotifyGlobalObservers effect, which the `persist_with` driver + // observes; it is infallible. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - // In the default (no-persist) build the closure's `cx` is otherwise - // unused; reference it so the build stays warning-free. #[cfg(not(feature = "persist"))] let _ = cx; }); diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 662a27b..93525b8 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -1,34 +1,8 @@ //! Type-partitioned bucket for mutation resources. //! -//! **v2 fix**: Implements actual GC instead of the v1 no-op. -//! -//! Each entry tracks an `updated_at` timestamp set at insertion. The GC removes -//! entries whose `updated_at` is older than `gc_time_ms` **and** that are not -//! currently loading. The erased `gc` signature carries `cx`, so the retain -//! closure reads live entity state directly via `entity.read(cx)` for the -//! loading and status checks; the timestamp stored on the entry is used only -//! for age-based filtering. -//! -//! **Audit fixes (this pass)**: -//! - `max_entries` cap + `evict_oldest` added (#2) — previously successful -//! mutation resources were never evicted, causing unbounded memory growth. -//! - `observer_count` / `retain()` / `release()` removed (#8) — they were -//! never incremented from production and are now redundant with -//! `WeakEntity::upgrade()` liveness, matching `QueryBucket`. -//! - GC rewritten as a single `HashMap::retain` closure (#91) — it only reads -//! `entity.read(cx)` (no update), so it is GPUI-safe and avoids the -//! collect-ids-then-remove two-phase dance. -//! - `Success` mutations now evictable (#108) when their age exceeds -//! `SUCCESS_GC_MULTIPLIER * gc_time_ms`, mirroring `QueryBucket::gc`. -//! - Local `MIN_GC_TIME_MS` removed (#69); imported from `bucket::types`. -//! - `insert` now takes a live `cx` (renamed from `_cx`) and evicts the oldest -//! entry before inserting when at capacity (#114). -//! - `touch()` / `set_loading()` / `set_not_loading()` removed as dead code -//! (#75). GC recency now prefers `MutationResource::last_updated_at_ms` -//! (terminal-completion time) over the entry's insertion time (audit #112), -//! so a recently-completed mutation inserted long ago is not evicted -//! prematurely; insertion time remains the fallback for never-completed -//! (Idle / in-flight) mutations. +//! Mutations are keyed by a generated numeric id (they have no query key), +//! and GC measures recency from the resource's completion time, falling back +//! to the insertion timestamp for mutations that never completed. use ahash::AHashMap; use gpui::{App, WeakEntity}; @@ -39,44 +13,14 @@ use super::ErasedMutationBucket; use super::bucket::types::{DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; use super::devtools::MutationDiagnostic; -// (Audit #75: `DEFAULT_MUTATION_GC_TIME_MS` was dead code — never referenced -// after the GC refactor — so the constant and its `#[allow(dead_code)]` are -// removed. Callers wanting the 5-minute default use `QueryClient::with_gc_time`.) - -/// Per-entry metadata stored alongside the entity. -/// -/// Uses `WeakEntity` instead of `Entity` so that the bucket does not prevent -/// GPUI from garbage-collecting mutation resources when all component-held -/// strong references are dropped. The weak reference is upgraded on access; -/// if the entity was already collected, the entry is treated as dead and -/// cleaned up by GC. -/// -/// # M2 eviction mirror (supersedes M1) -/// -/// `loading` is no longer the dead always-`false` field from M1 — it is now a -/// *real mirror* of `MutationResource::is_loading()`, and `last_updated_ms` -/// mirrors `MutationResource::last_updated_at_ms()` (the terminal-completion -/// time). Both are refreshed wherever the bucket already reads the entity -/// (zero extra reads). `evict_oldest` scans the mirrors + `WeakEntity::upgrade` -/// liveness with **one** `entity.read` on the winner to confirm `!is_loading()` -/// (guards #109 against a stale mirror), turning O(n) entity reads into O(1). -/// -/// `updated_at` remains the insertion timestamp (fallback recency for a -/// mutation that has never completed — audit #112). +/// Weak entity handle plus the eviction mirror. `updated_at` is the insertion +/// time; `last_updated_ms` / `loading` mirror the entity and are refreshed +/// wherever the bucket already reads it, so `evict_oldest` scans cheap fields +/// and confirms its winner with a single entity read. struct MutationEntry<V, T, E> { entity: WeakEntity<MutationResource<V, T, E>>, - /// Monotonic millisecond timestamp recorded at insertion. - /// - /// `MutationBucket::gc` prefers `MutationResource::last_updated_at_ms` - /// (terminal-completion time) over this insertion time when measuring - /// recency (audit #112); this value is the fallback used for mutations that - /// have never completed (Idle / in-flight). updated_at: u64, - /// Mirror of `MutationResource::last_updated_at_ms()` (completion time). - /// `None` until first refresh after a terminal completion. last_updated_ms: Option<u64>, - /// Mirror of `MutationResource::is_loading()`. Refreshed on every bucket - /// read of the entity; read by `evict_oldest`'s mirror scan and by `gc`. loading: bool, } @@ -84,13 +28,10 @@ struct MutationEntry<V, T, E> { pub struct MutationBucket<V, T, E> { resources: AHashMap<u64, MutationEntry<V, T, E>>, next_id: u64, - /// Maximum number of entries allowed in this bucket (#2 fix). - /// When exceeded, the oldest entry (by `updated_at`) is evicted. + /// Entries allowed before the oldest one is evicted. max_entries: usize, } -/// (Audit #41) The previous private `now_ms()` duplicate is removed; this -/// module now reuses the canonical [`super::erased::current_time_ms`]. impl< V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, @@ -105,18 +46,11 @@ impl< } } - /// Evict the oldest (least-recently-updated) entry to make room for a new one. - /// - /// **M2 (O(n)→O(1) entity reads)**: scans entry MIRRORS (no `entity.read`) - /// plus `WeakEntity::upgrade` liveness, picking the entry with the smallest - /// mirrored recency — preferring the completion-time mirror - /// (`last_updated_ms`) and falling back to the insertion time - /// (`updated_at`) for never-completed mutations (audit #112). Then **one** - /// `entity.read` on the winner to confirm `!is_loading()` (guards #109 - /// against a stale mirror) before removal. Mirrors - /// [`QueryBucket::evict_oldest`](super::QueryBucket::evict_oldest). - /// - /// **Audit fix #2**. + /// Evict the least-recently-updated entry to make room. Recency prefers + /// the completion-time mirror, falling back to the insertion time for + /// mutations that never completed. The winner gets one confirming entity + /// read (the mirror can be stale if a fetch began after the last + /// refresh); each retry marks the stale mirror and re-picks. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self @@ -127,15 +61,12 @@ impl< return None; } entry.entity.upgrade()?; - // Prefer completion-time mirror; fall back to insertion - // time for never-completed mutations (audit #112). - let age = entry.last_updated_ms.unwrap_or(entry.updated_at); - Some((*id, age)) + Some((*id, entry.last_updated_ms.unwrap_or(entry.updated_at))) }) .min_by_key(|&(_, age)| age); let Some((id, _)) = target else { - return; + return; // every live entry is loading: nothing safe to evict }; let still_loading = self @@ -146,12 +77,9 @@ impl< match still_loading { Some(true) => { - // Stale mirror: a mutation began after the last refresh. - // Mark and re-pick so #109 is honored. if let Some(entry) = self.resources.get_mut(&id) { entry.loading = true; } - continue; } _ => { self.resources.remove(&id); @@ -161,22 +89,12 @@ impl< } } - /// Insert a mutation entity, recording `now_ms` as `updated_at`. + /// Insert a mutation entity, recording `now_ms` as `updated_at`, and + /// return the generated id. Evicts the oldest non-loading entry first + /// when at capacity. /// - /// Returns the generated numeric id for the entry. - /// - /// When the bucket is at capacity, the oldest non-loading entry is evicted - /// first (#2). **Audit fix #114**: the `cx` param (previously unused - /// `_cx`) is now passed to `evict_oldest`. - /// - /// **M6**: `now_ms` is threaded in from the caller (which already cached - /// it for `maybe_opportunistic_gc`) instead of re-syscalling - /// `current_time_ms()` here. - /// - /// **Audit fix #3**: Uses `checked_add` on `next_id` to prevent wraparound - /// after `u64::MAX` insertions. If the counter overflows, the method - /// panics — consistent with the principle that IDs must be unique and - /// wraparound would cause data loss. + /// `next_id` saturates at `u64::MAX`: staying monotonic matters more than + /// uniqueness after ~1.8e19 insertions, which GC has long outlived. pub(crate) fn insert( &mut self, entity: &gpui::Entity<MutationResource<V, T, E>>, @@ -188,17 +106,7 @@ impl< } let id = self.next_id; - // Audit fix #29: replace the production `.expect()` on `checked_add` - // with a saturating fallback so the bucket never panics after - // `u64::MAX` insertions. `saturating_add` clamps `next_id` at - // `u64::MAX`, which keeps it monotonic (no duplicate IDs while earlier - // IDs are still live) and is the safe alternative to panicking. The - // prior comment is preserved below for intent. self.next_id = self.next_id.saturating_add(1); - // Note: saturating at `u64::MAX` means every insertion past - // `u64::MAX` reuses that single ID — acceptable because reaching this - // state requires ~1.8e19 prior insertions, and GC has long since - // evicted the originals. self.resources.insert( id, MutationEntry { @@ -211,22 +119,6 @@ impl< id } - // (Audit #75/#112: `touch()`, `set_loading()`, `set_not_loading()` were - // dead code — never called from production. They are removed along with - // their `#[allow(dead_code)]` attributes. The `loading` entry field is - // retained because `gc` still reads it as a secondary guard; it stays - // `false` in practice, which is harmless. Audit #112 (computing mutation - // GC recency from live entity completion time) was skipped because - // `MutationResource` stores no completion/last-updated timestamp — wiring - // one would require editing `core/mutation.rs`, outside this group.) - - /// All entities in this bucket that are still alive. - /// - /// **Audit 3 fix (finding 1)**: This method allocates a `Vec` of all - /// mutation entities by upgrading weak references. Callers that invoke - /// this on every render (e.g., `use_mutation_state()`) will allocate a - /// new `Vec` each time. If this becomes a performance concern, consider - /// caching the result or calling this less frequently. pub(crate) fn all_entities(&self) -> Vec<gpui::Entity<MutationResource<V, T, E>>> { self.resources .values() @@ -249,54 +141,20 @@ impl< self } - /// Garbage-collect stale mutation resources. - /// - /// **Audit fix #91**: Rewritten as a single `HashMap::retain` closure - /// that returns `false` to evict. It only reads `entity.read(cx)` (no - /// `update`), so it is GPUI-safe and avoids the two-phase collect-then- - /// remove dance. Preserves the exact eviction semantics. - /// - /// **Audit fix #4**: Uses `cx` to read entity state and check - /// `is_loading()`, matching the pattern used in `QueryBucket::gc`. - /// - /// **Audit fix #2**: Also checks the entry-level `loading` flag as a - /// secondary guard, protecting mid-flight mutations even when the weak - /// reference cannot be upgraded. - /// - /// **Audit fix #5**: Removes dead entries whose `WeakEntity` can no - /// longer be upgraded (all strong references dropped). - /// - /// **Audit fix #108**: `Success` mutations are now evictable when their - /// age exceeds `SUCCESS_GC_MULTIPLIER * gc_time_ms`, mirroring how - /// `QueryBucket::gc` computes the success threshold. `Idle` and - /// `Failure` remain evictable at `gc_threshold`. - /// - /// Evicts entries where: - /// 1. The entity has been collected (weak ref dead) and the entry is not - /// flagged loading, OR - /// 2. The entry is not loading (flag and entity state), is in an evictable - /// status (`Idle`/`Failure`/`Success`), and the age exceeds the - /// threshold for that status (`gc_threshold` for Idle/Failure, - /// `SUCCESS_GC_MULTIPLIER * gc_threshold` for Success). + /// Evict dead references and terminal mutations past their age window: + /// loading always survives; `Success` survives + /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`; `Idle`/`Failure` survive + /// `gc_time_ms`. The entry `loading` mirror is checked first so a + /// mid-flight mutation whose weak ref cannot upgrade survives one cycle. fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { - let gc_time_ms = gc_time_ms.max(MIN_GC_TIME_MS); - let gc_threshold = gc_time_ms; - let success_threshold = gc_threshold * (SUCCESS_GC_MULTIPLIER as u64); + let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); + let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); self.resources.retain(|_id, entry| { - // M2: refresh the eviction mirror from this read (gc walks every - // entry anyway, so this is the canonical refresh point). The - // mirror's `loading` is now authoritative, so the separate - // dead-entry `loading` safety guard below stays as a cheap - // pre-check that avoids an `upgrade` when the mirror already says - // loading (a dead entry with loading=true means a mutation is - // in-flight but the weak ref couldn't upgrade — keep it one cycle). if entry.loading { return true; } - // Audit fix (finding 5): Remove dead entries whose entity has - // already been collected. let Some(entity) = entry.entity.upgrade() else { return false; }; @@ -305,32 +163,20 @@ impl< entry.last_updated_ms = resource.last_updated_at_ms(); entry.loading = resource.is_loading(); - // Audit fix (finding 4): Never evict resources that are actively - // loading, even if the entry flag disagrees. if resource.is_loading() { return true; } - let status = resource.status(); - - // Audit fix #108: Success is evictable on its own (longer) age - // threshold; Idle and Failure evict at gc_threshold. - let (evictable, threshold) = match status { - MutationStatus::Success => (true, success_threshold), - MutationStatus::Idle | MutationStatus::Failure => (true, gc_threshold), - MutationStatus::Loading => (false, gc_threshold), + let threshold = match resource.status() { + MutationStatus::Success => success_threshold, + MutationStatus::Idle | MutationStatus::Failure => gc_threshold, + MutationStatus::Loading => return true, }; - if !evictable { - return true; - } - // Audit fix #112: measure recency from the resource's last terminal - // *completion* time when available, so a recently completed mutation - // that was inserted long ago is not evicted prematurely. Fall back to - // the entry's insertion time for mutations that never completed. + // Recency from the completion time when available; insertion + // time for mutations that never completed. let base = resource.last_updated_at_ms().unwrap_or(entry.updated_at); - let age = now_ms.saturating_sub(base); - age < threshold + now_ms.saturating_sub(base) < threshold }); } @@ -338,12 +184,6 @@ impl< self.resources.len() } - /// Collect per-resource diagnostic details for all live mutation entries. - /// - /// Iterates all entries, upgrades weak references, reads entity state, - /// and constructs a `MutationDiagnostic` for each live resource. - /// Dead entries (collected entities) are skipped. Pushes each live entry's - /// `MutationDiagnostic` into `out` instead of allocating a fresh `Vec`. fn collect_diagnostics_into(&self, cx: &App, out: &mut Vec<MutationDiagnostic>) { for entry in self.resources.values() { let Some(entity) = entry.entity.upgrade() else { @@ -358,9 +198,8 @@ impl< } } - /// Lightweight key/status pairs (#9). `key` is `None` for keyless - /// mutations. Pushes each live entry's `(Option<String>, MutationStatus)` - /// pair into `out`. + /// `key` is `None` for keyless mutations, mirroring + /// [`MutationDiagnostic::key`]. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(Option<String>, MutationStatus)>) { for entry in self.resources.values() { diff --git a/crates/gpui-query/src/client/mutation_signal.rs b/crates/gpui-query/src/client/mutation_signal.rs index 42b8e83..5bcdf97 100644 --- a/crates/gpui-query/src/client/mutation_signal.rs +++ b/crates/gpui-query/src/client/mutation_signal.rs @@ -1,27 +1,11 @@ -//! Precise whole-client dirty signal for the persistence layer. -//! -//! [`CacheMutation`] is a marker [`gpui::Global`] bumped (via -//! `cx.default_global::<CacheMutation>()`) at every point in the client/hook -//! layer that mutates cached query data. In GPUI, `default_global` pushes -//! `Effect::NotifyGlobalObservers` exactly like `set_global` and `global_mut`, -//! so [`persist_with`](super::QueryClient::persist_with) — which subscribes via -//! `cx.observe_global::<CacheMutation>()` — wakes on every bump and a save is -//! scheduled exactly when the cache actually changed. This is "Open Question 2 -//! → Option B" from the design doc: a dedicated signal is both more precise -//! (it avoids the spurious fetch-start noise of observing the `QueryClient` -//! global) and more complete (it fires for `set_query_data` and the three -//! completion-site families, where `observe_global::<QueryClient>` would miss -//! bare `entity.update` completions that don't touch the `QueryClient` global). - /// Marker [`gpui::Global`] bumped whenever cached query data changes. /// -/// The value itself carries no state — the bump sites call -/// `cx.default_global::<CacheMutation>()`, which (like `set_global` and -/// `global_mut`) unconditionally pushes GPUI's `NotifyGlobalObservers` effect, -/// and that notification is what `observe_global::<CacheMutation>()` -/// listeners — including the `persist_with` driver — react to. `default_global` -/// is infallible and seeds the marker on first bump if no driver has set it -/// yet, so the bump sites never panic. +/// The value carries no state: bump sites call +/// `cx.default_global::<CacheMutation>()`, which pushes GPUI's +/// `NotifyGlobalObservers` effect exactly like `set_global`, and that +/// notification is what `observe_global::<CacheMutation>()` listeners +/// (the `persist_with` driver) react to. `default_global` is infallible and +/// seeds the marker on first bump, so bump sites never panic. #[derive(Default)] pub struct CacheMutation; diff --git a/crates/gpui-query/src/client/observer.rs b/crates/gpui-query/src/client/observer.rs index 67ee192..4371c53 100644 --- a/crates/gpui-query/src/client/observer.rs +++ b/crates/gpui-query/src/client/observer.rs @@ -1,15 +1,8 @@ -//! Query observer for reactive state tracking. +//! Resource observers for reactive state tracking. //! -//! **v2 improvements**: -//! - `observe()` returns `Option<Subscription>` instead of panicking on dropped entity -//! - Status deduplication to avoid unnecessary `cx.notify()` calls -//! -//! **Audit L8**: the three formerly-structurally-identical observer types -//! (`QueryObserver`, `InfiniteQueryObserver`, `MutationObserver`) are now a -//! single generic [`Observer<R>`] plus type aliases. They differed only in -//! their entity type and status type (`QueryStatus` vs `MutationStatus`); the -//! observe logic (dedup `cx.notify()` via a `Cell<Option<S>>`, fall back to -//! unconditional notify) is shared by the one generic impl. +//! The three observer kinds (`QueryObserver`, `InfiniteQueryObserver`, +//! `MutationObserver`) are aliases over one generic [`Observer<R>`]; they +//! differ only in entity and status type. use std::cell::Cell; @@ -21,10 +14,10 @@ use crate::core::{ /// Bridges a resource type to its status for the generic [`Observer`]. /// -/// Each resource exposes its status via an *inherent* `status()` method, which -/// cannot be called generically without a trait; this trait (pub(crate), not -/// part of the public API) surfaces it with an associated `Status` type so -/// [`Observer<R>`] can dedup notifications for any resource kind. +/// Each resource exposes its status via an inherent `status()` method, which +/// cannot be called generically without a trait; this pub(crate) trait +/// surfaces it with an associated `Status` type so [`Observer<R>`] can dedup +/// notifications for any resource kind. pub trait ObservableResource { type Status: PartialEq + Copy + 'static; @@ -72,20 +65,11 @@ impl Default for ObserverConfig { /// Observes a resource and triggers re-renders only on status changes. /// -/// In v2, the observer only calls `cx.notify()` when the status actually -/// changes, preventing excessive re-renders from intermediate state updates -/// like retry count increments. -/// -/// This is a single generic implementation shared by every resource kind -/// (audit L8). Use the [`QueryObserver`] / [`InfiniteQueryObserver`] / -/// [`MutationObserver`] type aliases for the concrete kinds. -/// -/// This is also the fix for audit findings #1/#11: the raw `cx.observe` in -/// `use_mutation` unconditionally called `cx.notify()` on every entity -/// mutation, causing 2-3 re-renders per retry attempt. By tracking the last -/// status and only notifying on change, `increment_retry()` / `prepare_retry()` -/// calls (which don't change status — it stays Loading) no longer trigger -/// re-renders. +/// With the default config, `cx.notify()` fires only when the status +/// actually changes, so intermediate updates that keep the status (retry +/// count increments, `prepare_retry`) do not re-render. Use the +/// [`QueryObserver`] / [`InfiniteQueryObserver`] / [`MutationObserver`] +/// aliases for the concrete kinds. pub struct Observer<R> { entity: gpui::WeakEntity<R>, config: ObserverConfig, @@ -106,13 +90,9 @@ impl<R: ObservableResource + 'static> Observer<R> { self } - /// Start observing the entity. Returns `None` if the entity was already dropped. - /// - /// **v2 fix**: Returns `Option<Subscription>` instead of panicking. - /// - /// **Audit #71**: takes `&self` (was `&mut self`) — the body only reads the - /// weak entity handle and the `Copy` config flag, so no interior mutation - /// is required. `&mut` callers coerce to `&` with no ripple. + /// Start observing the entity. Returns `None` if the entity was already + /// dropped. Takes `&self`: the body only reads the weak handle and the + /// `Copy` config flag. pub fn observe<W: 'static>(&self, cx: &mut Context<W>) -> Option<Subscription> { let upgraded = self.entity.upgrade()?; let notify_on_change = self.config.notify_on_status_change_only; diff --git a/crates/gpui-query/src/client/prepared_fetch.rs b/crates/gpui-query/src/client/prepared_fetch.rs index c1a6759..8687dfd 100644 --- a/crates/gpui-query/src/client/prepared_fetch.rs +++ b/crates/gpui-query/src/client/prepared_fetch.rs @@ -1,22 +1,18 @@ -//! Prepared fetch type for imperative and prefetch query operations. -//! -//! [`PreparedFetch`] is returned by `QueryClient::prepare_fetch_query` and -//! `QueryClient::prepare_prefetch_query`. It holds the entity, request ID, -//! and cooperative cancellation signal needed to complete an async fetch. +//! [`PreparedFetch`]: the handle returned by the imperative fetch and +//! prefetch operations. use gpui::{App, Entity}; use crate::core::QueryResource; -/// A prepared fetch returned by [`QueryClient::prepare_fetch_query`] or -/// [`QueryClient::prepare_prefetch_query`]. +/// A prepared fetch returned by +/// [`QueryClient::prepare_fetch_query`](crate::client::QueryClient::prepare_fetch_query) +/// or +/// [`QueryClient::prepare_prefetch_query`](crate::client::QueryClient::prepare_prefetch_query). /// -/// Contains the entity, request ID, and cooperative cancellation signal -/// needed to perform the async fetch and complete the resource. -/// -/// The caller should: -/// 1. Call their fetcher with `self.signal` -/// 2. Use `complete_success` or `complete_failure` with the result +/// Holds the entity, request ID, and cooperative cancellation signal needed +/// to perform the async fetch: call your fetcher with `self.signal`, then +/// complete via `complete_success` or `complete_failure`. /// /// # Example /// @@ -44,31 +40,19 @@ pub struct PreparedFetch<T, E> { pub request_id: crate::core::RequestId, /// The cooperative cancellation signal for the in-flight request. pub signal: crate::core::QuerySignal, - /// **M3**: the wall-clock ms captured at prepare time. Reused by - /// `complete_success` / `complete_failure` so they don't re-syscall - /// `current_time_ms()` (the fetch's logical completion time is the prepare - /// time, matching the request's `started_at`). + /// Completion time captured at prepare time; the fetch's logical + /// completion clock, reused by the complete_* methods. pub(crate) now_ms: u64, } impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> PreparedFetch<T, E> { - /// Complete the fetch with success. - /// - /// Calls `complete_current_success` on the resource entity. If the request - /// ID is no longer active (replaced by a newer request), this is a no-op. - /// - /// **M3**: reuses the `now_ms` captured at prepare time instead of - /// re-syscalling `current_time_ms()`. + /// Complete the fetch with success. A no-op if the request ID is no + /// longer active (replaced by a newer request). pub fn complete_success(self, data: T, cx: &mut App) { self.entity.update(cx, |resource, cx| { let accepted = resource.complete_current_success(self.request_id, data, self.now_ms); - // B2: precise dirty signal for the persistence layer. Imperative - // completions (`prepare_fetch_query`) mutate the cache just like the - // hook-layer completions, so they must wake `persist_with` too — - // without this a resolved imperative fetch is silently never saved. - // Gated on `accepted` to match the hook sites (which bump inside the - // `accept_current_request` guard), so a stale no-op completion does - // not spuriously schedule a save. + // Wake the persistence driver, but only when the completion was + // actually accepted, so a stale no-op does not schedule a save. if accepted { #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); @@ -76,19 +60,11 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare }); } - /// Complete the fetch with failure. - /// - /// Calls `complete_current_failure` on the resource entity. If the request - /// ID is no longer active (replaced by a newer request), this is a no-op. - /// - /// **M3**: reuses the `now_ms` captured at prepare time instead of - /// re-syscalling `current_time_ms()`. + /// Complete the fetch with failure. A no-op if the request ID is no + /// longer active (replaced by a newer request). pub fn complete_failure(self, error: E, cx: &mut App) { self.entity.update(cx, |resource, cx| { let accepted = resource.complete_current_failure(self.request_id, error, self.now_ms); - // B2: see `complete_success` — bump only when the failure was - // actually accepted, so an imperative failure is visible to - // `persist_with` without spurious saves on stale completions. if accepted { #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); diff --git a/crates/gpui-query/src/client/time.rs b/crates/gpui-query/src/client/time.rs index 62ccf30..0b55616 100644 --- a/crates/gpui-query/src/client/time.rs +++ b/crates/gpui-query/src/client/time.rs @@ -1,28 +1,12 @@ -//! Neutral time helper shared across the client layer. -//! -//! Previously co-located with the type-erased bucket traits in `erased.rs`, this -//! helper moved to its own module so the `persist` feature gate (which now owns -//! `erased.rs`'s persistence symbols) does not pull `current_time_ms` behind a -//! `cfg`: the GC subsystem and several non-persistence call sites depend on it. - /// Returns the current time as milliseconds since the UNIX epoch. /// -/// Used internally by `gc()` and other time-sensitive operations. -/// Exposed so callers can cache the value and pass it to `gc_with_time()` -/// to avoid repeated syscalls. +/// Callers can cache the value and pass it to +/// [`gc_with_time`](crate::client::QueryClient::gc_with_time) to avoid +/// repeated syscalls. /// -/// # Clock-before-epoch fallback -/// -/// `duration_since(UNIX_EPOCH)` errors if the system clock reports a time -/// *before* the Unix epoch (1970-01-01 UTC) — e.g. a misconfigured RTC or a -/// clock skewed backwards on cold boot. The `.unwrap_or_default()` silently -/// clamps that case to a `Duration::ZERO`, i.e. this function returns `0`. -/// That `0` is treated as "ancient" by GC, so the only observable effect is -/// that entries become immediately eligible for garbage collection for the -/// duration of the clock anomaly; no panic, no error propagation. This is a -/// deliberate silent clamp rather than a propagating error because every -/// caller treats `now_ms` as infallible and time-sensitive operations -/// degrading to "collect now" is the safest default under a broken clock. +/// A clock reading before the Unix epoch clamps to `0` rather than +/// propagating an error; GC treats `0` as "ancient", so the only effect of +/// such a clock anomaly is that entries become immediately GC-eligible. pub fn current_time_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs b/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs index b9d294b..e816236 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs @@ -39,7 +39,6 @@ fn two_phase_stale_accept_then_complete_does_not_corrupt() { let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); // rid1 is stale. complete_current_success should return false. - // Audit fix #85: use the shared `complete_success_id` helper. assert!(!complete_success_id(&mut r, rid1, "stale_data", 300)); assert_eq!(r.ignored_results(), 1); @@ -93,7 +92,7 @@ fn ignore_while_loading_rejects_concurrent_requests() { ); assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); - // Complete the first request. (Audit fix #85: shared helper.) + // Complete the first request. complete_success_id(&mut r, rid1, "data", 300); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"data")); diff --git a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs b/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs index 1b414c6..f798d1b 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs @@ -1,4 +1,4 @@ -//! Individual gap-filling tests (GAP-03 through GAP-20). +//! Individual gap-filling tests. //! //! Covers: begin_request_with_id + SWR + IgnoreWhileLoading, stale request ID //! rejection, Force mode + IgnoreWhileLoading, QueryError sanitized, QueryKey @@ -9,10 +9,6 @@ use crate::core::*; use crate::tests::test_support::*; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-03: begin_request_with_id + SWR + IgnoreWhileLoading + active request -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn begin_request_with_id_swr_ignore_while_loading_with_active_request() { let mut r: QueryResource<&str> = QueryResource::new( @@ -62,10 +58,6 @@ fn begin_request_with_id_swr_ignore_while_loading_with_active_request() { } } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-04: complete_current_optional_success rejects stale request ID -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn complete_current_optional_success_rejects_stale_id() { let mut r = test_resource(); @@ -89,10 +81,6 @@ fn complete_current_optional_success_rejects_stale_id() { assert_eq!(r.data(), Some(&"fresh")); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-05: complete_current_failure_with_data rejects stale request ID -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn complete_current_failure_with_data_rejects_stale_id() { let mut r = test_resource(); @@ -111,10 +99,6 @@ fn complete_current_failure_with_data_rejects_stale_id() { assert!(r.active_request_id().is_some()); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-06: Force mode respects IgnoreWhileLoading -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn ignore_while_loading_rejects_forced_fetch_when_loading() { let mut r: QueryResource<&str> = QueryResource::new( @@ -133,10 +117,6 @@ fn ignore_while_loading_rejects_forced_fetch_when_loading() { ); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-12: QueryError::sanitized() with mongodb connection string -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_error_sanitized_mongodb_connection() { let err = QueryError::transport("connect mongodb://admin:secret@host/db failed"); @@ -151,10 +131,6 @@ fn query_error_sanitized_mongodb_connection() { ); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-13: QueryError::sanitized() with empty string message -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_error_sanitized_empty_message() { let err = QueryError::response(""); @@ -162,10 +138,6 @@ fn query_error_sanitized_empty_message() { assert_eq!(clean.message(), ""); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-14: QueryError::new() with explicit kind -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_error_new_with_explicit_kind() { let err = QueryError::new(QueryErrorKind::Transport, "timeout"); @@ -173,10 +145,6 @@ fn query_error_new_with_explicit_kind() { assert_eq!(err.message(), "timeout"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-15: record_cache_hit does not clear Cancelled status -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn record_cache_hit_does_not_clear_cancelled_status() { let mut r: QueryResource<&str> = QueryResource::new( @@ -202,10 +170,6 @@ fn record_cache_hit_does_not_clear_cancelled_status() { assert_eq!(r.cache_hits(), 1); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-16: QueryKey::join() appends segments -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn join_appends_segment() { let key = QueryKey::from(["users"]); @@ -223,10 +187,6 @@ fn join_chain_creates_multi_part_key() { assert_eq!(key.to_path(), "users::42::posts"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-17: QueryKey::from(Vec<String>) -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn from_vec_string() { let key = QueryKey::from(vec!["users".to_string(), "42".to_string()]); @@ -234,10 +194,6 @@ fn from_vec_string() { assert_eq!(key.to_path(), "users::42"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-18: QueryKey Deref to [Arc<str>] allows indexing -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn deref_allows_indexing() { let key = QueryKey::from(["a", "b", "c"]); @@ -246,10 +202,6 @@ fn deref_allows_indexing() { assert_eq!(key.len(), 3); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-19: QueryKey serde deserialize from single string -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn serde_deserialize_single_string() { let json = "\"users\""; @@ -258,10 +210,6 @@ fn serde_deserialize_single_string() { assert_eq!(key.first_segment(), "users"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-20: QueryKey Hash consistency -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn hash_consistency() { use std::collections::HashSet; @@ -274,10 +222,6 @@ fn hash_consistency() { assert!(!set.contains(&k3), "different keys should not match"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-07: InfiniteQuery begin_fetch_previous with IgnoreWhileLoading -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn ignore_while_loading_prevents_previous_page_replacement() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -300,10 +244,6 @@ fn ignore_while_loading_prevents_previous_page_replacement() { assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-08: Cross-direction IgnoreWhileLoading (next then previous) -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn ignore_while_loading_cross_direction_next_then_prev() { let mut r = InfiniteQueryResource::<Vec<String>>::new_bidirectional( @@ -333,10 +273,6 @@ fn ignore_while_loading_cross_direction_next_then_prev() { assert!(!r.is_fetching_next_page()); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-09: InfiniteQueryResource reset preserves retry_policy -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn infinite_query_reset_preserves_retry_policy() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -356,10 +292,6 @@ fn infinite_query_reset_preserves_retry_policy() { ); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-10: Bidirectional resource initial accessors -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn bidirectional_resource_initial_accessors() { let r = InfiniteQueryResource::<Vec<String>>::new_bidirectional( @@ -374,10 +306,6 @@ fn bidirectional_resource_initial_accessors() { assert!(!r.has_previous_page()); } -// ═══════════════════════════════════════════════════════════════════════════ -// GAP-11: prepend with has_more=true preserves has_previous_page -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn prepend_with_has_more_true_preserves_has_previous() { let mut r = InfiniteQueryResource::<Vec<String>>::new( diff --git a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs b/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs index 463beb7..5073ee9 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs @@ -10,7 +10,7 @@ use gpui::{BorrowAppContext as _, TestAppContext}; /// Populate a `Success` resource whose `last_updated_at` is a known timestamp. /// -/// GC reads live entity state directly (audit #CL2), so we drive the resource +/// GC reads live entity state, so we drive the resource /// to `Success` with a controlled timestamp via `apply_success` instead of /// faking a cached snapshot. `Ttl` has no stale window, so GC falls through to /// the success-threshold age check (`success_threshold = 2 * gc_time_ms`). @@ -109,8 +109,7 @@ fn test_gc_preserves_loading_resource_with_snapshot(cx: &mut TestAppContext) { let prepared = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) .expect("should start"); - // Don't complete — leave in Loading state. GC reads the live - // LoadingEmpty status (audit #CL2), so no snapshot is needed. + // Don't complete — leave in Loading state. // GC at t=1_000_000 — Loading resources are never evicted. client.gc_with_time(1_000_000, cx); @@ -140,8 +139,7 @@ fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Loading => preserved (loading never evicted). GC reads the live - // LoadingEmpty status (audit #CL2), so no snapshot is needed. + // Loading => preserved (loading is never evicted). let prepared = client .prepare_fetch_query::<String, QueryError>("loading", cx) .expect("should start"); diff --git a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs b/crates/gpui-query/src/tests/coverage_gaps/property_based.rs index 54c5318..bdea595 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/property_based.rs @@ -274,7 +274,6 @@ fn prop_cache_policy_total_valid_ms_consistency() { #[test] fn prop_serde_roundtrip_all_statuses() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ QueryStatus::Idle, QueryStatus::LoadingEmpty, @@ -287,7 +286,6 @@ fn prop_serde_roundtrip_all_statuses() { #[test] fn prop_serde_roundtrip_all_cache_policies() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ CachePolicy::NoCache, CachePolicy::Ttl { ttl_ms: 0 }, @@ -311,13 +309,11 @@ fn prop_serde_roundtrip_all_cache_policies() { #[test] fn prop_serde_roundtrip_all_request_policies() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[RequestPolicy::LatestWins, RequestPolicy::IgnoreWhileLoading]); } #[test] fn prop_serde_roundtrip_retry_policies() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ RetryPolicy::no_retries(), RetryPolicy::default(), @@ -336,7 +332,6 @@ fn prop_serde_roundtrip_retry_policies() { #[test] fn prop_serde_roundtrip_query_error_all_kinds() { - // T10: shared roundtrip helper. assert_serde_roundtrip(&[ QueryError::cancelled("abort"), QueryError::response("not found"), diff --git a/crates/gpui-query/src/tests/integration_client/client_basics.rs b/crates/gpui-query/src/tests/integration_client/client_basics.rs index cf3c974..8343e69 100644 --- a/crates/gpui-query/src/tests/integration_client/client_basics.rs +++ b/crates/gpui-query/src/tests/integration_client/client_basics.rs @@ -299,12 +299,8 @@ fn test_query_observer_observe_returns_subscription(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { let entity = client.resource::<String, QueryError>("sub_key", cx); - // Create a dummy view to host the observer - struct DummyView; - let view = cx.new(|_| DummyView); - - let observer = QueryObserver::new(&entity); - let subscription = view.update(cx, |_view, cx| observer.observe(cx)); + let mut observer = QueryObserver::new(&entity); + let subscription = observe_with_dummy_view(cx, &mut observer); assert!( subscription.is_some(), "observe should return Some(Subscription)" diff --git a/crates/gpui-query/src/tests/integration_client/data_access.rs b/crates/gpui-query/src/tests/integration_client/data_access.rs index 122dcf6..5089bc1 100644 --- a/crates/gpui-query/src/tests/integration_client/data_access.rs +++ b/crates/gpui-query/src/tests/integration_client/data_access.rs @@ -163,9 +163,8 @@ fn test_with_query_data_reads_without_clone(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>("len_key", "hello".to_string(), cx); - // L12: `with_query_data` lends `&T` to the closure with NO clone of - // `T` (unlike `get_query_data`, which returns an owned `T`). The - // closure computes the length, so we never own/clone the `String`. + // `with_query_data` lends `&T` to the closure; no clone of the + // `String` ever happens (unlike `get_query_data`). let len = client.with_query_data::<String, QueryError, usize>( &QueryKey::from("len_key"), cx, diff --git a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs index d349e14..81d5ef5 100644 --- a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs +++ b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs @@ -148,21 +148,12 @@ fn test_reset_queries_prefix_preserves_non_matching(cx: &mut TestAppContext) { // ── 6. GC evicts stale Idle/Failure/Success resources ─────────────────── // -// GC reads live entity state directly via `entity.read(cx)` (CL2/#106); no -// cached snapshot is involved. For deterministic tests, we drive resources -// to a known status / `last_updated_ms` via direct entity updates before -// calling `gc_with_time()`, then assert the expected outcome unconditionally. -// -// gc_time_ms=1000 means: MIN_GC_TIME_MS=1000 (enforced floor), so -// - Idle/Failure: evicted when age >= gc_threshold (1000ms) -// - Success: evicted when age >= success_threshold (2 * 1000 = 2000ms) -// - Loading: never evicted (regardless of age) -// - No snapshot (last_updated_ms=None): age defaults to gc_threshold, evicted -// -// NOTE: The tests below cover the basic eviction paths (idle with no snapshot, -// Failure/Success with snapshots, Loading preserved). For more comprehensive -// GC coverage including snapshot-bearing resources in all statuses with varied -// cache policies and edge-case timing, see `coverage_gaps.rs`. +// GC reads live entity state via `entity.read(cx)`, so tests drive resources +// to a known status / timestamp with direct entity updates, then call +// `gc_with_time()` and assert the outcome. With gc_time_ms=1000 (the enforced +// floor): Idle/Failure evict at age >= 1000ms, Success at age >= 2000ms, +// Loading is never evicted, and a missing timestamp counts as fully aged. +// More GC edge cases live in `coverage_gaps/gc_eviction.rs`. #[gpui::test] fn test_gc_evicts_idle_resources_with_no_snapshot(cx: &mut TestAppContext) { @@ -195,8 +186,6 @@ fn test_gc_evicts_failure_resources_after_gc_time(cx: &mut TestAppContext) { let key = QueryKey::from("fail_key"); // Drive the resource to Failure at a controlled timestamp (t=1000). - // GC reads live entity state (audit #CL2), so we set `last_updated_at` - // directly via `apply_failure` instead of faking a snapshot. let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| { r.apply_failure(QueryError::response("broken"), 1_000) @@ -251,8 +240,7 @@ fn test_gc_preserves_loading_resources_regardless_of_age(cx: &mut TestAppContext cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("loading_key"); - // Start a fetch via the public API but don't complete it. GC reads - // the live LoadingEmpty status (audit #CL2) — no snapshot needed. + // Start a fetch via the public API but don't complete it. let prepared = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) .expect("should start"); diff --git a/crates/gpui-query/src/tests/integration_client/mod.rs b/crates/gpui-query/src/tests/integration_client/mod.rs index 9817d58..e3272de 100644 --- a/crates/gpui-query/src/tests/integration_client/mod.rs +++ b/crates/gpui-query/src/tests/integration_client/mod.rs @@ -1,28 +1,17 @@ -//! Integration tests for the QueryClient layer (v2). +//! Integration tests for the QueryClient layer. //! -//! Tests use `#[gpui::test]` with `TestAppContext` and the `test_support` helpers. -//! They exercise the full client API: resource creation, type partitioning, -//! invalidation, reset, GC, mutations, diagnostics, signals, data access, -//! and observers. +//! Tests use `#[gpui::test]` with `TestAppContext` and the `test_support` +//! helpers, exercising the full client API: resource creation, type +//! partitioning, invalidation, reset, GC, mutations, diagnostics, signals, +//! data access, and observers. //! -//! # Context pattern +//! All tests use `cx.update_global::<QueryClient, _>(|client, cx| ...)`: +//! methods like `resource()` need `&mut self` and `&mut App`, so the +//! immutable `cx.global()` cannot be used. //! -//! All tests use `cx.update_global::<QueryClient, _>(|client, cx| ...)` to -//! get `(&mut QueryClient, &mut App)`. Methods like `resource()` require -//! `&mut self` and `&mut App`, so `cx.global()` (immutable) cannot be used. -//! -//! # GC test design -//! -//! The bucket's GC reads live entity state directly via `entity.read(cx)` -//! (CL2/#106 removed the cached `StatusSnapshot`). Direct entity -//! manipulation (`apply_success`, etc.) and `PreparedFetch` completions -//! are therefore visible to GC immediately, with no separate snapshot -//! refresh step. -//! -//! For deterministic GC tests, we drive resources to a known status and -//! `last_updated_ms` via direct entity updates (e.g. `apply_success`), -//! then call `gc_with_time()` and assert the expected eviction / -//! preservation behavior without the hook layer. +//! GC reads live entity state via `entity.read(cx)`, so tests drive +//! resources to a known status / timestamp with direct entity updates +//! (e.g. `apply_success`) before calling `gc_with_time()`. mod client_basics; mod data_access; diff --git a/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs b/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs index 7804074..aa83757 100644 --- a/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs +++ b/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs @@ -115,9 +115,8 @@ fn test_full_lifecycle_idle_to_loading_to_success_to_gc(cx: &mut TestAppContext) .expect("entity should exist"); assert!(entity.read(cx).is_loading()); - // 2. Complete with success at a controlled timestamp (t=1000) so GC - // age is deterministic. GC reads live entity state (audit #CL2), - // so we set `last_updated_at` directly instead of faking a snapshot. + // 2. Complete with success at a controlled timestamp (t=1000) so + // the GC age is deterministic. entity.update(cx, |r, _| r.apply_success("Carol".to_string(), 1_000)); assert_eq!(entity.read(cx).status(), QueryStatus::Success); assert_eq!(entity.read(cx).data().unwrap(), "Carol"); diff --git a/crates/gpui-query/src/tests/test_support.rs b/crates/gpui-query/src/tests/test_support.rs index f4a85e7..b55ad49 100644 --- a/crates/gpui-query/src/tests/test_support.rs +++ b/crates/gpui-query/src/tests/test_support.rs @@ -1,16 +1,5 @@ -//! Shared test infrastructure for gpui-query. -//! -//! Provides: -//! - [`TestAppContext`] setup helpers via [`setup_test`] / [`setup_query_client`] -//! - [`QueryClient`] as a [`Global`] for tests via [`setup_query_client`] -//! - Core resource constructors: [`test_resource`], [`test_resource_with_policies`], -//! [`resource_with_sequencer`] -//! - Assertion helpers: [`assert_status`], [`begin_request_id`] -//! - Cache/mutation option factories: [`no_retry_mutation_options`] -//! - Async test helpers: [`Gate`], [`run_until_parked_and_read`], -//! [`observe_with_dummy_view`], [`DummyView`] -//! -//! # Usage +//! Shared test infrastructure: `TestAppContext`/`QueryClient` setup helpers, +//! core resource constructors, assertion helpers, and async test utilities. //! //! ```ignore //! use crate::tests::test_support::*; @@ -109,29 +98,9 @@ pub fn test_sequencer() -> RequestSequencer { RequestSequencer::new() } -/// Create a fresh resource paired with a new [`RequestSequencer`]. -/// -/// Returns `(QueryResource, RequestSequencer)` so tests can immediately call -/// `begin_request(&mut r, &mut seq, now, mode)` without boilerplate. The -/// sequencer is a fresh `RequestSequencer::new()`. -/// -/// Uses `CachePolicy::NoCache` so every `begin_request` returns `Started` -/// (never `CacheHit`), giving deterministic control over each fetch lifecycle -/// step without worrying about TTL freshness windows. -#[expect(dead_code, reason = "kept as a shared lifecycle-test helper")] -pub fn resource_with_sequencer( - key: impl Into<QueryKey>, -) -> (QueryResource<&'static str>, RequestSequencer) { - ( - QueryResource::new(key, CachePolicy::NoCache, RequestPolicy::LatestWins), - RequestSequencer::new(), - ) -} - // ── Assertion helpers ────────────────────────────────────────────────── /// Assert that a resource has the expected status. -// Audit fix #123: removed `#[allow(dead_code)]` — used by request_policy/lifecycle tests. pub fn assert_status(resource: &QueryResource<impl Clone, impl Clone>, expected: QueryStatus) { let actual = resource.status(); assert_eq!( @@ -156,7 +125,6 @@ pub fn nocache_resource(key: impl Into<QueryKey>) -> QueryResource<&'static str> /// /// Convenience alias for [`nocache_resource`] with key `"invariant-test"`. /// Every `begin_request` on this resource will return `Started` (never `CacheHit`). -// Audit fix #123: removed `#[allow(dead_code)]` — used by coverage_gaps tests. pub fn fresh_resource() -> QueryResource<&'static str> { nocache_resource("invariant-test") } @@ -187,7 +155,7 @@ pub fn begin_request_id( /// Accept the current request by `request_id` and complete it with success. /// /// Convenience wrapper around [`QueryResource::complete_current_success`] -/// (audit fix #85). Mirrors [`begin_request_id`] so tests that just need to +/// Mirrors [`begin_request_id`] so tests that just need to /// drive a request through to `Success` can do so in one call without /// repeating the `(request_id, data, now_ms)` triple inline. /// @@ -259,9 +227,9 @@ where /// A minimal generic test harness that owns a single entity handle. /// -/// Audit fix #47: many hook-layer tests define a one-off `struct H { entity: -/// Entity<...> }` purely to host hook calls via `cx.new(|cx| ...)` and later -/// inspect the entity. [`HookHarness`] replaces that boilerplate for the common +/// Many hook-layer tests define a one-off `struct H { entity: Entity<...> }` +/// purely to host hook calls via `cx.new(|cx| ...)` and later inspect the +/// entity. [`HookHarness`] replaces that boilerplate for the common /// single-entity case: tests construct `cx.new(|cx| HookHarness::new(entity))` /// and read back via `harness.read(cx).entity.read(cx)`. /// @@ -414,12 +382,10 @@ pub struct Post; // ── Time helpers ─────────────────────────────────────────────────────── /// A fixed "now" timestamp for deterministic cache tests (ms since UNIX epoch). -// Audit fix #123: removed `#[allow(dead_code)]` — used by request_policy/lifecycle tests. pub const TEST_NOW_MS: u64 = 1_000_000; /// Assert that every value in `cases` survives a JSON serialize -> deserialize -/// roundtrip unchanged. Shared helper for the serde-roundtrip tests (extends -/// audit #129). +/// roundtrip unchanged. pub fn assert_serde_roundtrip<T>(cases: &[T]) where T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, From 0d00ab627239731914f530548a2fcd89cc36a454 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 13:38:27 +0200 Subject: [PATCH 023/111] refactor: consolidate duplicated hook/fetch impls in hook layer - collapse use_query/use_query_with_policy into a private use_query_impl generic over FetchedLike<T>; merge fetch_query/fetch_query_with_policy into fetch_query_impl - unify mutation begin_and_spawn paths (legacy Fn(V) adapter preserves the single V::clone-per-attempt semantics); delete dead legacy runners run_mutation_loop/run_mutation_loop_with_callbacks; dedupe four identical callback blocks via fire_error_callbacks - strip audit/history narration from hook sources and scoped tests; tighten pub docs to 1-3 lines, doctests kept passing (hook scope 10/10) - fix the 10 hook/* rustdoc warnings: unresolved QuerySignal/CachePolicy/ QueryOptions links and links to private begin_and_spawn items - reuse the destructured retry_policy local in use_query_impl (one fewer entity read per hook call) No public API changes; all restructuring is private. Gates on the commit tree: cargo test --all-features 819/0 (zero delta), clippy -D warnings clean, cargo doc warnings 12->2 (sole survivor is persist.rs, area 4 scope). --- crates/gpui-query/src/hook/fetch_retry.rs | 205 ++-------- crates/gpui-query/src/hook/mod.rs | 92 +---- .../src/hook/mutation_hooks/hooks.rs | 259 ++++--------- .../src/hook/mutation_hooks/internals.rs | 230 +++-------- .../gpui-query/src/hook/mutation_hooks/mod.rs | 11 +- crates/gpui-query/src/hook/options.rs | 140 ++----- crates/gpui-query/src/hook/query_hooks.rs | 360 +++++------------- .../hook/use_infinite_query/fetch_helpers.rs | 63 +-- .../hook/use_infinite_query/fetch_runners.rs | 98 ++--- .../src/hook/use_infinite_query/hook.rs | 105 +---- .../gpui-query/src/hook/use_query_select.rs | 107 +----- .../hook_tests/mutation_tests/basic_tests.rs | 9 +- .../hook_tests/query_tests/advanced_hooks.rs | 7 +- .../src/tests/hook_tests/regression_tests.rs | 118 +++--- .../client_gap_coverage/gc_coverage.rs | 85 ++--- .../client_gap_coverage/hook_coverage.rs | 19 +- .../client_mutations.rs | 4 - .../fetch_prefetch_cancel.rs | 25 +- .../client_operations/gc_query_operations.rs | 23 +- .../property_tests/query_key/proptests.rs | 13 +- .../property_tests/query_key/strategies.rs | 7 +- 21 files changed, 491 insertions(+), 1489 deletions(-) diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index 55e7756..becdc3a 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -10,20 +10,8 @@ use crate::core::{ use super::{current_time_ms, read_entity}; -// ── Fetcher-success adapter ("server wins") ───────────────────────────── -// -// Lets the single retry loop below serve both fetcher shapes: -// - `Result<T, E>` (plain) → no policy override -// - `Result<Fetched<T>, E>` (`*_with_policy`) → optional server-derived policy -// -// The loop is generic over `Out: FetchedLike<T>`; the success arm extracts -// `(data, server_policy)` from the fetcher result and, when a server policy is -// present, applies it to the resource after `complete_success`. A plain `T` -// yields `(self, None)`, so the existing fetch paths are behaviorally identical. - -/// Decomposed fetcher success: the underlying data, an optional server-derived -/// [`CachePolicy`], and (under `persist`) optional opaque metadata sourced from -/// [`Fetched::meta`](crate::core::Fetched). +/// Decomposed fetcher success: the data, an optional server-derived +/// [`CachePolicy`], and (under `persist`) optional opaque metadata. pub(crate) struct FetchParts<T> { pub data: T, pub server_policy: Option<CachePolicy>, @@ -31,10 +19,9 @@ pub(crate) struct FetchParts<T> { pub meta: Option<serde_json::Value>, } -/// Adapt a fetcher success payload into its decomposed [`FetchParts`]. +/// Adapt a fetcher success payload into [`FetchParts`]. Plain `T` yields no +/// server policy; [`Fetched<T>`] carries both optional extras. pub(crate) trait FetchedLike<T> { - /// Consume `self` into [`FetchParts`]; `server_policy` (and `meta` under - /// `persist`) are `None` for plain fetcher results. fn into_parts(self) -> FetchParts<T>; } @@ -60,52 +47,18 @@ impl<T> FetchedLike<T> for Fetched<T> { } } -// ── Request lifecycle helpers ─────────────────────────────────────────── - -/// Call `begin_request` on a query entity. -/// -/// This transitions the resource to a Loading status, creates a fresh signal, -/// and returns `Some(RequestId)` that must be used for completion. -/// -/// Returns `None` when the resource does not need fetching (cache hit, ignored -/// while loading). The caller should skip spawning the async fetch task when -/// this returns `None`. -/// -/// Audit fix #62: For `QueryFetchMode::Normal`, uses the new -/// `QueryResource::try_begin_request(now_ms)` (core contract #4) to perform -/// the cache-freshness / `IgnoreWhileLoading` check and the `Loading` -/// transition atomically inside a single `entity.update`. This closes the -/// read-then-update race window where a concurrent caller could change state -/// between the check and the begin. The `CacheHit` / `Started` / -/// `StaleCacheHit` / `IgnoredWhileLoading` distinction is preserved via -/// [`QueryBeginResult`]. +/// Begin a request on a query entity: runs the cache-freshness / +/// `IgnoreWhileLoading` check and the `Loading` transition atomically in one +/// `entity.update`, and reads the freshly created signal in the same pass. /// -/// Audit fix #2: Accepts an optional `known_key` so callers that already hold -/// the key avoid re-reading it from the entity; otherwise it is read once here. +/// Returns `(Some(request_id), Some(signal))` when a fetch should be spawned; +/// `(None, None)` on `CacheHit` / `IgnoredWhileLoading` (skip the fetch). /// -/// Audit H3: the result now also carries the `Option<QuerySignal>` that -/// `begin_request` just created, read from the SAME `entity.update` closure. -/// Callers previously did a SEPARATE `entity.read_with(|r, _| r.signal()…)` -/// afterwards — this fuses those two reads into one. The returned signal is the -/// one `begin_request` just created (Started/StaleCacheHit); `CacheHit` / -/// `IgnoredWhileLoading` return `None` for both slots, matching the prior -/// "skip the fetch" semantics. -/// -/// `RequestId` source: when a `QueryClient` global is available, the bucket's -/// co-located sequencer mints a monotonic `RequestId` (shared with the -/// imperative `prepare_fetch_query` path, so the two never collide); otherwise -/// `begin_request_with_id` falls back to the resource's own stored sequencer -/// (the N3 fix, monotonic per-resource). Done for BOTH fetch modes so -/// Normal-mode fetches also receive unique, monotonic ids. -/// -/// Note (audit H5 reverted): an earlier pass minted lazily from the resource's -/// own sequencer even when a `QueryClient` was present. That split the ID space -/// from the imperative fetch path — both sequencers start at scope 1 — so a -/// hook fetch and an imperative `prepare_fetch_query` on the same key could -/// both mint `RequestId(1,1)` and defeat stale-request rejection. The bucket -/// sequencer is restored here; the minor cost is one consumed sequence number -/// on `CacheHit` / `IgnoredWhileLoading` paths, which is not worth the -/// correctness risk. +/// When a [`QueryClient`] global is present, the bucket's co-located sequencer +/// mints the `RequestId` (shared with the imperative `prepare_fetch_query` +/// path so the two never collide for the same key); otherwise +/// `begin_request_with_id` falls back to the resource's own monotonic +/// sequencer. `known_key` spares callers that already hold the key a re-read. pub(crate) fn begin_request_on_entity<T, E, C>( entity: &Entity<QueryResource<T, E>>, cx: &mut Context<C>, @@ -119,10 +72,6 @@ where { let now_ms = current_time_ms(); - // Use the bucket's co-located sequencer for a monotonic `RequestId` when a - // QueryClient is available; otherwise fall back to the resource's stored - // sequencer inside `begin_request_with_id` (N3). Shared with the imperative - // fetch path, so ids never collide across hook/imperative for the same key. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = known_key.unwrap_or_else(|| entity.read_with(cx, |r, _| r.key().clone())); cx.update_global::<QueryClient, _>(|client, _cx| { @@ -132,60 +81,28 @@ where None }; - // Audit fix #62: the cache-freshness / IgnoreWhileLoading check and the - // Loading transition happen atomically inside this single `entity.update` - // closure, so a concurrent caller cannot slip a state mutation in between - // the check and the begin. - // - // Audit H3: ALSO read `resource.signal().cloned()` inside the SAME closure - // for the Started / StaleCacheHit branches (the ones that return a real - // `RequestId`), so callers do not need a second read pass to obtain the - // signal. For `Started` this is the signal `begin_request` just created; - // for the `IgnoreWhileLoading`-active `StaleCacheHit` sub-branch - // `begin_request` returns early and reuses the active request, so this is - // that existing signal — in both cases the correct one for the caller to - // poll for cancellation. entity.update(cx, |resource, _cx| { match resource.begin_request_with_id(maybe_request_id, now_ms, fetch_mode) { - QueryBeginResult::Started { request_id, .. } => { + QueryBeginResult::Started { request_id, .. } + | QueryBeginResult::StaleCacheHit { request_id, .. } => { let signal = resource.signal().cloned(); (Some(request_id), signal) } - QueryBeginResult::StaleCacheHit { request_id, .. } => { - let signal = resource.signal().cloned(); - (Some(request_id), signal) + QueryBeginResult::CacheHit | QueryBeginResult::IgnoredWhileLoading { .. } => { + (None, None) } - QueryBeginResult::CacheHit => (None, None), - QueryBeginResult::IgnoredWhileLoading { .. } => (None, None), } }) } -// ── Retry-aware fetch helpers ─────────────────────────────────────────── - -/// Unified retry loop for query fetches. +/// Single retry loop shared by every query fetch shape. /// -/// Audit fix #14: The previous `fetch_with_retry` and `fetch_signal_with_retry` -/// were ~90% duplicated (only signal-handling differed). Both now delegate to -/// this single loop parameterized by `signal: Option<QuerySignal>`: -/// - `None` → the no-signal variant (`Fn() -> Fut` fetcher wrapped to ignore -/// the signal slot; no fresh-signal re-read on retry). -/// - `Some(initial)` → the signal variant (`Fn(QuerySignal) -> Fut` fetcher -/// wrapped to unwrap the slot; fresh signal re-read after each retry delay). -/// -/// Audit fix #6: After each retry delay, checks whether the request has been -/// cancelled (e.g., by a newer `begin_request` under `LatestWins`). If so, -/// breaks out of the retry loop immediately to avoid unnecessary work. -/// -/// Audit fix #7: `cx.notify()` is only called when `accept_current_request` -/// succeeds (i.e., the result is actually accepted by the current request -/// slot). Discarded results do not trigger a re-render. -/// -/// Audit fix #24: `unwrap_or_else(QuerySignal::new)` instead of the -/// redundant-closure `unwrap_or_else(|| QuerySignal::new())`. -/// -/// Audit fix #27/#121: `entity.update` results are discarded via `let _ =` -/// because `update` returns `Result<R>` under `AsyncApp`. +/// `signal` is `None` for signal-less fetchers; `Some(initial)` re-reads a +/// fresh signal from the resource after each retry delay. After each delay the +/// loop checks `is_current_request` and stops early if a newer request has +/// superseded this one. `cx.notify()` fires only when a result is actually +/// accepted, and `entity.update` results are discarded because `update` +/// returns `Result<R>` under `AsyncApp`. async fn run_query_retry_loop<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, @@ -207,37 +124,26 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( match result { Ok(out) => { - // Server wins: extract any server-derived policy (and, under - // `persist`, opaque metadata) from a `Fetched<T>` result. Plain - // `T` results yield `None` for both. let parts = out.into_parts(); - let data = parts.data; - let server_policy = parts.server_policy; #[cfg(feature = "persist")] let meta = parts.meta; let now_ms = current_time_ms(); let Some(e) = entity.upgrade() else { - // Documented behavior -- if the owning component - // was unmounted, the result is silently discarded. + // Owning component unmounted: result is silently discarded. return; }; let _ = e.update(cx, |resource, cx| { resource.reset_retry_count(); if let Some(guard) = resource.accept_current_request(request_id) { - resource.complete_success(guard, data, now_ms); - // Server wins: override the resource's policy only when - // the fetcher supplied one. No-op for plain `T` results. - if let Some(policy) = server_policy { + resource.complete_success(guard, parts.data, now_ms); + // Server wins: a fetcher-supplied policy overrides the + // resource's stored one. + if let Some(policy) = parts.server_policy { resource.set_cache_policy(policy); } - // Audit fix #7: Only notify when the result was actually accepted. cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - // L1: carry fetcher-supplied opaque metadata (e.g. an - // HTTP CacheMeta) into the persistence layer's per-key - // map so it round-trips through PersistedEntry.meta. #[cfg(feature = "persist")] if let Some(meta) = meta { let key = resource.key().clone(); @@ -259,12 +165,10 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( if retry_policy.should_retry(attempt) { let delay_ms = retry_policy.delay_for_attempt(attempt); let Some(e) = entity.upgrade() else { return }; + // No notify: retry counters do not change status (stays + // Loading), and the observer dedupes on status. let _ = e.update(cx, |resource, _cx| { resource.increment_retry(); - // No cx.notify() here -- increment_retry does not change - // status (stays Loading). The QueryObserver handles - // status-deduplication so this update does not trigger - // a re-render. }); attempt += 1; @@ -274,23 +178,14 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( .await; } - // Audit fix #6: After the retry delay, check whether the - // request has been cancelled (e.g., by a newer begin_request - // under LatestWins). If so, stop retrying immediately. - // Audit H6: fuse the cancellation check and the fresh-signal - // re-read into a single read_entity pass (was two sequential - // reads). `fresh_signal` is computed unconditionally but only - // used when `signal` is `Some` (audit fix #14). let Some(e) = entity.upgrade() else { return }; let (request_still_active, fresh_signal) = read_entity(&e, cx, |r, _| { ( r.is_current_request(request_id), - // Audit fix #24: `QuerySignal::new` directly instead - // of the redundant closure. r.signal().cloned().unwrap_or_else(QuerySignal::new), ) }) - .unwrap_or((false, QuerySignal::new())); + .unwrap_or_else(|| (false, QuerySignal::new())); if !request_still_active { #[cfg(debug_assertions)] eprintln!( @@ -299,27 +194,18 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( ); return; } - - // Audit fix #14: Only the signal variant re-reads a fresh - // signal after the retry-delay cancellation check. The - // no-signal variant leaves `signal` as `None` forever. if let Some(ref mut sig) = signal { *sig = fresh_signal; } - // Loop to retry } else { - // No more retries -- complete with failure let Some(e) = entity.upgrade() else { return }; let failure_now_ms = current_time_ms(); let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.complete_failure(guard, error, failure_now_ms); - // Audit fix #4: Reset retry_count on terminal failure so the - // resource is clean for the next begin_request. + // Reset so the next begin_request starts clean. resource.reset_retry_count(); - // Audit fix #7: Only notify when the result was actually accepted. cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); } else { @@ -337,18 +223,8 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( } } -/// Execute a fetch with retry logic for a query resource (no-signal variant). -/// -/// Calls the fetcher. On failure, if the retry policy allows it, waits for the -/// configured delay and retries. Updates the entity state between attempts. -/// Resets the retry counter on success. -/// -/// Takes an explicit `request_id` parameter obtained from -/// `begin_request_on_entity`. Callers should only invoke this when -/// `begin_request_on_entity` returns `Some(request_id)`. -/// -/// Audit fix #14: Thin wrapper over [`run_query_retry_loop`] with -/// `signal = None`. +/// Fetch with retry for a query resource (no-signal fetcher): a thin wrapper +/// over [`run_query_retry_loop`] with `signal = None`. pub(crate) async fn fetch_with_retry<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, @@ -368,13 +244,8 @@ pub(crate) async fn fetch_with_retry<T, E, Out, F, Fut>( } /// Like [`fetch_with_retry`] but for fetchers that take a [`QuerySignal`]. -/// -/// On retry, reads a fresh signal from the resource entity and passes it to -/// the fetcher. The signal is properly cancelled when a new request replaces -/// the current one (v2 fix). -/// -/// Audit fix #14: Thin wrapper over [`run_query_retry_loop`] with -/// `signal = Some(initial_signal)`. +/// On retry, a fresh signal is read from the resource and handed to the +/// fetcher. pub(crate) async fn fetch_signal_with_retry<T, E, Out, F, Fut>( fetcher: F, initial_signal: QuerySignal, diff --git a/crates/gpui-query/src/hook/mod.rs b/crates/gpui-query/src/hook/mod.rs index eda1aa2..3a853cc 100644 --- a/crates/gpui-query/src/hook/mod.rs +++ b/crates/gpui-query/src/hook/mod.rs @@ -1,19 +1,11 @@ -//! The `use_query` and `use_mutation` hooks — ergonomic query and mutation -//! subscriptions for GPUI components. +//! `use_query` and `use_mutation` hooks: query and mutation subscriptions for +//! GPUI components. //! -//! # v2 Improvements +//! The primary API is options-first. The fetcher always receives a +//! [`QuerySignal`](crate::core::QuerySignal) for cooperative cancellation. +//! `use_query_unsignalled` remains for callers that want a signal-free fetcher. //! -//! - Uses `QueryObserver` which returns `Option<Subscription>` instead of panicking -//! - Signals are properly cancelled on `LatestWins` replacement and `reset()` -//! - `AHashMap` in `QueryClient` for faster lookups -//! - `MutationDiagnostic` is a real type in devtools -//! - `max_pages` defaults to `Some(50)` -//! - `QueryError` has full `Display` + `Error` impls -//! -//! # Query Usage (options-first) -//! -//! The primary API is **options-first** with sensible defaults. The fetcher -//! always receives a [`QuerySignal`] for cooperative cancellation: +//! # Query usage //! //! ```no_run //! use gpui_query::hook::use_query; @@ -45,11 +37,7 @@ //! } //! ``` //! -//! For backward compatibility, [`use_query_unsignalled`] is available with a -//! `Fn() -> Fut` fetcher that receives no signal. However, the signal-accepting -//! `use_query` is the recommended default per the v2 "Signal-always" design goal. -//! -//! # Mutation Usage +//! # Mutation usage //! //! ```no_run //! use gpui_query::hook::{use_mutation, mutate}; @@ -79,22 +67,14 @@ //! } //! ``` //! -//! # WeakEntity Discard Behavior -//! -//! Throughout this module, [`gpui::WeakEntity::upgrade()`] is used inside async -//! tasks to access the owning entity. If the owning component is unmounted while -//! a fetch is in-flight, `upgrade()` returns `None` and the fetch result is -//! **silently discarded**. This is intentional for cache-layer correctness (avoids -//! writing to a dead entity), but callers who rely on side effects from fetch -//! completion should be aware that no callback or notification fires in this case. +//! # Discard behavior //! -//! # Signal Cancellation (Audit Finding #8) -//! -//! The `accept_current_request` guard is the authoritative protection against stale -//! writes. A previous `signal.is_cancelled()` check after the fetcher returned was -//! removed -- it was a best-effort optimization with a TOCTOU window that provided -//! no guarantees. The two-phase protocol (accept + complete) correctly handles all -//! cases where a newer request supersedes the current one. +//! Async tasks here access the owning entity through +//! [`gpui::WeakEntity::upgrade()`]. If the component unmounts while a fetch is +//! in-flight, `upgrade()` returns `None` and the result is silently discarded: +//! no callback or notification fires. Stale writes are prevented by the +//! two-phase `accept_current_request` + complete protocol rather than by +//! aborting tasks. mod fetch_retry; mod gpui_compat; @@ -108,60 +88,30 @@ mod use_query_select; // `R` (older gpui / git) or `Result<R>` (gpui 0.2.2 / crates.io). pub(crate) use gpui_compat::read_entity; -// ── Re-exports from options ───────────────────────────────────────────── - pub use options::{InfiniteQueryOptions, MutationCallbacks, MutationOptions, QueryOptions}; -// ── Re-exports from query_hooks ───────────────────────────────────────── - pub use query_hooks::{ fetch_query, fetch_query_with_policy, fetch_query_with_signal, use_query, use_query_manual, use_query_manual_opts, use_query_unsignalled, use_query_unsignalled_opts, use_query_with_policy, }; -// ── Re-exports from use_infinite_query ─────────────────────────────────── - pub use use_infinite_query::{ fetch_next_page_infinite, fetch_previous_page_infinite, use_infinite_query, }; -// ── Re-exports from use_query_select ───────────────────────────────────── - pub use use_query_select::use_query_select; -// ── Re-exports from mutation_hooks ─────────────────────────────────────── -// -// Audit fix #22: `use_mutation_with_options` is intentionally NOT re-exported -// here. The deprecated function itself remains defined (and delegates to -// `use_mutation`), but removing it from the `pub use` list stops the -// `deprecated` lint from firing on the re-export. Existing callers that -// import it via the full path still see the deprecation warning at the call -// site. - +// `use_mutation_with_options` is deprecated and deliberately not re-exported; +// callers that reach it via the full path still get the deprecation warning. pub use mutation_hooks::{ mutate, mutate_arc, mutate_by_ref, mutate_with_callbacks, use_mutation, use_mutation_state, }; -// ── Utility ───────────────────────────────────────────────────────────── - -/// Returns current time as milliseconds since UNIX epoch. +/// Current time as milliseconds since the UNIX epoch. /// -/// Audit fix #20: This is the canonical implementation used across the hook -/// layer. The private duplicate in `mutation_bucket.rs` (`now_ms`) should -/// ideally be consolidated here or into a shared utility module. -/// -/// # Clock-before-epoch fallback -/// -/// `duration_since(UNIX_EPOCH)` errors if the system clock reports a time -/// *before* the Unix epoch (e.g. a misconfigured RTC or a clock skewed -/// backwards on cold boot). The `.unwrap_or_default()` silently clamps that -/// case to a `Duration::ZERO`, so this function returns `0`. Callers treat -/// `0` as "ancient", which makes the only observable effect under a broken -/// clock be that stale entries become immediately eligible for garbage -/// collection; no panic or error is propagated. This mirrors the silent-clamp -/// behavior of the `current_time_ms` in `client::erased` so both clock -/// sources stay consistent. +/// A clock that reports a time before the epoch clamps to `0`, which callers +/// treat as "ancient" (stale). No panic is propagated. #[inline] pub fn current_time_ms() -> u64 { std::time::SystemTime::now() @@ -170,9 +120,7 @@ pub fn current_time_ms() -> u64 { .as_millis() as u64 } -// ── Impl for MutationOptions integration ──────────────────────────────── - -/// Allow `use_mutation((), cx)` to work with default options. +/// Lets `use_mutation((), cx)` use the default options. impl From<()> for MutationOptions { fn from((): ()) -> Self { Self::default() diff --git a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs index 61ccd19..8c62ed9 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs @@ -10,35 +10,19 @@ use crate::core::MutationResource; use super::super::MutationOptions; use super::super::options::MutationCallbacks; -use super::internals::{ - run_mutation_loop, run_mutation_loop_by_ref, run_mutation_loop_by_ref_with_callbacks, - run_mutation_loop_with_callbacks, -}; +use super::internals::{run_mutation_loop_by_ref, run_mutation_loop_by_ref_with_callbacks}; /// Hook for executing mutations (create, update, delete operations). /// -/// Creates a [`MutationResource`] entity. Returns the entity and a subscription -/// for state observation during render. Use the [`mutate`] helper to trigger the -/// mutation from event handlers. +/// Creates a [`MutationResource`] entity and returns it with a subscription +/// for state observation during render. Trigger it with [`mutate`] from event +/// handlers. Accepts `impl Into<MutationOptions>`, so both `use_mutation((), cx)` +/// and `use_mutation(MutationOptions::default(), cx)` work. /// -/// Accepts `impl Into<MutationOptions>` so both `use_mutation((), cx)` (using -/// `Default` via `From<()>`) and `use_mutation(MutationOptions { .. }, cx)` -/// work. -/// -/// Audit fix #1/#11: Uses `MutationObserver` with status-deduplication instead -/// of a raw `cx.observe`. The observer only calls `cx.notify()` when the -/// mutation's `MutationStatus` actually changes (Idle -> Loading, Loading -> -/// Success, Loading -> Failure). Intermediate updates like `increment_retry()` -/// and `prepare_retry()` do not change status (stays Loading), so they no -/// longer trigger re-renders. -/// -/// Audit fix #17: Registers the mutation entity with the global [`QueryClient`] -/// so that `use_mutation_state` returns it, GC is triggered, and -/// `MutationOptions::gc_time_ms` is respected. -/// -/// Audit fix #29: Replaces the production `.expect()` on -/// `MutationObserver::observe` with a `debug_assert!` + safe fallback so -/// production builds never panic on a GPUI internal regression. +/// The observer dedupes on `MutationStatus`: intermediate updates like +/// `increment_retry()` stay in Loading and do not trigger re-renders. The +/// entity is registered with the global [`QueryClient`] so `use_mutation_state` +/// finds it and GC respects `gc_time_ms`. /// /// # Example /// @@ -81,35 +65,24 @@ where E: Clone + Send + Sync + 'static, C: 'static, { - // Audit H8: move retry_policy out of opts instead of cloning (opts is not - // used afterwards). let opts = options.into(); - let retry_policy = opts.retry_policy; - let entity = cx.new(|_| MutationResource::new(retry_policy)); + let entity = cx.new(|_| MutationResource::new(opts.retry_policy)); - // Audit fix #1/#11: Use MutationObserver with status-deduplication instead - // of raw cx.observe. The observer only calls cx.notify() when MutationStatus - // actually changes, preventing excessive re-renders from increment_retry() - // and prepare_retry() calls that don't change status (stays Loading). - let m_observer = MutationObserver::new(&entity); - // Audit fix #29: debug_assert + safe fallback instead of .expect() so a - // GPUI internal regression does not panic production builds. - let subscription = match m_observer.observe(cx) { + let observer = MutationObserver::new(&entity); + let subscription = match observer.observe(cx) { Some(sub) => sub, None => { + // The entity was just created, so this only fires on a GPUI + // internal regression. Do not panic production builds. debug_assert!( false, "MutationObserver::observe failed: entity was just created and \ cannot be dropped. This indicates a GPUI internal regression." ); - // Return a no-op subscription so the caller can continue. Subscription::new(|| {}) } }; - // Audit fix #17: Register the mutation entity with the global QueryClient so - // that use_mutation_state returns it, GC is triggered, and gc_time_ms - // is respected. if cx.has_global::<QueryClient>() { cx.update_global::<QueryClient, _>(|client, cx| { client.register_mutation(&entity, cx); @@ -119,20 +92,14 @@ where (entity, subscription) } -/// Hook for executing mutations with a custom retry policy. -/// -/// Audit fix #68 / CL7 (#111): This deprecated entrypoint now delegates to -/// [`use_mutation`] so it also registers the entity with the global -/// [`QueryClient`] (the previous implementation skipped registration, leaving -/// the mutation invisible to `use_mutation_state` and GC). Keeping the -/// `#[deprecated]` attribute preserves the source-compat migration path. +/// Hook for executing mutations with a custom retry policy. Deprecated alias +/// of [`use_mutation`], which now accepts `MutationOptions` directly. #[deprecated( since = "0.2.0", note = "Use `use_mutation(options, cx)` instead — it now accepts MutationOptions via Into" )] -// Intentionally retained (exercised by `test_deprecated_use_mutation_with_options_still_works`) -// but NOT re-exported from the crate root (audit #22). Flagged dead in the -// lib-only build because no non-test caller reaches it. +// Retained for the deprecated source-compat path and exercised by +// `test_deprecated_use_mutation_with_options_still_works`. #[allow(dead_code)] pub fn use_mutation_with_options<V, T, E, C>( options: &MutationOptions, @@ -147,12 +114,9 @@ where use_mutation(options.clone(), cx) } -/// Hook to observe all mutation state across the application for a given -/// `(V, T, E)` type triple. -/// -/// Returns a snapshot of all [`MutationResource`] entities of the specified -/// types registered in the global [`QueryClient`]. Returns an empty vec -/// if no mutations exist for this type or if no `QueryClient` is set up. +/// Observe all mutation state across the application for a given +/// `(V, T, E)` type triple. Returns an empty vec if no mutations of this type +/// exist or no [`QueryClient`] is set up. /// /// # Example /// @@ -190,34 +154,15 @@ where /// Trigger a mutation on an existing mutation entity. /// -/// This is the primary way to execute mutations. It: -/// 1. Transitions the entity to Loading with the given variables -/// 2. Spawns an async task calling the mutator -/// 3. On success, completes with the result data -/// 4. On failure, retries according to the entity's retry policy -/// -/// Audit fix #8/#7: Guards against concurrent calls by checking whether the -/// mutation is already in Loading state *inside the same `entity.update` that -/// calls `begin`*, so the check+begin is atomic and a racing caller cannot -/// slip a `begin` in between. If already Loading, returns without starting a -/// new mutation. -/// -/// Audit fix #3: Variables are wrapped in `Arc<V>` internally so that the -/// retry loop only performs an `Arc::clone` (cheap reference count increment) -/// per attempt, rather than cloning the full variables payload. For the -/// no-`V::clone`-per-attempt path, prefer [`mutate_by_ref`] or -/// [`mutate_arc`]. +/// Transitions the entity to Loading with the given variables, spawns the +/// mutator, and retries per the entity's policy. Variables are wrapped in an +/// `Arc<V>` so each retry only clones once; prefer [`mutate_by_ref`] or +/// [`mutate_arc`] to skip the per-attempt `V::clone` entirely. /// -/// Audit fix #6: The spawned task is stored on the resource via -/// `set_current_task` so a replacement call (or entity drop) aborts the prior -/// in-flight task. Previously the task was `.detach()`ed and kept running -/// after unmount/replacement. -/// -/// Audit fix #67: The shared guard/begin/spawn logic lives in -/// [`begin_and_spawn`] and is shared with [`mutate_with_callbacks`]. -/// -/// Audit fix #119: The unused `+ Clone` bound on `F` has been dropped — the -/// mutator is only ever borrowed, never cloned. +/// A call while the mutation is already Loading is a no-op: the check and the +/// `begin` transition happen inside one `entity.update`, so racing callers +/// cannot both start. The spawned task is stored on the resource, so a +/// replacement call or entity drop aborts a prior in-flight task. /// /// # Example /// @@ -247,24 +192,23 @@ pub fn mutate<V, T, E, C, F, Fut>( F: Fn(V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - begin_and_spawn(entity, variables, mutator, cx, None); + // One V::clone per attempt, matching the Fn(V) mutator contract. + begin_and_spawn( + entity, + Arc::new(variables), + move |v: &V| mutator(v.clone()), + cx, + None, + ); } /// Like [`mutate`] but with lifecycle callbacks. /// -/// Callbacks fire on the final outcome (after all retries exhausted or -/// on first success), not on intermediate retry attempts. -/// -/// **Important**: Callbacks receive cloned data/error and run *outside* any -/// entity borrow, so they may safely call `entity.update()` or other GPUI -/// mutations without risk of deadlock or panic. -/// -/// Audit fix #8/#7: Guards against concurrent calls atomically (see [`mutate`]). -/// Audit fix #9: If the entity is dropped during the mutation, `on_error` and -/// `on_settled` are still invoked so callers always get a terminal callback. -/// Audit fix #3: Variables are wrapped in `Arc<V>` for cheap retries. -/// Audit fix #67: Delegates to the shared [`begin_and_spawn`] helper. -/// Audit fix #119: The unused `+ Clone` bound on `F` has been dropped. +/// Callbacks fire on the final outcome (first success or retries exhausted), +/// never on intermediate attempts. They receive cloned data/error and run +/// outside any entity borrow, so they may safely call `entity.update()`. If +/// the entity is dropped mid-mutation, `on_error` and `on_settled` still fire +/// so callers always get a terminal callback. pub fn mutate_with_callbacks<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: V, @@ -279,19 +223,21 @@ pub fn mutate_with_callbacks<V, T, E, C, F, Fut>( F: Fn(V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - begin_and_spawn(entity, variables, mutator, cx, Some(callbacks)); + begin_and_spawn( + entity, + Arc::new(variables), + move |v: &V| mutator(v.clone()), + cx, + Some(callbacks), + ); } -/// Audit fix #3: Like [`mutate`] but the mutator receives `&V` instead of -/// `V`, so the retry loop borrows the variables from the stored `Arc<V>` and -/// performs **no `V::clone` per attempt**. The caller is responsible for -/// cloning `V` inside the mutator only if the fetcher needs an owned value -/// across an `.await` (otherwise no clone is needed at all). -/// -/// `V` is still required to be `Clone` because the initial `begin` call -/// stores an owned copy on the resource. Only the retry path is clone-free. +/// Like [`mutate`] but the mutator receives `&V`, so the retry loop borrows +/// the variables from the stored `Arc<V>` and performs no `V::clone` per +/// attempt. Clone inside the mutator only if it needs an owned value across an +/// `.await`. /// -/// Audit fix #119: No `+ Clone` bound on `F`. +/// `V` is still `Clone` because `begin` stores an owned copy on the resource. pub fn mutate_by_ref<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: V, @@ -305,14 +251,11 @@ pub fn mutate_by_ref<V, T, E, C, F, Fut>( F: Fn(&V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - begin_and_spawn_by_ref(entity, Arc::new(variables), mutator, cx, None); + begin_and_spawn(entity, Arc::new(variables), mutator, cx, None); } -/// Audit fix #3: Like [`mutate_by_ref`] but accepts `Arc<V>` directly, letting -/// the caller share the variables buffer across multiple mutation invocations -/// (or with other readers) without an extra `Arc::new`. -/// -/// Audit fix #119: No `+ Clone` bound on `F`. +/// Like [`mutate_by_ref`] but accepts `Arc<V>` directly, letting the caller +/// share the variables buffer across invocations without an extra `Arc::new`. pub fn mutate_arc<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -326,88 +269,15 @@ pub fn mutate_arc<V, T, E, C, F, Fut>( F: Fn(&V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - begin_and_spawn_by_ref(entity, variables, mutator, cx, None); + begin_and_spawn(entity, variables, mutator, cx, None); } -// ── Shared helpers ─────────────────────────────────────────────────────── - -/// Audit fix #67: Shared guard/begin/spawn for [`mutate`] and -/// [`mutate_with_callbacks`] (legacy `Fn(V) -> Fut` mutator). +/// Shared guard/begin/spawn for every `mutate*` entrypoint. /// -/// Audit fix #7: The `is_loading` guard and the `begin` transition happen -/// inside the *same* `entity.update` closure, so the check+begin is atomic -/// — a racing caller cannot slip a `begin` in between the check and our own -/// `begin`. -/// -/// Audit fix #6: The spawned task is stored on the resource via -/// `set_current_task` so a replacement / unmount aborts the prior task. +/// The `is_loading` guard and the `begin` transition happen inside one +/// `entity.update` so racing callers cannot both begin. The spawned task is +/// stored via `set_current_task` so replacement or drop aborts it. fn begin_and_spawn<V, T, E, C, F, Fut>( - entity: &Entity<MutationResource<V, T, E>>, - variables: V, - mutator: F, - cx: &mut Context<C>, - callbacks: Option<MutationCallbacks<T, E>>, -) where - V: Clone + Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - C: 'static, - F: Fn(V) -> Fut + Send + 'static, - Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, -{ - let variables_arc = Arc::new(variables); - - let began = entity.update(cx, |resource, cx| { - // Audit fix #7: atomic check+begin. - if resource.is_loading() { - return false; - } - resource.begin((*variables_arc).clone()); - cx.notify(); - true - }); - if !began { - return; - } - - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - let weak = entity.downgrade(); - - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - // `mutator` is moved by value into exactly one arm (run_mutation_loop* - // now own the closure). A `match` keeps the two moves mutually exclusive. - match callbacks { - Some(callbacks) => { - run_mutation_loop_with_callbacks( - &weak, - variables_arc, - mutator, - &retry_policy, - callbacks, - cx, - ) - .await; - } - None => { - run_mutation_loop(&weak, variables_arc, mutator, &retry_policy, cx).await; - } - } - }); - // Audit fix #6: store the task so it is aborted on replacement / drop. - // Audit H9: no cx.notify() here — set_current_task does not change - // MutationStatus (status is already Loading from `begin` above, which - // notified), so MutationObserver dedupes this to a no-op anyway. - entity.update(cx, |r, _| { - r.set_current_task(task); - }); -} - -/// Audit fix #3/#67: Shared guard/begin/spawn for [`mutate_by_ref`] and -/// [`mutate_arc`] (new `Fn(&V) -> Fut` mutator). The retry loop borrows the -/// variables from the `Arc<V>` and performs no `V::clone` per attempt. -/// -/// Preserves the #7 atomic-check fix and the #6 task-storage fix. -fn begin_and_spawn_by_ref<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: Arc<V>, mutator: F, @@ -422,7 +292,6 @@ fn begin_and_spawn_by_ref<V, T, E, C, F, Fut>( Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { let began = entity.update(cx, |resource, cx| { - // Audit fix #7: atomic check+begin. if resource.is_loading() { return false; } @@ -453,8 +322,8 @@ fn begin_and_spawn_by_ref<V, T, E, C, F, Fut>( run_mutation_loop_by_ref(&weak, variables, mutator, &retry_policy, cx).await; } }); - // Audit fix #6: store the task so it is aborted on replacement / drop. - // Audit H9: no cx.notify() here — set_current_task does not change status. + // No notify: set_current_task does not change status (already Loading + // from begin, which notified). entity.update(cx, |r, _| { r.set_current_task(task); }); diff --git a/crates/gpui-query/src/hook/mutation_hooks/internals.rs b/crates/gpui-query/src/hook/mutation_hooks/internals.rs index bc84909..1840d71 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/internals.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/internals.rs @@ -1,20 +1,10 @@ //! Internal retry loops for mutations. //! -//! Audit fix #15: The previous `run_mutation_loop` and -//! `run_mutation_loop_with_callbacks` were ~90% duplicated. Both are now thin -//! wrappers over a single [`run_mutation_loop_inner`] that takes -//! `Option<MutationCallbacks<T, E>>` (`None` for the no-callback variant). -//! -//! Audit fix #3: A new [`run_mutation_loop_by_ref`] (and -//! [`run_mutation_loop_by_ref_with_callbacks`]) accepts a `Fn(&V) -> Fut` -//! mutator so the retry loop borrows the variables via the stored `Arc<V>` -//! instead of cloning `V` on every attempt. The legacy `Fn(V) -> Fut` loops -//! are kept for backward compatibility and adapt via a thin wrapper closure -//! that performs the single `V::clone` per attempt the old API requires. -//! -//! `run_mutation_loop` and `run_mutation_loop_with_callbacks` handle the -//! async retry logic with backoff, cancelled-mutation detection, and -//! lifecycle callback invocation. +//! Everything funnels into [`run_mutation_loop_inner`], which takes +//! `Option<MutationCallbacks>` and a `Fn(&V) -> Fut` mutator so the variables +//! are borrowed from the stored `Arc<V>` on every attempt (no `V::clone` per +//! retry). The public `mutate` entrypoints that accept `Fn(V) -> Fut` adapt at +//! the call site with a one-line wrapper. use std::sync::Arc; @@ -24,32 +14,19 @@ use super::super::options::MutationCallbacks; use crate::hook::read_entity; -/// Unified retry loop for mutations. -/// -/// Audit fix #15: Single implementation shared by the no-callback and -/// with-callback variants. `callbacks` is `None` for the no-callback path. -/// -/// Audit fix #19: When retries are available, uses `increment_retry()` + -/// `prepare_retry()` instead of `complete_failure()` followed by `retry()`. -/// This avoids a transient `Failure` status that would cause observers to see -/// a brief Failure flash between retry attempts. Only `complete_failure()` is -/// called when retries are exhausted, which represents a terminal failure. -/// -/// Audit fix #1: Does NOT call `cx.notify()` after `increment_retry()` or -/// `prepare_retry()` because those operations do not change the mutation status -/// (stays Loading). The `MutationObserver` only triggers `cx.notify()` on actual -/// status changes, so these intermediate updates are invisible to the component. -/// -/// Audit fix #3: Variables are passed as `Arc<V>` and the mutator takes `&V`, -/// so each retry attempt only borrows the variables (no `V::clone` per attempt). +/// Unified retry loop for mutations, shared by the no-callback and +/// with-callback variants. /// -/// Audit fix #9: After each retry delay, checks whether the mutation is still -/// in Loading state. If it was cancelled or reset (no longer Loading), stops -/// retrying immediately (and fires callbacks when present). +/// While retries remain, uses `increment_retry()` + `prepare_retry()` instead +/// of `complete_failure()` + `retry()` so observers never see a transient +/// Failure flash between attempts; only exhausted retries produce a terminal +/// `complete_failure()`. Neither intermediate call notifies: the status stays +/// Loading and the `MutationObserver` dedupes. /// -/// Audit fix #27/#121: `entity.update` results are discarded via `let _ =` to -/// silence `unused_must_use` under `AsyncApp` (where `update` returns -/// `Result<R>`). +/// After each retry delay the loop checks whether the mutation is still in +/// Loading state; a cancelled or reset mutation stops retrying immediately. +/// `entity.update` results are discarded because `update` returns `Result<R>` +/// under `AsyncApp`. async fn run_mutation_loop_inner<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -67,21 +44,16 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( let mut attempt: u32 = 0; loop { - // Audit fix #3: borrow the variables via the Arc; no V::clone per attempt. let result = mutator(&*variables).await; match result { Ok(data) => { // Clone data before update only when callbacks need it. - let data_for_callback = if callbacks.is_some() { - Some(data.clone()) - } else { - None - }; + let data_for_callback = callbacks.is_some().then(|| data.clone()); let Some(entity) = weak.upgrade() else { - // Audit fix #9: Entity dropped during mutation. Fire - // on_settled with None for both to indicate discard. + // Entity dropped mid-mutation: fire on_settled with None + // for both so the caller sees the discard. if let Some(ref cb) = callbacks && let Some(ref f) = cb.on_settled { @@ -92,13 +64,12 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( let _ = entity.update(cx, |resource, cx| { resource.complete_success(data); cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); }); - // Fire success/settled callbacks outside entity borrow so - // they can safely call entity.update(). + // Fire outside the entity borrow so callbacks can safely + // call entity.update(). if let Some(ref cb) = callbacks { if let Some(ref d) = data_for_callback && let Some(ref f) = cb.on_success @@ -113,38 +84,17 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( return; } Err(error) => { - // Clone error before update only when callbacks need it. - let error_for_callback = if callbacks.is_some() { - Some(error.clone()) - } else { - None - }; + let error_for_callback = callbacks.is_some().then(|| error.clone()); if retry_policy.should_retry(attempt) { - // Audit fix #19: Do NOT call complete_failure() here. - // Instead, just increment the retry counter and wait for - // the delay. This avoids a transient Failure -> Loading - // flash for observers. let delay_ms = retry_policy.delay_for_attempt(attempt); let Some(entity) = weak.upgrade() else { - // Audit fix #9: Entity dropped between mutator failure and retry. - if let Some(ref cb) = callbacks { - if let Some(ref ec) = error_for_callback - && let Some(ref f) = cb.on_error - { - f(ec); - } - if let Some(ref f) = cb.on_settled { - f(None, error_for_callback.as_ref()); - } - } + fire_error_callbacks(&callbacks, &error_for_callback); return; }; let _ = entity.update(cx, |resource, _cx| { resource.increment_retry(); - // Audit fix #1: No cx.notify() -- increment_retry does - // not change status (stays Loading). }); if delay_ms > 0 { @@ -153,36 +103,14 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( .await; } - // Audit fix #9: After the retry delay, check whether the - // mutation is still in Loading state. If it was cancelled - // or reset, stop retrying immediately. let Some(entity) = weak.upgrade() else { - // Audit fix #9/#10: Entity dropped during retry delay. - if let Some(ref cb) = callbacks { - if let Some(ref ec) = error_for_callback - && let Some(ref f) = cb.on_error - { - f(ec); - } - if let Some(ref f) = cb.on_settled { - f(None, error_for_callback.as_ref()); - } - } + fire_error_callbacks(&callbacks, &error_for_callback); return; }; if !read_entity(&entity, cx, |r, _| r.is_loading()).unwrap_or(false) { - // Mutation was cancelled or reset during the delay. - // Fire error callbacks so callers get a terminal notification. - if let Some(ref cb) = callbacks { - if let Some(ref ec) = error_for_callback - && let Some(ref f) = cb.on_error - { - f(ec); - } - if let Some(ref f) = cb.on_settled { - f(None, error_for_callback.as_ref()); - } - } + // Cancelled or reset during the delay: still fire the + // terminal callbacks. + fire_error_callbacks(&callbacks, &error_for_callback); #[cfg(debug_assertions)] eprintln!( "DEBUG: run_mutation_loop_inner: mutation no longer Loading after retry delay, aborting" @@ -190,47 +118,26 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( return; } - // After delay, prepare for retry (refresh signal, stay in Loading). let _ = entity.update(cx, |resource, _cx| { resource.prepare_retry(); - // Audit fix #1: No cx.notify() -- prepare_retry does - // not change status (stays Loading). }); attempt += 1; } else { - // No more retries -- terminal failure. - // Audit fix #10: Capture entity availability before - // complete_failure so callbacks still fire even if entity - // is dropped between the update and callback invocation. - let entity_available = weak.upgrade(); - if let Some(entity) = entity_available { + // Terminal failure. Capture availability before + // complete_failure so callbacks fire even if the entity + // drops in between. + if let Some(entity) = weak.upgrade() { let _ = entity.update(cx, |resource, cx| { resource.complete_failure(error); - // Audit fix #4: Reset retry_count on terminal failure. resource.reset_retry_count(); cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); }); } - // Fire error and settled callbacks outside entity borrow. - // These fire regardless of whether entity is still alive - // (Audit fix #9/#10). - if let Some(ref cb) = callbacks { - if let Some(ref ec) = error_for_callback - && let Some(ref f) = cb.on_error - { - f(ec); - } - - if let Some(ref f) = cb.on_settled { - f(None, error_for_callback.as_ref()); - } - } - + fire_error_callbacks(&callbacks, &error_for_callback); return; } } @@ -238,59 +145,26 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( } } -/// Core retry loop for mutations (legacy `Fn(V) -> Fut` mutator). -/// -/// Wraps the mutator so each attempt performs a single `V::clone` (preserving -/// the original semantics) and delegates to [`run_mutation_loop_inner`]. -/// -/// Audit fix #119: The `+ Clone` bound on `F` has been dropped — the mutator -/// is only ever borrowed (`&mutator`), never cloned. -pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( - weak: &gpui::WeakEntity<MutationResource<V, T, E>>, - variables: Arc<V>, - mutator: F, - retry_policy: &RetryPolicy, - cx: &mut gpui::AsyncApp, -) where - V: Clone + Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(V) -> Fut + Send + 'static, - Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, -{ - let wrapper = move |v: &V| mutator(v.clone()); - run_mutation_loop_inner(weak, variables, wrapper, retry_policy, None, cx).await; -} - -/// Like [`run_mutation_loop`] but fires lifecycle callbacks on final outcome -/// (legacy `Fn(V) -> Fut` mutator). -/// -/// Audit fix #119: The `+ Clone` bound on `F` has been dropped. -pub(super) async fn run_mutation_loop_with_callbacks<V, T, E, F, Fut>( - weak: &gpui::WeakEntity<MutationResource<V, T, E>>, - variables: Arc<V>, - mutator: F, - retry_policy: &RetryPolicy, - callbacks: MutationCallbacks<T, E>, - cx: &mut gpui::AsyncApp, -) where - V: Clone + Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(V) -> Fut + Send + 'static, - Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, -{ - let wrapper = move |v: &V| mutator(v.clone()); - run_mutation_loop_inner(weak, variables, wrapper, retry_policy, Some(callbacks), cx).await; +/// Fire `on_error` / `on_settled` for a failed mutation, whether the entity is +/// still alive or not. +fn fire_error_callbacks<T, E>( + callbacks: &Option<MutationCallbacks<T, E>>, + error_for_callback: &Option<E>, +) { + if let Some(cb) = callbacks { + if let Some(ec) = error_for_callback + && let Some(ref f) = cb.on_error + { + f(ec); + } + if let Some(ref f) = cb.on_settled { + f(None, error_for_callback.as_ref()); + } + } } -/// Audit fix #3: Retry loop for the new `Fn(&V) -> Fut` mutator signature. -/// -/// Borrows the variables via the stored `Arc<V>` on every attempt — no -/// `V::clone` per retry. Use this with [`super::super::mutate_by_ref`] / -/// [`super::super::mutate_arc`]. -/// -/// Audit fix #119: No `+ Clone` bound on `F` (mutator is borrowed, not cloned). +/// Retry loop for the `Fn(&V) -> Fut` mutator signature: borrows the variables +/// via the stored `Arc<V>` on every attempt, no `V::clone` per retry. pub(super) async fn run_mutation_loop_by_ref<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -307,8 +181,8 @@ pub(super) async fn run_mutation_loop_by_ref<V, T, E, F, Fut>( run_mutation_loop_inner(weak, variables, mutator, retry_policy, None, cx).await; } -/// Audit fix #3: Like [`run_mutation_loop_by_ref`] but fires lifecycle -/// callbacks on final outcome. +/// Like [`run_mutation_loop_by_ref`] but fires lifecycle callbacks on the +/// final outcome. pub(super) async fn run_mutation_loop_by_ref_with_callbacks<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, diff --git a/crates/gpui-query/src/hook/mutation_hooks/mod.rs b/crates/gpui-query/src/hook/mutation_hooks/mod.rs index a23a48c..2315051 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/mod.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/mod.rs @@ -1,12 +1,9 @@ -//! Mutation hooks and internals — `use_mutation`, `mutate`, `mutate_with_callbacks`, +//! Mutation hooks and internals: `use_mutation`, `mutate`, `mutate_with_callbacks`, //! `mutate_by_ref`, `mutate_arc`, and the internal retry loops. //! -//! Audit fix #22: `use_mutation_with_options` is intentionally NOT re-exported -//! from the module public surface. The function itself remains defined (it is -//! `#[deprecated]` and delegates to `use_mutation`) so existing call sites -//! that import it via the full path keep compiling with a deprecation warning, -//! but the `pub use` re-export no longer fires the deprecated-lint-on-re-export -//! warning under `clippy::style`. +//! `use_mutation_with_options` is deprecated and deliberately not re-exported +//! from the public surface; the `pub use` list would otherwise fire the +//! deprecated lint on every import of this module. mod hooks; mod internals; diff --git a/crates/gpui-query/src/hook/options.rs b/crates/gpui-query/src/hook/options.rs index 17cd7b1..b16eea1 100644 --- a/crates/gpui-query/src/hook/options.rs +++ b/crates/gpui-query/src/hook/options.rs @@ -1,7 +1,7 @@ //! Query and mutation options with builder pattern and sensible defaults. //! -//! **v2**: All options use `Default` and `From<&str>` so users can pass just -//! a string key for the simplest case. +//! All options implement `Default` and `From<&str>` so callers can pass just a +//! string key for the simplest case. use std::sync::Arc; @@ -9,7 +9,7 @@ use crate::core::{CachePolicy, RefetchTrigger, RequestPolicy, RetryPolicy}; /// Options for `use_query` and `fetch_query`. /// -/// # Quick Start +/// # Quick start /// /// ```no_run /// use gpui_query::QueryOptions; @@ -50,64 +50,31 @@ pub struct QueryOptions { pub retry_policy: RetryPolicy, /// GC time in milliseconds. Default: 300_000 (5 minutes). /// - /// **Reserved / forward-compat** (audit fix #80): settable via the - /// `.gc_time(ms)` builder, but **not yet consumed** by `use_query`, - /// `fetch_query`, or the bucket layer. Garbage collection currently runs - /// off the global GC time set via [`QueryClient::with_gc_time`]; this - /// per-query value is stored only so a future release can honor it without - /// a breaking API change. Setting it has no effect today. + /// Reserved: stored but not yet consumed. GC currently runs off the global + /// time set via `QueryClient::with_gc_time`; setting this has no effect + /// today. pub gc_time_ms: u64, - /// Whether to keep previous data when the key changes. + /// Keep previous data when the key changes. /// - /// **Reserved / forward-compat** (audit fix #80): settable via the - /// `.keep_previous()` builder, but **not yet consumed** by `use_query` or - /// `use_query_manual`. The `placeholderData`/`keepPreviousData` behavior is - /// not yet implemented; the field is stored so a future release can honor - /// it without a breaking API change. Setting it has no effect today. + /// Reserved: stored but not yet consumed; setting it has no effect today. pub keep_previous_data: bool, - /// Whether to force a fetch (ignore cache). - /// - /// When `true`, `use_query` passes `QueryFetchMode::Force` to - /// `begin_request`, bypassing cache freshness checks and always starting - /// a new fetch. + /// Whether to force a fetch (ignore cache). When `true`, `use_query` + /// passes `QueryFetchMode::Force` to `begin_request`, bypassing freshness + /// checks. pub force_fetch: bool, - /// Refetch on mount trigger. - /// - /// **Reserved / forward-compat** (audit fix #80): settable on the struct, - /// but **not yet consumed**. The GPUI event-system integration for - /// automatic refetching on component mount is not yet implemented; the - /// field is stored so a future release can honor it without a breaking API - /// change. Setting it has no effect today. + /// Refetch on mount trigger. Reserved: stored but not yet consumed. pub refetch_on_mount: RefetchTrigger, - /// Refetch on window focus trigger. - /// - /// **Reserved / forward-compat** (audit fix #80): settable on the struct, - /// but **not yet consumed**. The GPUI event-system integration for - /// automatic refetching on window focus is not yet implemented; the field - /// is stored so a future release can honor it without a breaking API - /// change. Setting it has no effect today. + /// Refetch on window focus trigger. Reserved: stored but not yet consumed. pub refetch_on_window_focus: RefetchTrigger, - /// Refetch on reconnect trigger. - /// - /// **Reserved / forward-compat** (audit fix #80): settable on the struct, - /// but **not yet consumed**. The GPUI event-system integration for - /// automatic refetching on reconnect is not yet implemented; the field is - /// stored so a future release can honor it without a breaking API change. - /// Setting it has no effect today. + /// Refetch on reconnect trigger. Reserved: stored but not yet consumed. pub refetch_on_reconnect: RefetchTrigger, } impl Default for QueryOptions { fn default() -> Self { Self { - // #77: The default key cannot be a `const` because `QueryKey` - // wraps an `Arc<[Arc<str>]>` and `Arc::from` is not const-stable, - // so `QueryKey` itself is not const-constructable. This is a - // single allocation per `QueryOptions::default()` call and is not - // on a hot path (`Default` is only invoked when a caller opts out - // of supplying a key, e.g. `use_mutation((), cx)`), so the runtime - // cost is acceptable. The `Arc` also means cloning the resulting - // default key is a single refcount bump. + // Not const-constructable: QueryKey wraps an Arc, so `from` + // allocates. Only reached when a caller omits the key. key: crate::core::QueryKey::from("default"), cache_policy: CachePolicy::default(), request_policy: RequestPolicy::default(), @@ -122,12 +89,8 @@ impl Default for QueryOptions { } } -/// Declarative macro that generates the byte-for-byte equivalent builder -/// methods shared by [`QueryOptions`] and [`InfiniteQueryOptions`] -/// (`cache_policy`, `request_policy`, `retry_policy`, `gc_time`). -/// -/// Audit fix #44: collapses the duplicated builders into a single source of -/// truth so the two option types cannot drift. +/// Generates the builder methods shared by [`QueryOptions`] and +/// [`InfiniteQueryOptions`] so the two cannot drift. macro_rules! impl_query_options_builders { ($t:ident) => { impl $t { @@ -161,8 +124,6 @@ macro_rules! impl_query_options_builders { impl QueryOptions { /// Create options with just a key. pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { - // Construct directly to avoid Default::default() allocating a default - // key that is immediately overwritten (audit H4). Self { key: key.into(), cache_policy: CachePolicy::default(), @@ -177,10 +138,7 @@ impl QueryOptions { } } - /// Force a fetch, ignoring cache. - /// - /// When set, `use_query` passes `QueryFetchMode::Force` to `begin_request`, - /// which bypasses cache freshness checks and always starts a new fetch. + /// Force a fetch, ignoring cache freshness checks. pub fn force(mut self) -> Self { self.force_fetch = true; self @@ -188,13 +146,8 @@ impl QueryOptions { /// Keep previous data when the key changes. /// - /// **Reserved / forward-compat** (audit fix #80): sets the - /// `keep_previous_data` field, which is **not yet consumed** by `use_query` - /// or `use_query_manual`. The `keepPreviousData` behavior is intended for a - /// future release (preserve the prior `data`/`previous_data` slot across a - /// key change so the component keeps rendering the last successful result - /// while the new fetch is in flight). The builder is provided now so callers - /// can opt in without a future API change; calling it has no effect today. + /// Reserved: sets the field, which is not yet consumed by `use_query`. + /// Calling it has no effect today. pub fn keep_previous(mut self) -> Self { self.keep_previous_data = true; self @@ -221,13 +174,6 @@ impl From<crate::core::QueryKey> for QueryOptions { } } -/// Build [`QueryOptions`] from a raw `(key, cache_policy, request_policy)` -/// triple. -/// -/// Audit fix #79: this lets `use_query_manual_opts` / -/// `use_query_unsignalled_opts` accept callers that already hold the legacy -/// raw-parameter triple without forcing them to spell out `QueryOptions::new`. -/// Non-breaking: the existing constructors and `From` impls are untouched. impl From<(crate::core::QueryKey, CachePolicy, RequestPolicy)> for QueryOptions { fn from( (key, cache_policy, request_policy): (crate::core::QueryKey, CachePolicy, RequestPolicy), @@ -261,53 +207,36 @@ impl Default for MutationOptions { impl MutationOptions { /// Set the retry policy. - /// - /// Audit fix #43: Mirrors the `.retry_policy(p)` builder on - /// [`QueryOptions`] so mutation callers can configure retries without - /// constructing `MutationOptions` via struct literal. pub fn retry_policy(mut self, policy: RetryPolicy) -> Self { self.retry_policy = policy; self } /// Set the GC time in milliseconds. - /// - /// Audit fix #43: Mirrors the `.gc_time(ms)` builder on [`QueryOptions`]. pub fn gc_time(mut self, ms: u64) -> Self { self.gc_time_ms = ms; self } } -/// Type alias for the `on_success` callback field on [`MutationCallbacks`]. -/// -/// Audit fix #96: collapses the `Option<Arc<dyn Fn(&T) + Send + Sync>>` -/// field type so `clippy::type_complexity` does not fire on the struct -/// definition. pub type MutationSuccessCallback<T> = Option<Arc<dyn Fn(&T) + Send + Sync>>; -/// Type alias for the `on_error` callback field on [`MutationCallbacks`]. pub type MutationErrorCallback<E> = Option<Arc<dyn Fn(&E) + Send + Sync>>; -/// Type alias for the `on_settled` callback field on [`MutationCallbacks`]. pub type MutationSettledCallback<T, E> = Option<Arc<dyn Fn(Option<&T>, Option<&E>) + Send + Sync>>; /// Lifecycle callbacks for mutations. /// -/// `Clone` is implemented manually (no `T: Clone` / `E: Clone` bound needed) -/// because every field is an `Option<Arc<...>>` — cloning bumps the refcount, -/// it does not clone `T`/`E`. Construct with `MutationCallbacks::new()` and -/// the builder methods. -/// -/// Callbacks are wrapped in `Arc` so they can be shared across concurrent -/// mutation invocations. `E` should implement `std::fmt::Debug` so that +/// `Clone` is manual (every field is an `Option<Arc<...>>`, so cloning bumps +/// refcounts without requiring `T: Clone` / `E: Clone`). Callbacks are shared +/// across concurrent mutation invocations. `E` should implement `Debug` so /// callbacks can log or display error details. pub struct MutationCallbacks<T, E> { - /// Fired on terminal success (after all retries skipped or succeeded). + /// Fired on terminal success. pub on_success: MutationSuccessCallback<T>, - /// Fired on terminal failure (after retries exhausted or cancelled). + /// Fired on terminal failure (retries exhausted or cancelled). pub on_error: MutationErrorCallback<E>, - /// Fired on every terminal outcome (success, failure, or discard). + /// Fired on every terminal outcome. pub on_settled: MutationSettledCallback<T, E>, } @@ -392,8 +321,6 @@ impl Default for InfiniteQueryOptions { impl InfiniteQueryOptions { /// Create with just a key. pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { - // Construct directly to avoid Default::default() allocating a default - // key that is immediately overwritten (audit H4). Self { key: key.into(), cache_policy: CachePolicy::default(), @@ -404,20 +331,15 @@ impl InfiniteQueryOptions { } } - /// Set max pages. Pass a concrete number to cap retained pages. - /// - /// To allow unbounded pages, use [`InfiniteQueryOptions::unbounded_pages`] - /// instead. + /// Set max pages: the number of retained pages before old ones are + /// evicted. Use [`InfiniteQueryOptions::unbounded_pages`] for no limit. pub fn max_pages(mut self, max: usize) -> Self { self.max_pages = Some(max); self } - /// Allow unbounded page accumulation (no limit). - /// - /// Sets `max_pages` to `None`, meaning the infinite query will never - /// evict old pages. Use with caution — unbounded page storage can grow - /// without limit if the user scrolls far enough. + /// Allow unbounded page accumulation (no limit). Use with caution: page + /// storage grows without bound if the user scrolls far enough. pub fn unbounded_pages(mut self) -> Self { self.max_pages = None; self diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index 37126b4..102d9c2 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -1,24 +1,11 @@ -//! Query hook functions — `use_query`, `use_query_unsignalled`, `use_query_manual`, +//! Query hook functions: `use_query`, `use_query_unsignalled`, `use_query_manual`, //! `fetch_query`, and `fetch_query_with_signal`. //! -//! # Task lifecycle: deliberate detach-by-design (Audit Finding #6) -//! -//! Audit fix #6 (storing the spawned fetch task so a replacement fetch or -//! entity drop aborts it) is applied to **mutations and infinite queries**. -//! The **plain-query** spawn sites in this module — inside `use_query`, -//! `use_query_unsignalled`, `fetch_query`, and `fetch_query_with_signal` — -//! intentionally call `task.detach()` instead. This is not an unfinished fix: -//! -//! - Query fetches already prevent stale writes through the cooperative -//! `QuerySignal` plus the `is_current_request` / `accept_current_request` -//! two-phase guard in the retry loop. A superseded fetcher still observes -//! its cancelled signal, which tests enforce. -//! - Hard-aborting a query task on replacement would break that cooperative -//! contract. The detached task self-terminates once the owning entity is -//! dropped (the `weak.upgrade()` checks return `None`), so it cannot leak -//! writes after unmount. -//! -//! Each site repeats a short form of this rationale next to its `detach()`. +//! Plain-query fetch tasks are deliberately detached. Stale writes are already +//! prevented by the cooperative `QuerySignal` plus the two-phase +//! `is_current_request` / `accept_current_request` guard in the retry loop, and +//! hard-aborting on replacement would break that contract. Each task holds only +//! a `WeakEntity`, so it self-terminates once the owning entity is dropped. use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; @@ -26,33 +13,19 @@ use crate::client::{QueryClient, QueryObserver}; use crate::core::{Fetched, QueryFetchMode, QueryKey, QueryResource, QuerySignal, QueryStatus}; use super::current_time_ms; -use super::fetch_retry::{begin_request_on_entity, fetch_signal_with_retry, fetch_with_retry}; +use super::fetch_retry::{ + FetchedLike, begin_request_on_entity, fetch_signal_with_retry, fetch_with_retry, +}; -/// Subscribe to a query resource and automatically re-render when it changes. -/// -/// This is the **primary** `use_query` hook following the v2 "Signal-always" -/// design: the fetcher receives a [`QuerySignal`] for cooperative cancellation. -/// -/// Call this in your component's constructor (not in `render`). It: -/// -/// 1. Gets or creates a [`QueryResource`] entity from the global [`QueryClient`] -/// 2. Sets up a [`QueryObserver`] so your component re-renders on state changes -/// 3. Propagates the user's retry policy to the resource entity (audit fix #16) -/// 4. Calls `begin_request` to set status to Loading and obtain a `RequestId` -/// 5. Spawns an async fetch with retry logic, using the stored `RequestId` +/// Subscribe to a query resource and re-render when it changes. /// -/// # Returns +/// The primary hook: creates or reuses the resource in the global +/// [`QueryClient`], sets up a [`QueryObserver`], propagates the retry policy +/// from `options`, and spawns a signal-accepting fetch if the resource is +/// idle. Call it in a component constructor, not in `render`. /// -/// A tuple of `(Entity<QueryResource<T, E>>, Subscription)`: -/// - Store the entity to read state during render -/// - Store the subscription to keep the observation alive -/// -/// # Unmount Behavior (Audit Finding #6) -/// -/// If the component unmounts while a fetch is in-flight, the fetch result is -/// silently discarded. No callback fires. This is intentional for cache-layer -/// correctness. Callers who need completion guarantees should use -/// `fetch_query_with_signal` directly with their own completion handling. +/// If the component unmounts mid-fetch the result is silently discarded; use +/// [`fetch_query_with_signal`] directly when you need completion guarantees. pub fn use_query<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -65,88 +38,20 @@ where F: Fn(QuerySignal) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - // Audit H7: destructure opts so retry_policy can be moved (not cloned) - // into the entity store; it has no later use. key is still cloned once for - // use_query_manual (audit #61 moves the original into begin_request below). - let crate::hook::QueryOptions { - key, - cache_policy, - request_policy, - retry_policy, - force_fetch, - .. - } = options.into(); - let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); - - // Audit fix #16: Propagate the user's retry policy to the resource entity. - // Without this, the resource defaults to RetryPolicy::no_retries() and - // the user's QueryOptions::retry_policy() builder is a dead API. - entity.update(cx, |r, _| r.set_retry_policy(retry_policy)); - - // Start fetch if resource is idle - let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle); - if should_fetch { - let fetch_mode = if force_fetch { - QueryFetchMode::Force - } else { - QueryFetchMode::Normal - }; - // Audit fix #3: begin_request_on_entity returns Option<RequestId>. - // If CacheHit or IgnoredWhileLoading, skip spawning the fetch task. - // Audit fix #2: Thread the key through to avoid re-reading from entity. - // Audit fix #61: opts.key was cloned once above for use_query_manual - // and is no longer needed after this call, so move it instead of - // cloning again (removes a redundant second clone of the key). - if let (Some(request_id), signal) = - begin_request_on_entity(&entity, cx, fetch_mode, Some(key)) - { - // Audit H3: `signal` comes straight from begin_request_on_entity - // (read in the same entity.update as the begin) instead of via a - // separate entity.read_with pass. unwrap_or_else covers the - // pathological case where begin created no signal. - let signal = signal.unwrap_or_else(QuerySignal::new); - let weak = entity.downgrade(); - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Audit fix #6: store the spawned task on the resource so a - // replacement fetch (or entity drop on unmount) aborts the prior - // in-flight task instead of leaving it detached and running. - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - fetch_signal_with_retry(fetcher, signal, request_id, &retry_policy, &weak, cx) - .await; - }); - // Audit #6 NOT applied to queries: query fetches already prevent - // stale writes via the signal + `is_current_request` cooperative - // check in run_query_retry_loop, and tests enforce that a - // superseded fetcher still observes its cancelled signal. Hard- - // aborting on replacement would break that contract, so the task - // is detached (it self-terminates when the entity is dropped). - task.detach(); - } - } - - (entity, subscription) + use_query_impl(options.into(), fetcher, cx) } -/// Like [`use_query`], but the fetcher returns [`Fetched<T>`] so a server-derived -/// [`CachePolicy`] can override the caller's per-query policy on success -/// ("server wins"). -/// -/// Mirrors [`use_query`] exactly — same options-first signature, same -/// [`QuerySignal`]-accepting fetcher, same -/// `(Entity<QueryResource<T, E>>, Subscription)` return — except the fetcher -/// returns `Result<Fetched<T>, E>`. [`Fetched::new`] keeps the caller's policy; -/// [`Fetched::with_policy`] overrides it with the server's (e.g. parsed from -/// `Cache-Control`) once the fetch resolves. The existing `Result<T, E>` -/// [`use_query`] is unchanged. +/// Like [`use_query`], but the fetcher returns +/// [`Fetched<T>`](crate::core::Fetched) so a server-derived +/// [`CachePolicy`](crate::core::CachePolicy) can override the caller's +/// per-query policy on success ("server wins"). /// -/// # Server wins -/// -/// The resource's `CachePolicy` is established at `begin_request` time from -/// [`QueryOptions`] (the caller's policy). When a fetcher returns -/// [`Fetched::with_policy`], that server policy replaces the resource's stored -/// policy immediately after `complete_success`, so subsequent freshness / SWR -/// checks use the server's TTL. `None` (via [`Fetched::new`]) leaves the caller's -/// policy in place. +/// The resource's policy is established at `begin_request` time from +/// [`QueryOptions`](crate::hook::QueryOptions). A fetcher returning +/// [`Fetched::with_policy`](crate::core::Fetched::with_policy) replaces that +/// stored policy right after `complete_success`, so later freshness checks use +/// the server's TTL; [`Fetched::new`](crate::core::Fetched::new) keeps the +/// caller's policy. pub fn use_query_with_policy<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -158,6 +63,24 @@ where C: 'static, F: Fn(QuerySignal) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<Fetched<T>, E>> + Send + 'static, +{ + use_query_impl(options.into(), fetcher, cx) +} + +/// Shared body of [`use_query`] and [`use_query_with_policy`], generic over +/// the fetcher output via [`FetchedLike`]. +fn use_query_impl<T, E, C, F, Fut, Out>( + options: crate::hook::QueryOptions, + fetcher: F, + cx: &mut Context<C>, +) -> (Entity<QueryResource<T, E>>, Subscription) +where + T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + std::fmt::Debug + 'static, + C: 'static, + Out: FetchedLike<T> + Send + 'static, + F: Fn(QuerySignal) -> Fut + Send + 'static, + Fut: std::future::Future<Output = Result<Out, E>> + Send + 'static, { let crate::hook::QueryOptions { key, @@ -166,15 +89,15 @@ where retry_policy, force_fetch, .. - } = options.into(); + } = options; let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); - // Audit fix #16 (mirrors `use_query`): propagate the user's retry policy. - entity.update(cx, |r, _| r.set_retry_policy(retry_policy)); + // Propagate the user's retry policy; the resource would otherwise keep + // its no-retries default. + entity.update(cx, |r, _| r.set_retry_policy(retry_policy.clone())); // Start fetch if resource is idle - let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle); - if should_fetch { + if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { let fetch_mode = if force_fetch { QueryFetchMode::Force } else { @@ -185,10 +108,6 @@ where { let signal = signal.unwrap_or_else(QuerySignal::new); let weak = entity.downgrade(); - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Same deliberate detach as `use_query` (audit #6 NOT applied to - // queries): cooperative signal cancellation + `accept_current_request` - // prevent stale writes, and the task self-terminates on entity drop. let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { fetch_signal_with_retry(fetcher, signal, request_id, &retry_policy, &weak, cx) .await; @@ -200,10 +119,8 @@ where (entity, subscription) } -/// Like [`use_query`] but the fetcher receives no signal argument. -/// -/// This exists for backward compatibility. Prefer [`use_query`] (the -/// signal-accepting version) which aligns with the v2 "Signal-always" design. +/// Like [`use_query`], but the fetcher receives no signal argument. Exists for +/// backward compatibility; prefer the signal-accepting [`use_query`]. pub fn use_query_unsignalled<T, E, C, F, Fut>( key: QueryKey, cache_policy: crate::core::CachePolicy, @@ -221,44 +138,25 @@ where let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); // Start fetch if resource is idle - let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle); - if should_fetch { - // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId. - // Audit fix #2: Thread the key through to avoid re-reading from entity. - if let (Some(request_id), _signal) = + if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) + && let (Some(request_id), _signal) = begin_request_on_entity(&entity, cx, QueryFetchMode::Normal, Some(key)) - { - let weak = entity.downgrade(); - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Audit fix #6: store the task so replacement/unmount aborts it. - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; - }); - // Audit #6 NOT applied to queries: query fetches already prevent - // stale writes via the signal + `is_current_request` cooperative - // check in run_query_retry_loop, and tests enforce that a - // superseded fetcher still observes its cancelled signal. Hard- - // aborting on replacement would break that contract, so the task - // is detached (it self-terminates when the entity is dropped). - task.detach(); - } + { + let weak = entity.downgrade(); + let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); + let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; + }); + task.detach(); } (entity, subscription) } -/// Convenience wrapper around [`use_query_manual`] that builds the entity and -/// observation from a [`QueryOptions`] value instead of raw policy parameters. -/// -/// Audit fix #79: `use_query_manual` historically required a raw -/// `(key, cache_policy, request_policy)` triple. This overload accepts anything -/// convertible into [`QueryOptions`] (a string, a [`QueryKey`], a full -/// `QueryOptions` builder, or the legacy `(QueryKey, CachePolicy, RequestPolicy)` -/// tuple) so callers do not have to spell out the policies by hand. Only the -/// `key`, `cache_policy`, and `request_policy` fields are consumed; the -/// remaining options (retry policy, `force_fetch`, etc.) are ignored at this -/// layer — use [`use_query`] to honor them. The existing -/// [`use_query_manual`] signature is unchanged. +/// Build the entity and observation from a +/// [`QueryOptions`](crate::hook::QueryOptions) value. Only `key`, +/// `cache_policy`, and `request_policy` are consumed here; use [`use_query`] +/// to honor the rest. pub fn use_query_manual_opts<T, E, C>( options: impl Into<crate::hook::QueryOptions>, cx: &mut Context<C>, @@ -273,13 +171,7 @@ where } /// Convenience wrapper around [`use_query_unsignalled`] that accepts an -/// `impl Into<QueryOptions>` instead of the raw `(key, cache_policy, -/// request_policy)` triple. -/// -/// Audit fix #79: mirrors [`use_query_manual_opts`]. Only the `key`, -/// `cache_policy`, and `request_policy` fields of [`QueryOptions`] are read; -/// the remaining options are not consumed at this layer. The existing -/// [`use_query_unsignalled`] signature is unchanged. +/// `impl Into<QueryOptions>` instead of the raw policy triple. pub fn use_query_unsignalled_opts<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -302,19 +194,15 @@ where ) } -/// Lower-level hook that sets up the entity and observation without starting a fetch. -/// -/// Use this when you need full control over when and how fetching happens. -/// -/// Uses v2's [`QueryObserver`] which returns `Option<Subscription>` instead of -/// panicking when the entity has been dropped. +/// Lower-level hook that sets up the entity and observation without starting +/// a fetch. Use this when you need full control over when and how fetching +/// happens. /// /// # Panics (debug builds only) /// /// In debug builds, panics if no [`QueryClient`] has been set via -/// `cx.set_global::<QueryClient>()`. In release builds, falls back to a -/// standalone entity (no shared caching, no GC) so that tests and demos -/// continue to work. +/// `cx.set_global::<QueryClient>()`. Release builds fall back to a standalone +/// entity (no shared caching, no GC). pub fn use_query_manual<T, E, C>( key: QueryKey, cache_policy: crate::core::CachePolicy, @@ -331,8 +219,6 @@ where client.resource_with_policies::<T, E>(key, cache_policy, request_policy, cx) }) } else { - // Panic in debug builds when QueryClient is not initialized. - // The silent fallback is appropriate for tests but dangerous for production. #[cfg(debug_assertions)] { eprintln!( @@ -347,16 +233,10 @@ where } #[cfg(not(debug_assertions))] { - // Audit fix #5: Warning eprintln removed from release builds. - // In release builds, silently fall back without leaking to stderr. cx.new(|_| QueryResource::new(key, cache_policy, request_policy)) } }; - // Audit fix #12: Use match instead of expect() to avoid production panics. - // In debug builds, the entity was just created so observe() should succeed. - // In release builds, if GPUI internals change unexpectedly, fall back - // gracefully rather than panicking. let observer = QueryObserver::new(&entity); let Some(subscription) = observer.observe(cx) else { #[cfg(debug_assertions)] @@ -366,9 +246,6 @@ where ); #[cfg(not(debug_assertions))] { - // Audit fix #5: Warning eprintln removed from release builds. - // Return a no-op subscription so the caller can continue. - // This prevents a production panic from a GPUI internal issue. return (entity, Subscription::new(|| {})); } }; @@ -376,16 +253,9 @@ where (entity, subscription) } -/// Initiate a fetch on an existing query entity. -/// -/// Call this when you want to refetch (e.g., on button click or timer). -/// Respects the resource's retry policy on failure. -/// -/// Calls `begin_request` to obtain a fresh `RequestId` and transition the -/// resource to Loading before spawning the fetch task. -/// -/// Audit fix #3: If `begin_request` returns `None` (cache hit or ignored), -/// no async fetch task is spawned, avoiding wasted resources. +/// Initiate a fetch on an existing query entity (e.g. on button click or +/// timer). Respects the resource's retry policy on failure. If the cache is +/// fresh or a fetch is already loading, no task is spawned. pub fn fetch_query<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -397,29 +267,14 @@ pub fn fetch_query<T, E, C, F, Fut>( F: Fn() -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId. - let (Some(request_id), _signal) = - begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None) - else { - return; - }; - let weak = entity.downgrade(); - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Audit fix #6 (deliberate detach): plain-query fetches are NOT stored on - // the resource. Cooperative signal cancellation + `accept_current_request` - // already prevent stale writes (see the module-level docs), so the task is - // detached and self-terminates once the entity is dropped. - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; - }); - task.detach(); + fetch_query_impl(entity, fetcher, cx); } -/// Like [`fetch_query`], but the fetcher returns [`Fetched<T>`] so a server-derived -/// [`CachePolicy`] can override the resource's policy on success ("server wins"). -/// -/// See [`use_query_with_policy`] for the server-wins semantics. Respects the -/// resource's retry policy on failure. +/// Like [`fetch_query`], but the fetcher returns +/// [`Fetched<T>`](crate::core::Fetched) so a server-derived +/// [`CachePolicy`](crate::core::CachePolicy) can override the resource's +/// policy on success. See [`use_query_with_policy`] for the server-wins +/// semantics. pub fn fetch_query_with_policy<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -431,7 +286,22 @@ pub fn fetch_query_with_policy<T, E, C, F, Fut>( F: Fn() -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<Fetched<T>, E>> + Send + 'static, { - // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId. + fetch_query_impl(entity, fetcher, cx); +} + +/// Shared body of [`fetch_query`] and [`fetch_query_with_policy`]. +fn fetch_query_impl<T, E, C, F, Fut, Out>( + entity: &Entity<QueryResource<T, E>>, + fetcher: F, + cx: &mut Context<C>, +) where + T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + std::fmt::Debug + 'static, + C: 'static, + Out: FetchedLike<T> + Send + 'static, + F: Fn() -> Fut + Send + 'static, + Fut: std::future::Future<Output = Result<Out, E>> + Send + 'static, +{ let (Some(request_id), _signal) = begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None) else { @@ -439,31 +309,18 @@ pub fn fetch_query_with_policy<T, E, C, F, Fut>( }; let weak = entity.downgrade(); let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Same deliberate detach as `fetch_query` (audit #6 NOT applied to queries). let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; }); task.detach(); } -/// Like [`fetch_query`], but the fetcher receives a [`QuerySignal`] that it can -/// check periodically for cooperative cancellation. -/// -/// The fetcher signature is `FnOnce(QuerySignal) -> Fut`. Since `FnOnce` closures -/// are consumed on the first call, retries are not possible. -/// -/// Calls `begin_request` to obtain a fresh `RequestId` and reads the signal -/// *after* `begin_request` creates it (v2 fix for stale signal). -/// -/// Audit fix #3: If `begin_request` returns `None`, no async fetch task is spawned. -/// -/// # Signal Cancellation (Audit Finding #8) +/// Like [`fetch_query`], but the fetcher receives a [`QuerySignal`] it can +/// check for cooperative cancellation. /// -/// The `accept_current_request` guard is the authoritative protection against -/// stale writes. A previous `signal.is_cancelled()` check after the fetcher -/// returned was removed -- it was a best-effort optimization with a TOCTOU -/// window that provided no guarantees. The two-phase protocol (accept + complete) -/// correctly handles all cases where a newer request supersedes the current one. +/// The fetcher is `FnOnce`, so no retries are possible. Stale writes are +/// prevented by the `accept_current_request` guard, not by a +/// `signal.is_cancelled()` check after the fetch (that would be racy). pub fn fetch_query_with_signal<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -475,35 +332,20 @@ pub fn fetch_query_with_signal<T, E, C, F, Fut>( F: FnOnce(QuerySignal) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - // Audit fix #3: Only spawn fetch if begin_request returns a real RequestId. let (Some(request_id), signal) = begin_request_on_entity(entity, cx, QueryFetchMode::Normal, None) else { return; }; - // Audit H3: `signal` is the one begin_request just created, read in the - // same entity.update as the begin (no separate read pass). let signal = signal.unwrap_or_else(QuerySignal::new); let weak = entity.downgrade(); - // FnOnce fetchers can only be called once, so retries are not possible. - // Audit fix #6 (deliberate detach): plain-query fetches are NOT stored on - // the resource. The `accept_current_request` guard below is the - // authoritative protection against stale writes (see the module-level - // docs), so the task is detached and self-terminates once the entity is - // dropped. let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { let result = fetcher(signal).await; let now_ms = current_time_ms(); let Some(entity) = weak.upgrade() else { return }; - // Audit fix #7/#13: Only call cx.notify() when the result is actually - // accepted. When accept_current_request returns None, no state change - // occurred and no re-render is needed. - // - // Audit fix #8: Removed the signal.is_cancelled() check. The - // accept_current_request guard is the authoritative protection. let _ = entity.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { match result { diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs index f5dc773..6346842 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs @@ -1,7 +1,6 @@ -//! Public fetch helpers for infinite query entities. -//! -//! These functions are called from event handlers (e.g., on scroll-to-bottom, -//! on button click) to fetch the next or previous page. +//! Public fetch helpers for infinite query entities, called from event +//! handlers (scroll-to-bottom, button click) to fetch the next or previous +//! page. use gpui::{BorrowAppContext as _, Context, Entity}; @@ -13,21 +12,11 @@ use super::fetch_runners::{ }; use crate::hook::current_time_ms; -// ── Public fetch helpers ───────────────────────────────────────────────── - /// Initiate a fetch of the next page on an existing infinite query entity. /// -/// Call this from event handlers (e.g., on scroll-to-bottom, on button click). -/// It reads the last page from the entity and passes it to the fetcher. -/// -/// # v2 Notes -/// -/// - The old signal is cancelled before creating a new one (v2 fix). -/// - `max_pages` enforcement uses `Vec::drain` instead of O(n^2) `remove(0)`. -/// - The `RequestId` from `begin_fetch_next` is captured and passed through -/// to the completion, preventing stale-ID acceptance (audit fix). -/// - Uses two-phase completion protocol for correctness. -/// - Applies the retry policy stored on the entity. +/// Reads the last page from the entity and passes it to the fetcher. Applies +/// the retry policy stored on the entity; if a fetch is already in flight the +/// old signal is cancelled and the new request supersedes it. /// /// # Example /// @@ -60,15 +49,9 @@ pub fn fetch_next_page_infinite<T, E, C, FNext, Fut>( /// Initiate a fetch of the previous page on an existing infinite query entity. /// -/// Similar to [`fetch_next_page_infinite`] but fetches in the backward direction. -/// The fetcher receives the first page (not the last) so it can determine -/// the cursor for the previous page. -/// -/// # v2 Notes -/// -/// - The old signal is cancelled before creating a new one (v2 fix). -/// - Uses two-phase completion protocol for correctness. -/// - Applies the retry policy stored on the entity. +/// Like [`fetch_next_page_infinite`] but backward: the fetcher receives the +/// first page (not the last) so it can determine the cursor for the previous +/// page. pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: FPrev, @@ -83,14 +66,8 @@ pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( fetch_page_infinite(entity, fetcher, cx, PageDirection::Previous); } -// ── Private shared implementation ──────────────────────────────────────── - -/// Shared body of [`fetch_next_page_infinite`] / [`fetch_previous_page_infinite`]. -/// -/// The two public helpers are ~90% duplicated, differing only by `direction`: -/// which `begin_fetch_*_with_id` is called and which runner is spawned. This -/// private fn unifies them; behavior is identical to the previous inlined -/// implementations. +/// Shared body of [`fetch_next_page_infinite`] / [`fetch_previous_page_infinite`]; +/// only `direction` differs (which `begin_fetch_*` call and which runner). fn fetch_page_infinite<T, E, C, F, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: F, @@ -105,11 +82,10 @@ fn fetch_page_infinite<T, E, C, F, Fut>( { let weak = entity.downgrade(); - // #fix: Use the bucket's persistent sequencer via QueryClient for - // monotonic RequestIds. The pre-allocated ID is passed through to - // begin_fetch_*_with_id so the resource's active_request_id matches - // the bucket's counter. Falls back to None (transient sequencer) when - // no QueryClient is available. + // Mint the RequestId from the bucket's persistent sequencer so ids stay + // monotonic; pass it into begin_fetch_*_with_id so the resource's + // active_request_id matches the bucket's counter. Falls back to None + // (transient sequencer) when no QueryClient is available. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { @@ -129,15 +105,10 @@ fn fetch_page_infinite<T, E, C, F, Fut>( } }); - // #fix #2: Removed unconditional cx.notify() here. The InfiniteQueryObserver - // observes status changes and will trigger re-renders when status transitions - // from Idle/Success to Loading. - if let Some(request_id) = request_id { let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Audit fix #6: store the spawned task on the resource so a replacement - // fetch (or entity drop on unmount) aborts the prior in-flight task - // instead of leaving it detached and running. + // Stored on the resource so a replacement fetch or unmount aborts the + // prior in-flight task. let task: gpui::Task<()> = cx.spawn(async move |_this, cx| match direction { PageDirection::Next => { run_fetch_next_page_with_id(&weak, &fetcher, request_id, &retry_policy, cx).await; diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs index 01b5cba..44c2cf3 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs @@ -1,7 +1,5 @@ -//! Internal async fetch runners for infinite query page fetches. -//! -//! These are the retry-aware async functions that execute the actual fetch -//! operations with captured `RequestId`s and two-phase completion protocol. +//! Internal async fetch runners for infinite query page fetches: retry-aware, +//! running with a captured [`RequestId`] and two-phase completion. use std::sync::Arc; @@ -9,14 +7,12 @@ use crate::core::{InfiniteQueryResource, RequestId}; use crate::hook::{current_time_ms, read_entity}; -// ── Internal fetch runners ─────────────────────────────────────────────── - /// Direction of an infinite-query page fetch. /// -/// The next/previous fetch runners are ~90% identical, differing only in -/// which page they read as the cursor and which `is_next` flag they pass to -/// [`InfiniteQueryResource::complete_success_with_guard`]. This enum -/// parameterizes that single difference so the shared body lives in one place. +/// The next/previous runners differ only in which page they read as the +/// cursor and which `is_next` flag they pass to +/// [`InfiniteQueryResource::complete_success_with_guard`]; this enum +/// parameterizes that difference so the body lives in one place. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum PageDirection { Next, @@ -26,10 +22,7 @@ pub(super) enum PageDirection { impl PageDirection { /// The `is_next` flag handed to `complete_success_with_guard`. fn is_next(self) -> bool { - match self { - PageDirection::Next => true, - PageDirection::Previous => false, - } + matches!(self, PageDirection::Next) } /// The page used as the fetcher cursor: the last page for `Next`, the @@ -46,18 +39,13 @@ impl PageDirection { } } -/// Execute a fetch-page operation with a captured `RequestId` in the given -/// [`PageDirection`]. +/// Execute a page fetch with a captured `RequestId` in the given direction. /// -/// #fix #5/#6: The `request_id` is the one returned from `begin_fetch_*`, -/// not re-read after the fetcher completes. This prevents stale-ID acceptance -/// when concurrent fetches are in flight. -/// -/// #fix #12: Uses two-phase completion (`accept_current_request` then -/// `complete_success_with_guard`/`complete_failure_with_guard`) to close -/// the race window between reading active_request_id and completing. -/// -/// #fix #13: Applies retry policy on fetch failure. +/// The `request_id` is the one returned from `begin_fetch_*`, not re-read +/// after the fetcher completes, and completion is two-phase +/// (`accept_current_request` then complete) so a superseded request can never +/// write. After each retry delay the signal and the active request are checked +/// in one read pass; a cancelled or superseded fetch stops retrying. async fn run_fetch_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, @@ -74,12 +62,8 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( let mut attempt: u32 = 0; loop { - // Audit fix #5/#73: Read the cursor page inside the loop via the cheap - // refcount-bumped `Arc<T>` accessor (no full page clone), and re-read - // it fresh each retry so the fetcher sees up-to-date data. We capture - // the `Arc<T>` and hand the fetcher an `Option<&T>` via `as_ref` — the - // fetcher signature is unchanged. For `Next` this is the last page; - // for `Previous` it is the first page. + // Re-read the cursor fresh each attempt so the fetcher sees + // up-to-date data; the Arc access is a cheap refcount bump. let cursor_page_arc: Option<Arc<T>> = { let Some(e) = entity.upgrade() else { return }; read_entity(&e, cx, |r, _| direction.cursor_page_arc(r)).flatten() @@ -93,7 +77,6 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( match result { Ok((page, has_more)) => { - // #fix #12: Two-phase completion — accept then complete. let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.complete_success_with_guard( @@ -103,19 +86,14 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( direction.is_next(), now_ms, ); - // Notify on terminal state change (success). cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - } else { - // stale request, result discarded } }); return; } Err(error) => { - // #fix #13: Apply retry policy. if retry_policy.should_retry(attempt) { let delay_ms = retry_policy.delay_for_attempt(attempt); attempt += 1; @@ -126,11 +104,8 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( .await; } - // #fix #7 / Audit H14: After the retry delay, check whether - // the signal has been cancelled AND whether this request is - // still the active one in a single read_entity pass (was two - // sequential reads). A cancelled or superseded fetch should - // not retry. + // No notify during retry wait: status stays Loading and + // the InfiniteQueryObserver dedupes. let Some(e) = entity.upgrade() else { return }; let (cancelled, still_current) = read_entity(&e, cx, |r, _| { ( @@ -139,37 +114,16 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( ) }) .unwrap_or((true, false)); - if cancelled { - return; - } - - // Audit fix #73/#118: After the delay, also confirm this - // request is still the active one. If a newer fetch has - // superseded it, bail out instead of retrying a stale op. - if !still_current { + if cancelled || !still_current { return; } - - // #fix #1: No cx.notify() during retry wait. Status stays - // LoadingWithData/LoadingEmpty during retries, so the - // InfiniteQueryObserver deduplicates and no re-render is - // needed until terminal state (success or final failure). - - // Loop to retry } else { - // No more retries — complete with failure using two-phase protocol - // Audit fix #72: notify is moved INSIDE the accept arm so a - // discarded (stale) result does not trigger a spurious re-render. let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.complete_failure_with_guard(guard, error); - // Notify on terminal state change (failure). cx.notify(); - // B2: precise dirty signal for the persistence layer. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - } else { - // stale request, result discarded } }); return; @@ -179,11 +133,8 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( } } -/// Execute a fetch-next-page operation with a captured `RequestId`. -/// -/// Thin direction-specific wrapper around [`run_fetch_page_with_id`]. See that -/// function's docs for the shared behavior (captured `RequestId`, two-phase -/// completion, retry policy). +/// Execute a fetch-next-page operation with a captured `RequestId`. Thin +/// direction-specific wrapper around [`run_fetch_page_with_id`]. pub(super) async fn run_fetch_next_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, @@ -207,13 +158,8 @@ pub(super) async fn run_fetch_next_page_with_id<T, E, F, Fut>( .await; } -/// Execute a fetch-previous-page operation with a captured `RequestId`. -/// -/// Thin direction-specific wrapper around [`run_fetch_page_with_id`]. Same -/// fixes as [`run_fetch_next_page_with_id`]: -/// - Captured `RequestId` prevents stale-ID acceptance -/// - Two-phase completion protocol -/// - Retry policy on failure +/// Execute a fetch-previous-page operation with a captured `RequestId`. Thin +/// direction-specific wrapper around [`run_fetch_page_with_id`]. pub(super) async fn run_fetch_previous_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, diff --git a/crates/gpui-query/src/hook/use_infinite_query/hook.rs b/crates/gpui-query/src/hook/use_infinite_query/hook.rs index 75da4f6..661f308 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/hook.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/hook.rs @@ -1,33 +1,5 @@ -//! The `use_infinite_query` hook — ergonomic infinite scrolling / pagination -//! for GPUI components. -//! -//! # v2 Improvements -//! -//! - Signal is properly cancelled when a page fetch replaces an in-flight request -//! - `max_pages` defaults to `Some(50)` to prevent unbounded memory growth -//! - Uses `InfiniteQueryObserver` for status-deduplication (avoids unnecessary re-renders) -//! - `RequestSequencer` is persistent per resource (via `QueryClient` bucket) -//! - `RequestId` is captured from `begin_fetch_next` and passed through to completion -//! - Uses two-phase completion protocol (`accept_current_request` + guard) -//! - `fetch_next` closure actually triggers a fetch (no longer a no-op stub) -//! - Retry policy is stored on the entity and applied consistently to all page fetches -//! - Entity is registered with `QueryClient` for shared caching and GC -//! -//! # Audit 3 fixes -//! -//! - **#1**: Removed `cx.notify()` from retry-wait branches in fetch runners (status -//! stays Loading during retries, so InfiniteQueryObserver deduplicates anyway) -//! - **#2**: Removed unconditional `cx.notify()` from `fetch_next_page_infinite` and -//! `fetch_previous_page_infinite` before fetch completes -//! - **#3**: Read page data by reference inside entity update closure instead of cloning -//! - **#4/#6/#8/#9**: Use `QueryClient::next_request_id_for_infinite_key` for persistent -//! sequencers instead of creating transient ones per call -//! - **#5**: Use match-based fallback for `InfiniteQueryObserver::observe` instead of -//! `expect()` to avoid production panics -//! - **#7**: Check signal cancellation after retry delay to avoid retrying cancelled fetches -//! - **#10**: Store `retry_policy` on entity, read it in fetch helpers instead of using -//! `RetryPolicy::default()` -//! - **#12**: Removed the no-op stub `fetch_next` closure from the return type +//! The `use_infinite_query` hook: infinite scrolling / pagination for GPUI +//! components. //! //! # Usage //! @@ -79,27 +51,17 @@ use super::fetch_runners::run_fetch_next_page_with_id; use crate::hook::current_time_ms; use crate::hook::options::InfiniteQueryOptions; -// ── Hook ───────────────────────────────────────────────────────────────── - /// Hook for infinite scrolling / pagination. /// -/// Creates an [`InfiniteQueryResource`] entity and subscribes to it so the -/// component re-renders on state changes. Returns: +/// Creates an [`InfiniteQueryResource`] entity (registered with +/// [`QueryClient`] for shared caching, GC, and bulk invalidation) and +/// subscribes via an observer that dedupes on status, so intermediate retry +/// updates do not re-render. Returns the entity and the subscription; store +/// both. The retry policy from options is stored on the entity and applied to +/// every page fetch. /// -/// 1. The entity holding the page data -/// 2. The subscription (store to keep the observation alive) -/// -/// The fetcher receives `Option<&T>` (the last page, if any) and must return -/// `Result<(T, bool), E>` where `T` is the new page data and `bool` indicates -/// whether more pages exist. -/// -/// # v2 Notes -/// -/// - `max_pages` defaults to `Some(50)` via [`InfiniteQueryOptions`]. -/// - The signal is properly cancelled when a new fetch replaces an in-flight request. -/// - `InfiniteQueryObserver` provides status-deduplication to avoid unnecessary re-renders. -/// - The entity is registered with [`QueryClient`] for shared caching and GC. -/// - The retry policy from options is stored on the entity and used for all page fetches. +/// The fetcher receives `Option<&T>` (the last page, if any) and returns +/// `Result<(T, bool), E>` where the bool says whether more pages exist. pub fn use_infinite_query<T, E, C, FNext, Fut>( options: InfiniteQueryOptions, fetch_next: FNext, @@ -121,8 +83,6 @@ where .. } = options; - // #fix #8/#9: Route entity creation through QueryClient for shared - // caching, GC, and participation in bulk operations (invalidate/reset/remove). let entity = if cx.has_global::<QueryClient>() { cx.update_global::<QueryClient, _>(|client, cx| { client.infinite_resource_with_policies::<T, E>(key, cache_policy, request_policy, cx) @@ -136,37 +96,23 @@ where Call cx.set_global(QueryClient::new()) in your app setup." ); } - cx.new(|_| { - // Audit H12: max_pages is NOT set here — the unconditional - // entity.update below already applies it for both QueryClient and - // standalone entities, so setting it here was redundant on the - // standalone path. - InfiniteQueryResource::new(key, cache_policy, request_policy) - }) + // max_pages and retry_policy are applied unconditionally below for + // both paths. + cx.new(|_| InfiniteQueryResource::new(key, cache_policy, request_policy)) }; - // Audit fix #117: Apply max_pages (QueryClient-created entities don't set it) - // and store the retry policy on the entity in a single update + notify pass. - // #fix #10: the retry policy is stored on the entity so that - // fetch_next_page_infinite / fetch_previous_page_infinite can read it - // from the entity instead of using RetryPolicy::default(). + // QueryClient-created entities don't set max_pages; apply it and store + // the retry policy in one pass. entity.update(cx, |resource, cx| { if let Some(max) = max_pages { resource.set_max_pages(Some(max)); } - // Audit H13: clone for the entity store; the original local is reused - // in the initial-fetch spawn below (avoids the re-read + second clone - // that previously happened at the spawn site). resource.set_retry_policy(retry_policy.clone()); cx.notify(); }); - // #fix #7/#11: Use InfiniteQueryObserver for status deduplication instead - // of a raw cx.observe that fires on every entity mutation. let observer = InfiniteQueryObserver::new(&entity); - // #fix #5: Use match-based fallback instead of expect() to avoid production - // panics. Mirrors the pattern used in use_query_manual. let Some(subscription) = observer.observe(cx) else { #[cfg(debug_assertions)] panic!( @@ -175,20 +121,16 @@ where ); #[cfg(not(debug_assertions))] { - // Audit fix #5 / H10: silently fall back to a no-op subscription in - // release builds (matching use_query_manual and use_mutation); the - // eprintln! was inconsistent with the rest of the hook layer. return (entity, Subscription::new(|| {})); } }; // Start the initial fetch if idle - let should_fetch = entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle); - if should_fetch { - // #fix: Use the bucket's persistent sequencer via QueryClient so - // RequestIds are monotonic across the resource lifetime. The - // pre-allocated ID is passed through to begin_fetch_next_with_id so - // the resource's active_request_id matches the bucket's counter. + if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { + // Mint the RequestId from the bucket's persistent sequencer so ids + // stay monotonic across the resource lifetime; pass it into + // begin_fetch_next_with_id so the resource's active_request_id matches + // the bucket's counter. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { @@ -198,9 +140,6 @@ where None }; - // Pass the pre-allocated ID (or None) directly into - // begin_fetch_next_with_id, which uses it instead of creating - // a separate transient sequencer. let request_id = entity.update(cx, |resource, _| { let now_ms = current_time_ms(); resource.begin_fetch_next_with_id(maybe_request_id, now_ms) @@ -209,14 +148,10 @@ where if let Some(request_id) = request_id { let weak = entity.downgrade(); let fetcher = fetch_next; - // Audit H13: reuse the retry_policy local set above instead of - // re-reading + re-cloning from the entity. let retry = retry_policy.clone(); let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { run_fetch_next_page_with_id(&weak, &fetcher, request_id, &retry, cx).await; }); - // Audit fix #6: store the task so a replacement fetch (or entity - // drop on unmount) aborts the prior in-flight initial fetch. entity.update(cx, |r, _| r.set_current_task(task)); } } diff --git a/crates/gpui-query/src/hook/use_query_select.rs b/crates/gpui-query/src/hook/use_query_select.rs index 35a8bfc..e2d0505 100644 --- a/crates/gpui-query/src/hook/use_query_select.rs +++ b/crates/gpui-query/src/hook/use_query_select.rs @@ -1,17 +1,10 @@ -//! The `use_query_select` hook — combines `use_query` with a [`SelectTransform`]. +//! The `use_query_select` hook: combines `use_query` with a +//! [`SelectTransform`]. //! -//! TanStack Query's `select` option transforms cached data into a derived shape, -//! re-running only when data changes. This module provides the same pattern for -//! gpui-query: it wraps a [`QueryResource`] with a [`MappedQueryResource`] -//! entity that applies the transform on each observer notification. -//! -//! # Why a separate hook? -//! -//! Rust's type system requires knowing `T` (source) and `U` (output) at compile -//! time. Since `QueryOptions` is not generic over a transform output type, the -//! `select` field cannot live on `QueryOptions` without making the entire options -//! struct generic. Instead, [`use_query_select`] is a standalone hook that accepts -//! the transform as a separate parameter and returns a +//! Mirrors TanStack Query's `select` option: cached data is projected into a +//! derived shape, re-running only when the data changes. Rust needs `U` (the +//! transform output) at compile time and `QueryOptions` is not generic, so the +//! transform is a separate parameter and the hook returns a //! `MappedQueryResource<T, U, E>` entity. //! //! # Usage @@ -55,11 +48,8 @@ use crate::core::{MappedQueryResource, QueryResource, SelectTransform}; use super::{QueryOptions, use_query}; /// The result of [`use_query_select`]: the projected view entity, the -/// underlying query entity, and the pair of subscriptions that keep both -/// observations alive. -/// -/// Introduced as a type alias (audit #96) to satisfy `clippy::type_complexity` -/// on the public hook signature and to give callers a name to reference. +/// underlying query entity, and the subscriptions that keep both observations +/// alive. pub type QuerySelectResult<T, U, E> = ( Entity<MappedQueryResource<T, U, E>>, Entity<QueryResource<T, E>>, @@ -68,37 +58,16 @@ pub type QuerySelectResult<T, U, E> = ( /// Subscribe to a query and project its data through a [`SelectTransform`]. /// -/// This is the "select" integration point for the hook layer (audit #3, HIGH -/// finding). It: -/// -/// 1. Calls [`use_query`] to create/subscribe to the underlying `QueryResource`. -/// 2. Creates a `MappedQueryResource<T, U, E>` entity seeded with the current -/// source data. -/// 3. Observes the source entity so that every time it changes, the mapped -/// resource's source data is updated from the fresh `QueryResource::data()`. -/// The transform itself is applied lazily when -/// [`MappedQueryResource::data()`] is called. -/// -/// # Returns -/// -/// A tuple of: -/// - `Entity<MappedQueryResource<T, U, E>>` — the projected view entity -/// - `Entity<QueryResource<T, E>>` — the underlying query entity (for status, -/// error, refetch, etc.) -/// - `(Subscription, Subscription)` — the query subscription and the mapped -/// observer subscription. Store both to keep observations alive. -/// -/// # Transform cost -/// -/// The transform closure runs every time `mapped.data()` is called (no output -/// cache). For expensive transforms, cache the result: +/// Creates the underlying query via [`use_query`], seeds a +/// `MappedQueryResource<T, U, E>` with the current data, and observes the +/// source entity so the mapped resource refreshes whenever the data actually +/// changes. The transform itself runs lazily on every `mapped.data()` call +/// (no output cache), so reuse the result if it is expensive: /// /// ```no_run /// use gpui_query::core::{MappedQueryResource, SelectTransform}; /// # fn _doc(mapped: &gpui::Entity<MappedQueryResource<Vec<String>, usize, ()>>, cx: &gpui::App) { -/// -/// let count = mapped.read(cx).data(); // transform runs once -/// // reuse `count` below +/// let count = mapped.read(cx).data(); // transform runs once; reuse `count` /// # } /// ``` pub fn use_query_select<T, U, E, C, F, Fut>( @@ -115,52 +84,25 @@ where F: Fn(crate::core::QuerySignal) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - // Step 1: Create the underlying query entity and start the fetch. let (query_entity, query_subscription) = use_query(options, fetcher, cx); - // Step 2: Seed the mapped resource with whatever data the query has now. - // The source `QueryResource` owns `T` by value and only lends `&T`, so the - // initial seed clones `T` once into an `Arc<T>` (audit #20). Subsequent - // updates (Step 3) only re-clone `T` when the content has actually changed. + // Seed the mapped resource with whatever data the query has now. The + // source owns `T` by value and only lends `&T`, so this is the one + // unavoidable clone. let initial_data: Option<Arc<T>> = query_entity.read_with(cx, |r, _| r.data().map(|d| Arc::new(d.clone()))); let mapped = MappedQueryResource::new(initial_data, transform); let mapped_entity = cx.new(|_| mapped); - // Step 3: Observe the query entity so the mapped resource stays in sync. - // Every time the query entity is updated (fetch completes, refetch, cache - // invalidation, etc.), we read the fresh source data once, compare it - // against the cached source, and only notify + re-store when it changed. - // - // Audit H1: the previous version cloned `T` into a fresh `Arc<T>` on - // EVERY notification (even the common case where data was unchanged) just - // to drive the `PartialEq` comparison. This version avoids that O(|T|) - // clone on unchanged notifications: - // 1. Clone the cached `Arc<T>` out of the mapped resource first via - // `source_arc()` — a cheap refcount bump, no `T` clone. The mapped - // borrow ends with that call (audit #115 preserved: no nested borrow). - // 2. Read the fresh `&T` straight from the source entity and compare - // `&T` vs `&T` without cloning `T`. - // 3. Only when the content actually changed do we clone `T` into an - // `Arc<T>` to hand to `update_source`, exactly as before. - // Net: unchanged notifications (the common case) pay one cheap `Arc::clone` - // instead of a full `T` clone + allocation; changed notifications behave - // identically (same `update_source` + `notify`). - // - // Audit fix #4 / #20: source data is still stored as `Option<Arc<T>>`, so - // `MappedQueryResource` clones (derived views, entity cloning) remain cheap - // `Arc::clone`s and storage stays shared. + // Keep the mapped resource in sync. On each notification, bump the cached + // `Arc<T>` out (cheap), compare `&T` vs `&T` without cloning, and only + // clone + update + notify when the content actually changed. The borrow on + // `mapped` ends before the source read, so nothing nests. let mapped_weak = mapped_entity.downgrade(); let mapped_subscription = cx.observe(&query_entity, move |_, entity, cx| { if let Some(mapped) = mapped_weak.upgrade() { - // Audit fix #115 / H1 step 1: Read the cached source `Arc<T>` out - // of the mapped resource FIRST, as an owned value (cheap refcount - // bump via `source_arc`). The mapped borrow ends here, so the - // `entity.read(cx)` below does NOT nest inside it. let cached: Option<Arc<T>> = mapped.read_with(cx, |m, _| m.source_arc()); - // H1 step 2: Compare cached `&T` vs fresh `&T` WITHOUT cloning T. - // `cached` is owned, so no nested borrow is taken on `mapped`. let changed = entity.read_with(cx, |r, _| match (&cached, r.data()) { (Some(c), Some(fresh)) => c.as_ref() != fresh, (None, None) => false, @@ -168,14 +110,7 @@ where }); if changed { - // H1 step 3: Only now clone `T` into an `Arc<T>` for the - // update (the source `QueryResource` owns `T` by value and only - // lends `&T`, so this single clone is unavoidable on change). let fresh: Option<Arc<T>> = entity.read(cx).data().map(|d| Arc::new(d.clone())); - // Audit fix #116: Notify after updating the mapped source so - // third-party observers of the mapped entity (not just the - // primary caller, which already re-renders via the query - // subscription) see the derived change. Safe and correct. mapped.update(cx, |m, cx2| { m.update_source(fresh); cx2.notify(); diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs index ef8ead7..a5d6935 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs @@ -12,7 +12,6 @@ use crate::tests::test_support::*; fn test_use_mutation_creates_idle_entity(cx: &mut TestAppContext) { setup_query_client(cx); - // Audit fix #47: use the shared `HookHarness` instead of a one-off `struct H`. let harness = cx.new(|cx| { let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); let resource = entity.read(cx); @@ -33,7 +32,6 @@ fn test_use_mutation_creates_idle_entity(cx: &mut TestAppContext) { fn test_mutate_triggers_execution_and_completes(cx: &mut TestAppContext) { setup_query_client(cx); - // Audit fix #47: use the shared `HookHarness` instead of a one-off `struct H`. let harness = cx.new(|cx| { let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); mutate( @@ -51,8 +49,6 @@ fn test_mutate_triggers_execution_and_completes(cx: &mut TestAppContext) { cx.run_until_parked(); - // Audit fix #48: adopt the shared `run_until_parked_and_read` helper instead - // of `cx.run_until_parked()` + a manual `cx.update` read. let data = run_until_parked_and_read(cx, &harness, |h, cx| { let resource = h.entity.read(cx); (resource.is_success(), resource.data().cloned()) @@ -112,8 +108,7 @@ fn test_mutate_rejects_concurrent_calls(cx: &mut TestAppContext) { ); assert!(entity.read(cx).is_loading()); - // Attempt a second mutate while the first is still loading. - // The second call should be rejected (no-op) per audit fix #8. + // A second mutate while the first is still loading is a no-op. mutate( &entity, "second".to_string(), @@ -181,7 +176,7 @@ fn test_mutate_double_while_loading_second_rejected(cx: &mut TestAppContext) { cx, ); - // Second mutate while still loading — should be rejected. + // Second mutate while still loading: rejected. mutate( &entity, "second".to_string(), diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs index 4ac76d0..97d87aa 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs @@ -204,10 +204,9 @@ fn test_use_query_force_fetch_option_set(cx: &mut TestAppContext) { fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { setup_test(cx); - // Verify that when a second fetch replaces an in-flight fetch, the first - // fetcher's signal is cancelled. We use use_query_manual + fetch_query - // because use_query only auto-fetches when Idle — a second use_query with - // the same key while LoadingEmpty would not trigger begin_request. + // When a second fetch replaces an in-flight fetch, the first fetcher's + // signal is cancelled. use_query_manual + fetch_query because use_query + // only auto-fetches when Idle. let gate = Gate::new(); let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index f49301e..cb97fdd 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -1,20 +1,15 @@ -//! Regression tests for audit findings #131 and #132. +//! Regression tests for stored-task cancellation and the cross-context +//! mutate race. //! -//! #131: A stored mutation/infinite task is cancelled when superseded. The -//! production mechanism is `CurrentTask` + `set_current_task`: storing a new -//! task drops the previous `gpui::Task`, and dropping a GPUI task aborts its -//! future. We verify that an in-flight mutation whose entity is dropped (the -//! unmount/replacement path) has its post-gate side effect suppressed — the -//! gated mutator never reaches the code after `gate.wait(...).await` because -//! the task is aborted. +//! Stored mutation/infinite tasks are cancelled when superseded: `set_current_task` +//! drops the previous `gpui::Task`, and dropping a GPUI task aborts its future. +//! `test_stored_mutation_task_aborted_when_entity_dropped` checks that an in-flight +//! mutation whose entity is dropped never runs its post-gate side effect. //! -//! #132: The real TOCTOU race — two `mutate()` calls issued from *different* -//! async spawn contexts. The existing `test_mutate_double_while_loading_*` -//! tests cover only the synchronous double-call within one `cx.update` scope. -//! Here we interleave via independent background spawns + [`Gate`]; the second -//! mutate must still be rejected while the first is `Loading`, proving the -//! atomic check+begin guard (audit #7/#8) holds across genuinely concurrent -//! callers. +//! `test_mutate_from_two_spawn_contexts_second_rejected` races two `mutate()` +//! calls from different async spawn contexts (the synchronous double-call case +//! is covered by `test_mutate_double_while_loading_*`); the atomic check+begin +//! guard must still reject the second while the first is Loading. use std::sync::{Arc, Mutex}; @@ -24,15 +19,14 @@ use crate::core::{MutationResource, QueryError}; use crate::hook::*; use crate::tests::test_support::*; -// ── #131: stored task is cancelled when its entity is dropped ─────────────── +// Stored task is cancelled when its entity is dropped. #[gpui::test] fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext) { setup_test(cx); - // Side-effect counter incremented *after* the gate is released. If the - // task is correctly aborted on entity drop, the post-gate increment never - // runs and the counter stays at 0. + // Counter incremented after the gate is released: stays at 0 if the task + // is correctly aborted on entity drop. let landed = Arc::new(Mutex::new(0u32)); let landed_clone = landed.clone(); @@ -40,8 +34,6 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); - // Create the mutation entity and start a gated mutate, then drop the - // entity handle while the mutator is still parked on the gate. { #[allow(dead_code)] struct H { @@ -83,22 +75,19 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext ); }); - // Dropping `harness` drops the entity → drops MutationResource → drops - // CurrentTask → drops the gpui::Task → aborts the gated future. + // Dropping `harness` drops the entity and its CurrentTask, which + // aborts the gated future. drop(harness); } - // GPUI defers the actual entity release until the next `App::update` flushes - // effects (`release_dropped_entities` runs at the end of `App::update`, not - // during `run_until_parked`). Force that flush here so the entity — and its - // stored `CurrentTask`/`gpui::Task` — is dropped *before* we release the - // gate. Without this, the parked future would still be alive when the gate - // opens, run its post-gate side effect, and `landed` would read 1. + // GPUI releases dropped entities at the end of `App::update`, not during + // `run_until_parked`. Force that flush so the entity is gone before the + // gate opens, otherwise the parked future would still run its post-gate + // side effect and `landed` would read 1. cx.update(|_| {}); - // Release the gate. If the task had *not* been aborted, the mutator would - // now proceed past the gate and increment `landed`. The aborted task does - // not, so `landed` stays 0. + // If the task had not been aborted, the mutator would proceed past the + // gate and increment `landed`. gate.release(); cx.run_until_parked(); @@ -106,29 +95,21 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext *landed.lock().unwrap(), 0, "superseded/dropped mutation task must be aborted — its post-gate side \ - effect should never land (#131: CurrentTask Drop-cancellation)" + effect should never land" ); } -// ── #132: TOCTOU race — two mutate() calls from different spawn contexts ──── +// Two mutate() calls from different spawn contexts. // -// The existing `test_mutate_double_while_loading_*` tests issue both calls -// synchronously inside one `cx.update` scope. This test fires the second -// `mutate()` from an *independent* `Context::spawn` task that re-enters the -// harness entity via `AsyncApp` — the genuine cross-context race shape. The -// atomic check+begin guard (audit #7/#8) must still reject it while the first -// is `Loading`. -// -// We assert the rejection contract directly (the #132 concern): the second -// mutate's fetcher never runs, the first mutate stays in-flight and -// uncorrupted, and there is no double execution. We deliberately do NOT assert -// post-release completion of the gated first mutate here: a completed -// `Context::spawn` task interacts with the `TestAppContext` executor such that -// subsequent `run_until_parked` calls stop draining the background-timer -// wake-up chain that `Gate::wait` relies on (verified empirically: even a -// no-op spawn before `gate.release()` prevents the first task from -// completing). Gated-mutation completion is already covered by -// `retry_reset_tests`; this test's job is the cross-context rejection. +// The second `mutate()` fires from an independent `Context::spawn` task that +// re-enters the harness entity via `AsyncApp`: the real cross-context race +// shape. We assert the rejection contract directly (second fetcher never +// runs, first stays in-flight and uncorrupted). We deliberately do NOT assert +// post-release completion of the gated first mutate: a completed +// `Context::spawn` task interacts with the `TestAppContext` executor such +// that later `run_until_parked` calls stop draining the background-timer +// wake-up chain `Gate::wait` relies on. Gated-mutation completion is covered +// by `retry_reset_tests`. #[gpui::test] fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) { @@ -137,14 +118,13 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) let first_call_count = Arc::new(Mutex::new(0u32)); let second_call_count = Arc::new(Mutex::new(0u32)); - // A gate that keeps the *first* mutate's fetcher in flight while the second - // mutate is issued from a different spawn context. + // Keeps the first mutate's fetcher in flight while the second mutate is + // issued from a different spawn context. let gate = Gate::new(); let gate_for_first = gate.clone(); let executor = cx.background_executor.clone(); - // The first mutate is started inside `cx.new` (the proven gated-mutation - // shape); its fetcher parks on the gate so the mutation stays `Loading`. + // The first fetcher parks on the gate so the mutation stays Loading. #[allow(dead_code)] struct H { mutation: Entity<MutationResource<String, String, QueryError>>, @@ -178,15 +158,12 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) ); }); - // Second mutate — issued from a DIFFERENT async spawn context. We spawn on - // the harness `Context<H>` (so the task runs under `run_until_parked` and - // receives a `WeakEntity<H>` + `AsyncApp`), then re-enter the harness - // entity from inside that distinct context to call `mutate`. This is the - // real TOCTOU shape: an independent task racing the in-flight one. + // Second mutate, issued from a different async spawn context: spawn on the + // harness `Context<H>` and re-enter the entity via `AsyncApp` to call + // mutate. An independent task racing the in-flight one. let sc = second_call_count.clone(); - // T4: signal completion of the spawned second mutate via a Gate instead of - // busy-waiting on a Mutex<bool>. The spawned task releases the gate once it - // has executed its spawn context; the main task drains once. + // A Gate signals that the spawned task ran its context; the main task + // drains once. let second_ran = Gate::new(); let second_ran_clone = second_ran.clone(); let _second_task = harness.update(cx, |_this, cx| { @@ -212,9 +189,8 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) }) }); - // Drain the executor once so the spawned second mutate runs (and, because - // the first is still Loading, is rejected). The Gate signals that the - // spawn context executed; a regression that leaves it pending fails loudly. + // Drain so the spawned second mutate runs (and, because the first is + // still Loading, is rejected). cx.run_until_parked(); assert!( second_ran.is_released(), @@ -222,16 +198,14 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) actually exercised" ); - // ── The #132 contract: the cross-context second mutate is rejected ── // The second mutate's fetcher never ran (rejected by the is_loading guard). assert_eq!( *second_call_count.lock().unwrap(), 0, "second mutate from a different spawn context must be rejected while \ - the first is Loading (#132 TOCTOU race)" + the first is Loading" ); - // The first mutate is still in-flight and uncorrupted: its fetcher ran - // exactly once and the resource is still Loading. + // The first mutate is still in-flight and uncorrupted. assert_eq!( *first_call_count.lock().unwrap(), 1, @@ -245,8 +219,8 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) ); }); - // Hygiene: release the gate so the parked first fetcher can make progress - // once the test's executor drains it (not asserted — see module note). + // Hygiene: release the gate so the parked first fetcher can progress + // (not asserted; see the note above the test). gate.release(); cx.run_until_parked(); } diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index e642691..31b4531 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -10,18 +10,11 @@ use crate::client::{QueryClient, QueryObserver}; use crate::core::*; use crate::tests::test_support::*; -// -- Gap 1: (removed) observer_count-based GC protection -------------------- -// -// Audit #8 removed `observer_count` / `retain` / `release` from the buckets: -// the count was never incremented from production hooks (GPUI `Drop` has no -// `cx`), so it was always 0. GC now relies solely on `WeakEntity::upgrade()` -// liveness plus age/status — there is no observer-based eviction protection -// to test. Observed-but-aged resources are evicted by the normal age rules. +// The buckets expose no observer_count-based GC protection to test: the +// count was never incremented from production hooks (GPUI Drop has no cx). +// GC relies solely on WeakEntity::upgrade() liveness plus age/status. -// -- Gap 3: GC protection for StaleWhileRevalidate resources within stale window -// -// No test creates a SWR resource and verifies GC does NOT evict it during -// the stale-but-serveable window. +// SWR resources within the stale window are not evicted by GC. #[gpui::test] fn test_gc_preserves_swr_resources_within_stale_window(cx: &mut TestAppContext) { @@ -41,8 +34,8 @@ fn test_gc_preserves_swr_resources_within_stale_window(cx: &mut TestAppContext) cx, ); entity.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // GC reads live entity state (audit #CL2): Success + swr policy + - // last_updated_at=1000 are all set by `apply_success` above. + // GC reads live entity state: Success + swr policy + + // last_updated_at=1000 are all set by apply_success above. // GC at t=3000: age=2000, ttl expired (2000 > 1000), but within // stale window (2000 <= 6000). SWR protection should prevent eviction. @@ -82,8 +75,8 @@ fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { cx, ); entity.update(cx, |r, _| r.apply_success("fresh".to_string(), 1_000)); - // GC reads live entity state (audit #CL2): Success + swr policy + - // last_updated_at=1000 are all set by `apply_success` above. + // GC reads live entity state: Success + swr policy + + // last_updated_at=1000 are all set by apply_success above. // GC at t=3000: age=2000 < ttl(5000), still fresh client.gc_with_time(3_000, cx); @@ -95,10 +88,7 @@ fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { }); } -// -- Gap 5: Success-mutation GC path: completed mutation ages past gc_time --- -// -// The Success mutation GC path is unverified. This test creates a mutation, -// completes it with success, then verifies GC behavior at far-future time. +// A completed mutation ages past gc_time and is evicted. #[gpui::test] fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { @@ -133,10 +123,8 @@ fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { }); } -// -- Gap 6: InfiniteQueryBucket GC evicts stale infinite query ---------------- -// -// InfiniteQueryBucket GC reads entity state via cx (not cached snapshot). -// This test verifies GC evicts idle infinite queries and preserves loading ones. +// InfiniteQueryBucket GC reads entity state via cx (no cached snapshot): +// evicts idle infinite queries, preserves loading ones. #[gpui::test] fn test_gc_evicts_idle_infinite_query_with_realistic_timing(cx: &mut TestAppContext) { @@ -177,7 +165,7 @@ fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { }); assert!(entity.read(cx).status().is_loading()); - // GC reads the live LoadingEmpty status (audit #CL2) — no snapshot + // GC reads the live LoadingEmpty status; no snapshot // update is needed; loading resources survive regardless of age. client.gc_with_time(1_000_000, cx); @@ -189,12 +177,8 @@ fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { }); } -// -- Gap 6c / #133: InfiniteQueryBucket evicts aged successful resources ----- -// -// Audit #1/#133: successful infinite resources must be evicted once their age -// exceeds `SUCCESS_GC_MULTIPLIER * gc_time_ms` — previously they were never -// evicted, causing unbounded memory growth. (`observer_count` protection was -// removed in #8, so this is pure age-based eviction of a Success entry.) +// InfiniteQueryBucket evicts successful resources once their age exceeds +// SUCCESS_GC_MULTIPLIER * gc_time_ms (pure age-based eviction). #[gpui::test] fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { @@ -222,13 +206,9 @@ fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { }); } -// -- Gap 2: Bucket max_entries eviction --------------------------------------- -// -// QueryBucket::with_max_entries() and evict_oldest() eviction when max entries -// exceeded. We test this by creating a client and inserting resources via -// resource_with_policies, then verifying eviction. Since with_max_entries is -// pub(crate) on the bucket, we test indirectly through the client by creating -// many resources and verifying they all exist (the default limit is 10_000). +// Bucket max_entries: with_max_entries() is pub(crate), so eviction is tested +// indirectly through the client. The default limit (10_000) admits every +// resource created here. #[gpui::test] fn test_bucket_default_max_entries_allows_many_resources(cx: &mut TestAppContext) { @@ -327,13 +307,9 @@ fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut Test }); } -// -- Gap 7: QueryObserver::observe() returns Some for live entity ------------ -// -// The v2 fix returns Option<Subscription>. Verify that observe returns Some -// for a live entity and that constructing an observer from a WeakEntity that -// has been dropped would return None. Since GPUI doesn't allow truly dropping -// entities within a single cx.update scope, we verify the successful path -// and document the None path as the v2 safety improvement. +// QueryObserver::observe() returns Some for a live entity. GPUI can't truly +// drop an entity within a single cx.update scope, so the None path is +// untestable here. #[gpui::test] fn test_query_observer_observe_returns_some_for_live_entity(cx: &mut TestAppContext) { @@ -345,7 +321,6 @@ fn test_query_observer_observe_returns_some_for_live_entity(cx: &mut TestAppCont // Create an observer and verify it can observe a live entity let mut observer = QueryObserver::new(&entity); - // Audit fix #52: adopt the shared `observe_with_dummy_view` helper // instead of defining a local `struct DummyView;` + manual view dance. let sub = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); assert!( @@ -383,19 +358,10 @@ fn test_observer_status_dedup_default_config_is_status_change_only(_cx: &mut Tes ); } -// -- #134: MutationBucket evict_oldest triggers past DEFAULT_MAX_ENTRIES ------ -// -// Audit #134: verify that the MutationBucket `max_entries` cap actually binds -// growth. `evict_oldest` (audit #2) is called from `insert` when the bucket is -// at capacity, evicting the oldest non-loading entry. We insert more than -// `DEFAULT_MAX_ENTRIES` (10_000) Idle mutations and assert the live entry count -// stays bounded at exactly the cap — proving eviction fired on every subsequent -// insert rather than growing without limit. -// -// `DEFAULT_MAX_ENTRIES` lives in the private `client::bucket::types` module and -// isn't nameable from here; we mirror its documented value (10_000) as the -// expected bound. If the constant changes, this test's expected value must be -// updated to match. +// MutationBucket's max_entries cap binds growth: inserting past the cap +// evicts the oldest non-loading entry. DEFAULT_MAX_ENTRIES is private, so the +// documented value (10_000) is mirrored here; update it if the constant +// changes. #[gpui::test] fn test_mutation_bucket_evict_oldest_keeps_count_bounded(cx: &mut TestAppContext) { @@ -429,8 +395,7 @@ fn test_mutation_bucket_evict_oldest_keeps_count_bounded(cx: &mut TestAppContext mutations.len(), MAX_ENTRIES, "MutationBucket entry count must stay bounded at DEFAULT_MAX_ENTRIES \ - ({}); evict_oldest should have triggered on every insert past the \ - cap (#134)", + ({}); evict_oldest should have triggered on every insert past the cap", MAX_ENTRIES ); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs index dfa9523..cacafb8 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs @@ -16,11 +16,8 @@ use crate::hook::{ }; use crate::tests::test_support::*; -// -- Gap 9: use_mutation with MutationOptions still works -------------------- -// -// use_mutation now accepts MutationOptions via Into (the deprecated -// use_mutation_with_options just delegated to it). Verify the default-options -// path still registers and produces an Idle mutation. +// use_mutation accepts MutationOptions via Into; the default-options path +// must still register and produce an Idle mutation. #[gpui::test] fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext) { @@ -60,15 +57,9 @@ fn test_mutation_callbacks_fire_on_entity_drop_during_retry_delay(cx: &mut TestA let ec = error_called.clone(); let sc = settled_called.clone(); - // We use a mutation with retries. The first attempt fails, and during the - // retry delay, the entity is "dropped" (weak ref cannot upgrade). The - // retry-delay-check path in run_mutation_loop_with_callbacks fires - // on_error and on_settled when weak.upgrade() returns None. - // - // Since we can't truly drop a GPUI entity while a spawned task holds a - // weak ref (the test harness keeps it alive), we verify the callback path - // works correctly for the SUCCESS case instead, confirming the callback - // mechanism itself is sound. + // A GPUI entity can't be truly dropped while a spawned task holds a weak + // ref (the harness keeps it alive), so the drop-during-retry callback path + // is untestable here; the success case confirms the callback mechanism. #[allow(dead_code)] struct H { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs index 597ec8e..e300492 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs @@ -10,7 +10,6 @@ use crate::tests::test_support::*; #[gpui::test] fn test_mutation_with_key_registration(cx: &mut TestAppContext) { - // Audit fix #46: prefer the shorter `setup_test` alias. setup_test(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -30,7 +29,6 @@ fn test_mutation_with_key_registration(cx: &mut TestAppContext) { #[gpui::test] fn test_all_mutations_empty_for_unregistered_type(cx: &mut TestAppContext) { - // Audit fix #46: prefer the shorter `setup_test` alias. setup_test(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -51,7 +49,6 @@ fn test_all_mutations_empty_for_unregistered_type(cx: &mut TestAppContext) { #[gpui::test] fn test_multiple_mutations_same_type(cx: &mut TestAppContext) { - // Audit fix #46: prefer the shorter `setup_test` alias. setup_test(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -229,7 +226,6 @@ fn test_query_observer_observe_succeeds_for_live_entity(cx: &mut TestAppContext) let entity = client.resource::<String, QueryError>("live_obs", cx); let mut observer = QueryObserver::new(&entity); - // Audit fix #52: adopt the shared `observe_with_dummy_view` helper // instead of a local `struct DummyView;` + manual view dance. let result = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); assert!( diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs index d4ce27f..1299e34 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs @@ -41,13 +41,9 @@ fn test_prepare_fetch_query_uses_force_mode_always_starts(cx: &mut TestAppContex }); } -// -- 32. prepare_fetch_query refetch after TTL -------------------------------- -// -// NOTE: This test verifies that prepare_fetch_query returns Some both on the -// initial call and on a subsequent call with Force mode. Full TTL expiry -// behavior (data becoming stale and triggering automatic refetch) is tested -// at the resource level in core_cache.rs, where timestamps can be controlled -// deterministically via apply_success(data, now_ms). +// prepare_fetch_query returns Some on the initial call and on a Force-mode +// call. Full TTL expiry lives in core_cache.rs, where timestamps are +// controllable via apply_success(data, now_ms). #[gpui::test] fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { @@ -82,18 +78,9 @@ fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { }); } -// -- 33. prepare_prefetch_query returns None for fresh data ------------------ -// -// Finding 4/7 fix: Asserts the actual return value of prepare_prefetch_query. -// -// Determinism: captures a single `now` via `current_time_ms()` ONCE and uses -// it for both `apply_success(now)` AND an explicit age precondition check -// before calling `prepare_prefetch_query`. The 60s TTL gives a huge margin, -// so the test is deterministic as long as the wall clock doesn't jump >60s -// between the captured `now` and the internal `current_time_ms()` call -// inside `prepare_prefetch_query` (nanoseconds apart in practice). If a -// future production change adds a time-injection API, this test should be -// updated to pass `now` directly to `prepare_prefetch_query` instead. +// prepare_prefetch_query returns None for fresh data. The 60s TTL makes +// this deterministic: one captured `now` drives both apply_success and the +// freshness check unless the wall clock jumps a full minute between them. #[gpui::test] fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs index e825fa6..9c08b61 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs @@ -7,13 +7,9 @@ use crate::client::{InfiniteQueryObserver, QueryClient}; use crate::core::*; use crate::tests::test_support::*; -// -- 39. GC clamps gc_time=0 to 1000ms; Idle resources with no snapshot -// timestamp are evicted at any gc_with_time value since their -// age defaults to gc_threshold. ────────────────────────────────────── -// -// Finding 1 fix: Assert concrete eviction outcomes. An Idle resource with -// no snapshot timestamp (never fetched) is treated as "age == gc_threshold" -// by the GC, so it is always evicted regardless of gc_time clamping. +// GC clamps gc_time=0 to 1000ms. An Idle resource with no snapshot timestamp +// (never fetched) counts as "age == gc_threshold", so it is evicted at any +// gc_with_time value. #[gpui::test] fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { @@ -37,14 +33,9 @@ fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { }); } -// -- 40. GC uses gc_with_time with deterministic time control ---------------- -// -// Finding 2 fix: Assert concrete GC outcomes using the documented eviction -// rules. GC reads live entity state directly via `entity.read(cx)` (CL2/#106; -// no cached snapshot), so resources created via client.resource() always -// appear as Idle with last_updated_ms=None to GC. Idle resources with no -// timestamp are evicted at any gc_with_time value (age defaults to -// gc_threshold). +// gc_with_time reads live entity state directly (no cached snapshot), so +// resources created via client.resource() appear Idle with last_updated_ms +// None and are evicted at any gc_with_time value. #[gpui::test] fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { @@ -259,7 +250,7 @@ fn test_infinite_query_observer_weak_entity_pattern(cx: &mut TestAppContext) { fn test_current_time_ms_is_reasonable(_cx: &mut TestAppContext) { let now = crate::client::current_time_ms(); // Should be > 1_700_000_000_000 (after 2023). Upper bound widened to - // 4_000_000_000_000 (pre-2128) per audit #128 so the test doesn't fail + // 4_000_000_000_000 (pre-year-2128) so the test doesn't fail // once wall-clock crosses the old 2_000_000_000_000 (2033) threshold. assert!( now > 1_700_000_000_000, diff --git a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs index 044bd8c..d7e882d 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs @@ -9,11 +9,8 @@ use crate::core::*; use super::strategies::*; -// Audit fix #126: cap the default proptest case count at 64 (down from the -// default 256). The strategies still exercise unicode, separators, and deep -// nesting, so the property coverage stays meaningful while keeping the -// default `cargo test` run cheap. The heavyweight long-key / very-long-string -// invariants are also covered by the deterministic_tests module. +// 64 cases (down from the default 256) keeps the default `cargo test` run +// cheap; the heavyweight long-key invariants live in deterministic_tests. fn test_config() -> ProptestConfig { ProptestConfig { cases: 64, @@ -257,10 +254,8 @@ proptest! { prop_assert_eq!(key.to_path(), segments.join("::")); } - /// Longer keys still satisfy all invariants. - // Audit fix #126: reduced segment bound from 50..100 to 20..40 so the - // heavy multi-segment case stays within the default `cargo test` budget. - // Deep-nesting correctness is also exercised by the deterministic suite. + /// Longer keys still satisfy all invariants. Segment bound kept modest + /// so the multi-segment case stays within the default test budget. #[test] fn key_long_key_correctness( segments in prop::collection::vec(any::<String>(), 20..40), diff --git a/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs b/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs index 2455092..c018e7d 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs @@ -36,10 +36,9 @@ pub fn arb_key_special() -> impl Strategy<Value = Vec<String>> { // RTL overrides, surrogates-replacement, and other tricky codepoints // that regex classes like \p{L} do not cover. prop::collection::vec(arb_unicode_edge_case_string(), 1..5), - // Very long single segment (100-256 chars) to stress allocation paths. - // Bound reduced from 2000→256 per audit #126 so the default proptest - // suite stays fast; the 2000-char case is covered by the - // `#[ignore]`-gated `key_very_long_single_segment` deterministic test. + // Very long single segment (100-256 chars) to stress allocation + // paths. The 2000-char case is covered by the `#[ignore]`-gated + // deterministic test. ".{100,256}".prop_map(|s| vec![s]), ] } From d38ccbcba0c317f07566cca15445494b22154f53 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 14:11:49 +0200 Subject: [PATCH 024/111] fix: close persist_with snapshot race and rebuild multi-segment keys in hydrate persist_with could lose the last mutation before teardown: a bump landing between the armed task's take() and clear(armed) stashed into the pending slot, saw armed=true, and skipped spawning while the armed task had already drained. Snapshot collection now happens at drain time via a foreground cx.spawn task that disarms before collecting, so a bump either waits on a task that collects after it or arms a fresh one; the pending slot is gone and drop of PersistHandle still lets an armed task finish its save. hydrate rebuilt every stored key as a single segment, so PersistFilter::Exact/Prefix and set_query_data priming silently never matched multi-segment keys. Stored paths are now split back into segments on "::" (to_path was already joining with unescaped "::", so no previously-working key regresses). Perf: one serialization per debounce window instead of per bump (collection at drain time captures the latest state); hydrate loop reordered entries-outer/steps-inner so key reconstruction and filter/max_age run once per entry; dead TypeId dropped from DeserializerRegistry::steps. Trim: module doc de-historicized, pub docs tightened, persist.rs FilePersister intra-doc link replaced with plain text (last workspace doc warning), test narration stripped to short constraint comments with setup deduped locally; all 16 test fns and asserts kept. Gates: cargo test --all-features 819/0 (zero delta), clippy -D warnings clean, cargo doc 0 warnings. --- crates/gpui-query/src/client/persist.rs | 428 +++++++----------- .../diagnostics_dehydrate_persister.rs | 29 +- .../client_operations/persist_with_hydrate.rs | 311 ++++--------- 3 files changed, 252 insertions(+), 516 deletions(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index fd8970c..315866a 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -1,49 +1,39 @@ -//! Async, value-carrying persistence layer for [`QueryClient`](super::QueryClient). +//! Async, value-carrying persistence for [`QueryClient`](super::QueryClient): +//! the [`Persister`] trait, the debounced [`QueryClient::persist_with`] driver, +//! [`hydrate`], and the typed serializer/deserializer registries. //! -//! This is the Phase B enrichment of the shipped (synchronous, metadata-only) -//! skeleton. It adds: -//! -//! - an async [`Persister`] trait (non-object-safe; generic over the future), -//! - a value-carrying [`PersistedEntry`] (opaque `serde_json::Value` payload), -//! - a debounced [`QueryClient::persist_with`] driver keyed off the precise -//! [`CacheMutation`](super::CacheMutation) dirty signal, -//! - a typed-serializer registry so core can serialize concrete `T` without a -//! `T: Serialize` bound leaking onto every resource, and a matching -//! deserializer registry so [`hydrate`] can re-prime concrete values. -//! -//! See `docs/features.md` and the plan (`snug-wobbling-puzzle.md`, Phase B) for -//! the design rationale. +//! This layer trusts a persister's `load` output beyond a version check, but +//! never panics on it: unrecognized versions error out and values no +//! deserializer accepts are skipped. A persister reading untrusted storage +//! should validate and size-limit payloads itself. use std::any::TypeId; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use gpui::{App, BorrowAppContext as _, Subscription}; +use gpui::{App, Subscription}; use serde_json::Value as JsonValue; use thiserror::Error; use crate::core::{CachePolicy, QueryKey}; use super::QueryClient; -// The erased-bucket traits' `collect_persistable_into` methods are dispatched -// via the trait object's vtable (`Box<dyn ErasedBucket>`), so the traits -// themselves need not be imported here. -/// Current on-disk snapshot format version. Bumped when the serialized shape of -/// [`PersistSnapshot`] changes in a backwards-incompatible way; loaders reject -/// mismatched versions with [`PersistError::VersionMismatch`]. +/// Current on-disk snapshot format version. Bumped when the serialized shape +/// of [`PersistSnapshot`] changes incompatibly; loaders reject mismatches with +/// [`PersistError::VersionMismatch`]. pub const PERSIST_VERSION: u32 = 1; // ── Errors ─────────────────────────────────────────────────────────────── /// Errors produced by the persistence layer. /// -/// Every IO failure from a [`Persister`] implementation is mapped to a variant -/// here rather than panicked on; loaders tolerate corrupt/missing files (see -/// [`FilePersister`](../../gpui_query_persist/struct.FilePersister.html)) by -/// degrading to an empty snapshot. +/// Every IO failure from a [`Persister`] implementation maps to a variant +/// here rather than panicking; tolerant persisters degrade a corrupt store to +/// an empty snapshot. #[derive(Debug, Error)] pub enum PersistError { /// An underlying IO error (read or write) failed. @@ -52,20 +42,13 @@ pub enum PersistError { /// Serializing the snapshot (or an entry) to the persister's format failed. #[error("persistence serialize error: {0}")] Serialize(#[from] serde_json::Error), - /// The on-disk snapshot could not be parsed / deserialized. - /// - /// Reserved for persister implementations that surface (rather than - /// tolerate) deserialization failures; the shipped `FilePersister` degrades - /// corrupt stores to an empty snapshot instead, so core never constructs - /// this variant. It is retained on the public API for backends that prefer - /// to propagate parse errors. + /// The on-disk snapshot could not be parsed. Reserved for persisters that + /// surface (rather than tolerate) parse failures; core never constructs + /// this variant. #[error("persistence deserialize error: {0}")] Deserialize(String), - /// The on-disk snapshot's `version` does not match [`PERSIST_VERSION`]. - /// - /// Treated as a typed error (rather than silent empty-snapshot) so callers - /// can distinguish "file was corrupt" from "file was written by a - /// newer/older format we cannot read". + /// The on-disk snapshot's `version` does not match [`PERSIST_VERSION`], + /// so the file was written by a format we cannot read. #[error("persistence version mismatch: expected {expected}, found {found}")] VersionMismatch { /// The version this loader understands ([`PERSIST_VERSION`]). @@ -83,12 +66,11 @@ pub enum PersistError { // ── Snapshot types ─────────────────────────────────────────────────────── -/// A single persisted cache entry carrying the typed data as an opaque JSON -/// value plus the metadata needed to re-prime and re-validate it. +/// One persisted cache entry: the typed data as an opaque JSON value plus the +/// metadata needed to re-prime and re-validate it. /// -/// `value` is opaque to core (a `serde_json::Value`); the typed round-trip is -/// driven by the serializer/deserializer registries on [`QueryClient`]. This is -/// Open Question 3 from the design doc. +/// `value` is opaque to core; the typed round-trip is driven by the +/// serializer/deserializer registries on [`QueryClient`]. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct PersistedEntry { /// The serialized data value. Opaque to core. @@ -97,21 +79,19 @@ pub struct PersistedEntry { pub cached_at: u64, /// The cache policy in force when the entry was cached. pub cache_policy: CachePolicy, - /// Optional opaque metadata (e.g. ETag/Last-Modified for HTTP). Reserved - /// for the `gpui-query-http` companion crate. + /// Optional opaque metadata (e.g. ETag/Last-Modified for HTTP), captured + /// from `Fetched::meta` at fetch completion. Reserved for the + /// `gpui-query-http` companion crate. pub meta: Option<JsonValue>, } /// A full snapshot of the persistable cache, ready to hand to a [`Persister`]. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct PersistSnapshot { - /// The persistable entries, keyed by [`QueryKey`] path string. - /// - /// Keys are stored as their `to_path()` `String` form so the snapshot is - /// self-contained (no `Arc` aliasing across processes) and serializable. + /// The persistable entries, keyed by [`QueryKey`] path string so the + /// snapshot is self-contained and serializable. pub entries: HashMap<String, PersistedEntry>, - /// Format version; see [`PERSIST_VERSION`] and - /// [`PersistError::VersionMismatch`]. + /// Format version; see [`PERSIST_VERSION`]. pub version: u32, } @@ -127,13 +107,9 @@ impl PersistSnapshot { // ── Owned filter (vs core's borrowing QueryKeyFilter<'a>) ──────────────── -/// Owned counterpart to [`QueryKeyFilter`](crate::core::QueryKeyFilter) for the -/// persistence layer. -/// -/// The core filter borrows (`Exact(&QueryKey)` / `Prefix(&QueryKey)`) and is -/// therefore neither `Serialize` nor storable inside [`PersistOptions`]; this -/// owned enum lets a caller pin the filter into a long-lived `persist_with` -/// driver. +/// Owned counterpart to [`QueryKeyFilter`](crate::core::QueryKeyFilter), so a +/// filter can be pinned inside long-lived structures like +/// [`PersistOptions`]. #[derive(Clone, Debug)] pub enum PersistFilter { /// Persist only the entry matching exactly this key. @@ -157,20 +133,16 @@ impl PersistFilter { /// Tuning knobs for [`QueryClient::persist_with`]. /// -/// `Default` is: every entry, max age 24 hours, 500 ms debounce — a sensible -/// "save the cache to disk shortly after it changes" baseline. +/// `Default` is: every entry, max age 24 hours, 500 ms debounce. #[derive(Clone, Debug)] pub struct PersistOptions { /// Which entries to include. pub filter: PersistFilter, /// Skip entries older than this at save time. pub max_age: Duration, - /// Coalesce bursts of [`CacheMutation`](super::CacheMutation) into one save - /// per window. - /// - /// Passing [`Duration::ZERO`] disables the timer-based coalescing window — - /// each bump still races to drain the pending slot, but there is no - /// batching delay (saves still serialize through the drain slot). + /// Coalesce bursts of [`CacheMutation`](super::CacheMutation) into one + /// save per window. [`Duration::ZERO`] skips the delay: a bump arriving + /// while no save is pending saves immediately. pub debounce: Duration, } @@ -188,51 +160,36 @@ impl Default for PersistOptions { /// Type-erased serializer closure: `&dyn Any -> Option<serde_json::Value>`. /// -/// `None` means the downcast to the registered `T` failed — unreachable by -/// construction (the bucket looks the closure up by `TypeId::of::<T>()` and -/// passes that same `T`), but callers skip the entry rather than persisting a -/// placeholder, so a future invariant break can never panic the foreground -/// thread from inside the persistence path. +/// `None` means the downcast failed; the caller then skips the entry rather +/// than persisting junk. type SerializeFn = Box<dyn Fn(&dyn std::any::Any) -> Option<JsonValue> + Send + Sync>; -/// Registry of `T -> serde_json::Value` serializers, keyed by `TypeId` of the -/// resource's data type `T`. +/// Registry of `T -> serde_json::Value` serializers, keyed by `TypeId` of +/// the resource's data type `T`. /// -/// The keying is intentionally on `T` alone (not the full `(T, E)` resource -/// pair): the bucket impls (`erased_ops.rs`, `infinite_bucket.rs`) likewise -/// look up by `TypeId::of::<T>()`, so insert and lookup are consistent on `T`. -/// Serialization only depends on the data type, not the error type. Consequence: -/// registering serializers for the same `T` under two different `E` types -/// silently overwrites (last write wins); whichever serializer survives is -/// applied to both `(T, E)` buckets, which is correct because the value *is* -/// that `T`. -/// -/// Stored closures accept `&dyn Any` and downcast internally, so the registry -/// stays free of `T: Serialize` bounds on the resource itself. +/// Keyed on `T` alone, matching the bucket lookup: serialization depends only +/// on the data type, so registering for the same `T` under two error types +/// overwrites (last write wins), and the surviving closure applies to every +/// `(T, E)` bucket. That is correct because the value is that `T`. #[derive(Default)] pub struct SerializerRegistry { serializers: HashMap<TypeId, SerializeFn>, } impl SerializerRegistry { - /// Register a serializer for `T`. - /// - /// `f` receives a `&T` already downcast by the bucket impl; we erase it to - /// `&dyn Any` here so the registry is heterogeneous. + /// Register a serializer for `T`. `f` is a plain `fn` pointer (no + /// captures) so it is `Send + Sync + 'static` without boxing. pub fn register<T: 'static>(&mut self, f: fn(&T) -> JsonValue) { let wrap = move |any: &dyn std::any::Any| -> Option<JsonValue> { - // Downcast to the concrete `T` this closure was registered for. The - // bucket impl only invokes this after looking the closure up by - // `TypeId::of::<T>()`, so the `Any` is that `T` and the downcast - // always succeeds — but degrade to `None` (the bucket then skips the - // entry) instead of `.expect()`, honoring the no-panic rule for the - // persistence path even if the TypeId invariant ever breaks. + // Downcast failure is unreachable (buckets look the closure up by + // `TypeId::of::<T>()`), but degrade to None so this path can + // never panic. any.downcast_ref::<T>().map(f) }; self.serializers.insert(TypeId::of::<T>(), Box::new(wrap)); } - /// Look up the serializer closure registered for the given data `TypeId`. + /// Look up the serializer registered for `type_id`. pub(crate) fn get(&self, type_id: TypeId) -> Option<&SerializeFn> { self.serializers.get(&type_id) } @@ -243,31 +200,24 @@ impl SerializerRegistry { } } -/// Type-erased hydrate step: decode `JsonValue -> ()`, priming the live cache -/// via `client.set_query_data::<T, E>(key, value, cx)`. The closure captures -/// the concrete `T`/`E` in its monomorphized `register` call site, so it can -/// downcast/re-prime without the registry layer knowing the types. +/// Type-erased hydrate step: decode a `JsonValue` and prime the live cache +/// via `set_query_data::<T, E>`. The concrete types are captured at the +/// `register` call site, so no cross-type confusion is possible. type HydrateStep = Arc<dyn Fn(&mut QueryClient, &QueryKey, &JsonValue, &mut App) -> bool + Send + Sync>; -/// Registry of `serde_json::Value -> primed cache entry` steps, keyed by -/// `TypeId` of the resource's data type `T`. Used by [`hydrate`] to re-prime -/// erased on-disk values to concrete `T` so they can be handed to -/// `set_query_data`. -/// -/// Each step returns `true` if it successfully decoded and primed the value, -/// `false` if the value was unparseable (the entry is then skipped). +/// Registry of `serde_json::Value -> primed cache entry` steps, used by +/// [`hydrate`] to re-prime on-disk values. Each step returns `true` if it +/// decoded and primed the value, `false` to skip the entry. #[derive(Default)] pub struct DeserializerRegistry { - steps: Vec<(TypeId, HydrateStep)>, + steps: Vec<HydrateStep>, } impl DeserializerRegistry { - /// Register a deserializer for resources of type `(T, E)`. - /// - /// `deserialize` returns `Option<T>`; `None` means the value was - /// unparseable and the entry is skipped during hydration. On `Some(t)`, - /// `t` is primed into the cache via `set_query_data::<T, E>(key, t, cx)`. + /// Register a deserializer for resources of type `(T, E)`. `deserialize` + /// returns `None` for values it cannot decode; the entry is then skipped. + /// On `Some(t)` the value is primed via `set_query_data::<T, E>`. pub fn register<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>) where T: Clone + Send + Sync + 'static, @@ -284,13 +234,11 @@ impl DeserializerRegistry { client.set_query_data::<T, E>(key.clone(), t, cx); true }; - self.steps.push((TypeId::of::<(T, E)>(), Arc::new(step))); + self.steps.push(Arc::new(step)); } - /// Iterate every registered `(TypeId, hydrate-step)` pair. Used by - /// [`hydrate`] to find which registry entry owns a given on-disk key. - fn iter(&self) -> impl Iterator<Item = (TypeId, HydrateStep)> { - self.steps.iter().map(|(k, v)| (*k, Arc::clone(v))) + fn iter(&self) -> impl Iterator<Item = &HydrateStep> { + self.steps.iter() } } @@ -298,22 +246,18 @@ impl DeserializerRegistry { /// Async persistence backend for [`QueryClient::persist_with`]. /// -/// Non-object-safe (methods return `impl Future`): the trait is consumed -/// generically by `persist_with<P: Persister>`, which monomorphizes the driver -/// around the concrete `P`. This avoids `Pin<Box<dyn Future>>` overhead and -/// keeps the `Send + 'static` bounds visible at the call site (the save future -/// runs on GPUI's `background_executor`, so it must be `Send + 'static`). +/// Non-object-safe (methods return `impl Future`): `persist_with<P>` +/// monomorphizes the driver around the concrete `P`, avoiding +/// `Pin<Box<dyn Future>>` overhead and keeping the `Send + 'static` bounds +/// visible at the call site. The save future runs on GPUI's background +/// executor. /// -/// Implementations store cached data in any backend (filesystem, database, -/// KV store, …). See [`gpui_query_persist::FilePersister`] for a reference -/// disk adapter. +/// See the `FilePersister` adapter in the `gpui-query-persist` satellite +/// crate for a reference disk implementation. pub trait Persister: Send + Sync + 'static { - /// Load the snapshot from storage. - /// - /// Implementations should be tolerant: a missing store yields an empty - /// snapshot, a corrupt store yields an empty snapshot + a logged warning - /// (or a typed [`PersistError`] for version mismatches the caller may wish - /// to handle). + /// Load the snapshot from storage. Implementations should tolerate a + /// missing or corrupt store by yielding an empty snapshot (or a typed + /// [`PersistError`] for version mismatches). fn load(&self) -> impl Future<Output = Result<PersistSnapshot, PersistError>> + Send; /// Save `snapshot`, replacing any previously stored data. @@ -327,12 +271,10 @@ pub trait Persister: Send + Sync + 'static { /// Drop-guard returned by [`QueryClient::persist_with`]. /// -/// Holding the handle keeps the underlying [`CacheMutation`](super::CacheMutation) -/// observation (and thus the debounced save loop) alive; dropping it drops the -/// [`Subscription`], so no *new* saves are scheduled. A save task already -/// waiting on its debounce timer is detached and may still complete one final -/// save. The wrapped [`Persister`] is held in an `Arc` so the spawned save -/// future can use it after `persist_with` returns. +/// Holding the handle keeps the [`CacheMutation`](super::CacheMutation) +/// observation alive; dropping it stops new saves from being scheduled. A +/// task that is already armed still collects and completes its final save, +/// so nothing pending at drop time is lost. pub struct PersistHandle { // Subscription is dropped when the handle is, ending observation. _subscription: Option<Subscription>, @@ -350,14 +292,11 @@ impl PersistHandle { // ── QueryClient methods ───────────────────────────────────────────────── impl QueryClient { - /// Register a serializer for resources of type `(T, E)`. + /// Register a serializer for resources of data type `T`. /// - /// Only resources whose `T` has a registered serializer are emitted by the - /// value-carrying `collect_persistable_into` path; unregistered types fall - /// back to metadata-only (skipped), matching the legacy `dehydrate`. - /// - /// `f` is a `fn(&T) -> serde_json::Value` (a plain function pointer, not a - /// closure) so it is `Send + Sync + 'static` withoutboxing overhead. + /// Only `Success` resources whose `T` has a registered serializer are + /// emitted by [`collect_persist_snapshot`](Self::collect_persist_snapshot); + /// unregistered types are skipped. pub fn register_serializer<T, E>(&mut self, f: fn(&T) -> JsonValue) where T: Clone + Send + Sync + 'static, @@ -372,15 +311,11 @@ impl QueryClient { /// Register a deserializer for resources of type `(T, E)`, enabling /// [`hydrate`] to re-prime on-disk values of this type. /// - /// **Strict-deserializer contract.** [`hydrate`] offers every on-disk entry - /// to *every* registered deserializer (there is no type discriminator on - /// [`PersistedEntry`], so routing is by trial). A deserializer MUST return - /// `None` for any JSON shape it does not recognize as its own `T`; only - /// return `Some` for values that genuinely decode to `T`. A lax - /// deserializer that accepts a foreign shape would mis-prime the wrong - /// bucket. (Each `(T, E)` writes to its own bucket, so typed data is not - /// clobbered, but a permissive decoder wastes work and can prime a stale - /// value.) Keep deserializers strict and cheap. + /// [`hydrate`] offers every on-disk entry to every registered + /// deserializer (there is no type discriminator on [`PersistedEntry`]). + /// A deserializer MUST return `None` for any JSON shape that is not its + /// own `T`; a lax one can prime a stale or foreign value into a bucket + /// it does not belong to. pub fn register_deserializer<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>) where T: Clone + Send + Sync + 'static, @@ -392,9 +327,9 @@ impl QueryClient { registry.register::<T, E>(deserialize); } - /// Collect a value-carrying snapshot from the live cache, honoring `filter` - /// and `max_age`. Only resources with a registered serializer (and in - /// `Success` status) are included. + /// Collect a value-carrying snapshot from the live cache, honoring + /// `filter` and `max_age`. Only `Success` resources with a registered + /// serializer are included. pub fn collect_persist_snapshot( &self, filter: &PersistFilter, @@ -415,8 +350,7 @@ impl QueryClient { bucket.collect_persistable_into(cx, registry, now_ms, &mut out); } - // Enrich each collected entry with any opaque metadata recorded for its - // key at fetch-completion time (see QueryClient::record_meta), so HTTP + // Attach metadata recorded at fetch completion (record_meta) so HTTP // CacheMeta and similar round-trip through PersistedEntry.meta. if let Some(meta_map) = &self.persisted_meta { for (key, entry) in &mut out { @@ -442,16 +376,12 @@ impl QueryClient { /// Drive a [`Persister`] from the live cache, debounced on the /// [`CacheMutation`](super::CacheMutation) dirty signal. /// - /// On every `CacheMutation` bump, the callback: - /// 1. collects a fresh [`PersistSnapshot`] (cheap; main thread, has `&App`), - /// 2. stashes it in a shared slot, replacing any pending snapshot, - /// 3. spawns a debounced task that, after `opts.debounce`, takes the latest - /// snapshot from the slot and runs `persister.save(&snapshot)` on the - /// background executor. - /// - /// Rapid bursts coalesce: only the most recently collected snapshot is - /// saved when the debounce timer elapses. Returning the [`PersistHandle`] - /// keeps the observation alive; dropping it stops further saves. + /// Each bump arms at most one main-thread task; after `opts.debounce` + /// the task collects a fresh [`PersistSnapshot`] and runs + /// `persister.save(&snapshot)` on the background executor. Because + /// collection happens at drain time, a burst of bumps coalesces into one + /// save of the latest state. Dropping the returned [`PersistHandle`] + /// stops scheduling new saves; an armed task still finishes. pub fn persist_with<P: Persister>( &self, persister: P, @@ -460,78 +390,51 @@ impl QueryClient { ) -> PersistHandle { let persister: Arc<P> = Arc::new(persister); let debounce = opts.debounce; - // Shared slot for the latest pending snapshot. Replaced on every bump; - // drained by the debounced save task. - let pending: Arc<std::sync::Mutex<Option<PersistSnapshot>>> = - Arc::new(std::sync::Mutex::new(None)); - - // Bound on in-flight debounce tasks: at most one pending per window. - // A bump that arrives while a task is already armed skips spawning a - // new one (its snapshot still lands in `pending`, where the armed task - // will drain it), so a burst produces one task rather than N. Cleared - // by the task after it drains (or finds empty) the slot — on every - // path, so persistence can never get stuck never-spawning-again. - let armed: Arc<std::sync::Mutex<bool>> = Arc::new(std::sync::Mutex::new(false)); - - // Ensure the marker exists before observing. The bump sites (see - // `mutation_signal.rs`) call `cx.default_global::<CacheMutation>()`, - // which — like `set_global`/`global_mut` — pushes a - // `NotifyGlobalObservers` effect; that notification is what wakes this - // observer. We seed the marker here too so it is guaranteed present - // before observation is registered (the idempotent seeding itself also - // notifies, harmlessly). + let bg = cx.background_executor().clone(); + // At most one armed task per window; cleared by the task itself just + // before it collects, on every path. + let armed = Arc::new(AtomicBool::new(false)); + + // Seed the marker so observation is registered against a global that + // already exists; bump sites use the same idempotent seeding. let _ = cx.default_global::<super::CacheMutation>(); let subscription = { let persister = persister.clone(); - let pending = pending.clone(); let armed = armed.clone(); let filter = opts.filter; let max_age = opts.max_age; - let bg = cx.background_executor().clone(); cx.observe_global::<super::CacheMutation>(move |cx| { - // Collect fresh snapshot on the main thread (has &App). - let snapshot = cx.update_global::<QueryClient, _>(|client, cx| { - client.collect_persist_snapshot(&filter, max_age, cx) - }); - // Stash as the latest pending snapshot. - if let Ok(mut slot) = pending.lock() { - *slot = Some(snapshot); - } - // Spawn a debounced save only if no task is already armed for - // this window; otherwise let the in-flight task drain the slot - // we just stashed (latest snapshot wins). - { - let Ok(mut guard) = armed.lock() else { - return; - }; - if *guard { - return; - } - *guard = true; + // If a task is already armed it will collect after this bump + // when its window elapses; nothing else to do. + if armed.swap(true, Ordering::AcqRel) { + return; } let persister = persister.clone(); - let pending = pending.clone(); + let filter = filter.clone(); let armed = armed.clone(); - let bg_for_future = bg.clone(); - bg.spawn(async move { + let bg = bg.clone(); + cx.spawn(async move |cx| { if !debounce.is_zero() { - bg_for_future.timer(debounce).await; - } - // Take the latest snapshot (or no-op if a later task - // already drained the slot). Clear `armed` on every path so - // the next bump can spawn again — do it after draining so a - // bump that lands during the window still coalesces into - // this task's drain. - let snapshot = pending.lock().ok().and_then(|mut slot| slot.take()); - if let Ok(mut guard) = armed.lock() { - *guard = false; - } - let Some(snapshot) = snapshot else { return }; - if let Err(err) = persister.save(&snapshot).await { - #[cfg(debug_assertions)] - eprintln!("persist_with: save failed: {err}"); + bg.timer(debounce).await; } + // Disarm before collecting: a bump landing now arms a + // fresh task instead of trusting one about to finish. + armed.store(false, Ordering::Release); + let Ok(snapshot) = cx.update_global::<QueryClient, _>(|client, cx| { + client.collect_persist_snapshot(&filter, max_age, cx) + }) else { + return; + }; + // Collect on the main thread (entity reads), save on the + // background executor (IO), per the Persister contract. + bg.spawn(async move { + if let Err(err) = persister.save(&snapshot).await { + #[cfg(debug_assertions)] + eprintln!("persist_with: save failed: {err}"); + } + }) + .detach(); }) .detach(); }) @@ -547,8 +450,8 @@ impl QueryClient { /// A [`Persister`] that persists nothing and loads an empty snapshot. /// -/// Useful as a default, for tests that only exercise the dirty-signal/debounce -/// path, or as a base to compose with a real persister behind a feature flag. +/// Useful as a default, in tests that only exercise the debounce path, or as +/// a base to compose with a real persister behind a feature flag. pub struct NoopPersister; impl Persister for NoopPersister { @@ -563,28 +466,20 @@ impl Persister for NoopPersister { // ── hydrate ────────────────────────────────────────────────────────────── -/// Load a snapshot from `persister` and re-prime the live cache with it. -/// -/// This is the value-carrying counterpart to the (metadata-only) -/// [`QueryClient::hydrate`](super::QueryClient::hydrate). For each on-disk -/// entry whose `(T, E)` has a registered deserializer (see -/// [`QueryClient::register_deserializer`]), the JSON `value` is decoded and -/// primed via `set_query_data::<T, E>`. Entries without a registered -/// deserializer are skipped (the caller can still inspect them via the -/// returned [`PersistSnapshot`] for ad-hoc typed priming, matching the legacy -/// `hydrate` escape hatch). +/// Load a snapshot from `persister` and re-prime the live cache with it: the +/// value-carrying counterpart to the metadata-only +/// [`QueryClient::hydrate`](super::QueryClient::hydrate). /// -/// Entries older than `max_age` or excluded by `filter` are skipped. +/// Every entry surviving `filter` and `max_age` is offered to every +/// registered deserializer (see [`QueryClient::register_deserializer`]); +/// each one that decodes primes the value via `set_query_data`. Entries no +/// deserializer accepts are skipped. Stored keys are `to_path()` strings; +/// they are split back into segments so `Exact`/`Prefix` filters match the +/// live multi-segment key shapes. /// -/// **Routing.** There is no type discriminator on [`PersistedEntry`], so every -/// surviving entry is offered to every registered deserializer (O(deserializers -/// × entries)); each is primed by the first deserializer that decodes it. This -/// relies on the strict-deserializer contract of -/// [`QueryClient::register_deserializer`] — keep deserializers strict. -/// -/// Returns the loaded snapshot (post-filter) so callers can perform additional -/// metadata-only priming or diagnostics. Errors from the persister's `load` -/// propagate. +/// Returns the loaded snapshot (post-filter) so callers can inspect entries +/// or prime types with no registered deserializer themselves. Errors from +/// `load` propagate. pub async fn hydrate<P: Persister>( client: &mut QueryClient, persister: &P, @@ -593,8 +488,8 @@ pub async fn hydrate<P: Persister>( cx: &mut App, ) -> Result<PersistSnapshot, PersistError> { let snapshot = persister.load().await?; - // If the persister already enforces version, we still double-check here so - // an in-memory persister can't silently feed a mismatched snapshot. + // Check even if the persister already enforces the version, so an + // in-memory persister cannot feed a mismatched snapshot through. if snapshot.version != PERSIST_VERSION { return Err(PersistError::VersionMismatch { expected: PERSIST_VERSION, @@ -608,24 +503,21 @@ pub async fn hydrate<P: Persister>( return Ok(snapshot); }; - // Clone the step list out (cheap `Arc` bumps) so we drop the immutable - // borrow on `client` before calling `step(client, …)` which needs - // `&mut QueryClient` (it calls `set_query_data`). - let steps: Vec<HydrateStep> = deserializers.iter().map(|(_, s)| s).collect(); - - // For each registered (T, E) hydrate-step, walk the snapshot entries and - // let the step decode + prime any matching key. Because each step is - // monomorphized over concrete (T, E), it downcasts safely inside its own - // closure — no cross-type confusion. - for step in steps { - for (key_path, entry) in &snapshot.entries { - let key = QueryKey::from(key_path.as_str()); - if !filter.matches(&key) { - continue; - } - if max_age_ms > 0 && now_ms.saturating_sub(entry.cached_at) > max_age_ms { - continue; - } + // Clone the steps (cheap Arc bumps) so the immutable borrow on `client` + // ends before each step takes `&mut QueryClient` for set_query_data. + let steps: Vec<HydrateStep> = deserializers.iter().cloned().collect(); + + // One key reconstruction and filter pass per entry; every step then gets + // a shot at the value. + for (key_path, entry) in &snapshot.entries { + let key = QueryKey::new(key_path.split("::")); + if !filter.matches(&key) { + continue; + } + if max_age_ms > 0 && now_ms.saturating_sub(entry.cached_at) > max_age_ms { + continue; + } + for step in &steps { step(client, &key, &entry.value, cx); } } diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs index 3e0db4d..dff27ad 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs @@ -1,4 +1,4 @@ -//! Diagnostics, dehydrate/hydrate, and persister tests (tests 24–30). +//! Diagnostics, dehydrate/hydrate, and legacy persister tests. use std::sync::Mutex; @@ -8,17 +8,13 @@ use crate::client::{DehydratedEntry, DehydratedState, QueryClient}; use crate::core::*; use crate::tests::test_support::*; -// -- 24. Diagnostics: query status and cache_policy accuracy ----------------- - #[gpui::test] fn test_diagnostics_query_status_accuracy(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create idle resource let _idle = client.resource::<String, QueryError>("diag_idle", cx); - // Create success resource via prepared fetch let prepared = client .prepare_fetch_query::<String, QueryError>("diag_success", cx) .expect("should start"); @@ -46,8 +42,6 @@ fn test_diagnostics_query_status_accuracy(cx: &mut TestAppContext) { }); } -// -- 25. Diagnostics: cache_policy label correctness ------------------------- - #[gpui::test] fn test_diagnostics_cache_policy_label(cx: &mut TestAppContext) { cx.update(|cx| { @@ -69,28 +63,23 @@ fn test_diagnostics_cache_policy_label(cx: &mut TestAppContext) { }); } -// -- 26. Dehydrate includes infinite queries --------------------------------- - #[gpui::test] fn test_dehydrate_includes_infinite_query_success(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Regular query success let q = client.resource::<String, QueryError>("q1", cx); q.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // Infinite query (no direct apply_success, just create idle) + // Idle infinite query: created, never completed. let _iq = client.infinite_resource::<String, QueryError>("iq1", cx); let state = client.dehydrate(cx); - // Only the regular query with Success should appear let regular_entries: Vec<_> = state.entries.iter().filter(|e| e.kind == "query").collect(); assert_eq!(regular_entries.len(), 1); assert_eq!(regular_entries[0].key, "q1"); - // Infinite query is idle, so not in dehydrate output let inf_entries: Vec<_> = state .entries .iter() @@ -101,8 +90,6 @@ fn test_dehydrate_includes_infinite_query_success(cx: &mut TestAppContext) { }); } -// -- 27. DehydratedState default and manual construction --------------------- - #[gpui::test] fn test_dehydrated_state_default_and_construction(_cx: &mut TestAppContext) { let state = DehydratedState::default(); @@ -120,8 +107,6 @@ fn test_dehydrated_state_default_and_construction(_cx: &mut TestAppContext) { assert_eq!(state.entries[0].key, "users"); } -// -- 28. Hydrate is a no-op (placeholder API) -------------------------------- - #[gpui::test] fn test_hydrate_is_noop(cx: &mut TestAppContext) { setup_query_client(cx); @@ -134,18 +119,14 @@ fn test_hydrate_is_noop(cx: &mut TestAppContext) { kind: "query", }], }; - // hydrate is a placeholder — should not panic client.hydrate(state, cx); - // No data should be injected (hydrate is a no-op) let data = client.get_query_data::<String, QueryError>(&QueryKey::from("test"), cx); assert!(data.is_none(), "hydrate is a no-op, no data injected"); }); }); } -// -- 29. QueryPersister: save/load round-trip with typed data ---------------- - #[gpui::test] fn test_persister_empty_restore(cx: &mut TestAppContext) { setup_query_client(cx); @@ -159,7 +140,6 @@ fn test_persister_empty_restore(cx: &mut TestAppContext) { fn save(&self, _entries: Vec<DehydratedEntry>) {} } - // No resources yet — persist should produce no entries client.persist(&EmptyPersister, cx); let loaded = QueryClient::restore(&EmptyPersister); assert!(loaded.is_empty()); @@ -167,20 +147,17 @@ fn test_persister_empty_restore(cx: &mut TestAppContext) { }); } -// -- 30. Persister records multiple success entries -------------------------- - #[gpui::test] fn test_persister_records_multiple_entries(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create several success resources for i in 0..5 { let key = format!("persist_{i}"); let e = client.resource::<String, QueryError>(key.clone(), cx); e.update(cx, |r, _| r.apply_success(format!("val_{i}"), 1_000)); } - // One idle resource that should NOT be persisted + // Idle resources are never persisted. let _idle = client.resource::<String, QueryError>("idle_persist", cx); struct CapturePersister { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index e686b97..b22a110 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -1,7 +1,7 @@ -//! Integration tests for the value-carrying persistence layer (`persist` -//! feature): `persist_with` debounce/coalescing, the typed serializer/deserializer -//! registries, `hydrate` round-trip, the [`CacheMutation`] dirty signal firing -//! on `set_query_data`, and `PersistFilter` / `max_age` behavior. +//! Integration tests for the value-carrying persistence layer: +//! `persist_with` debounce/coalescing, the serializer/deserializer registries, +//! `hydrate` round-trip, the `CacheMutation` dirty signal, and +//! `PersistFilter`/`max_age` behavior. use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -21,12 +21,8 @@ use crate::hook::{ }; use crate::tests::test_support::*; -/// An in-memory persister that stores the last saved snapshot, for asserting -/// on the value-carrying payload in tests. -/// -/// `save_count` tracks the number of `save` invocations so coalescing tests -/// can assert exactly how many saves actually fired (additive: existing tests -/// that ignore it are unaffected). +/// In-memory persister for asserting on saved payloads. `save_count` counts +/// `save` calls so coalescing tests can assert exactly how many fired. #[derive(Default, Clone)] struct MemPersister { last_saved: Arc<StdMutex<Option<PersistSnapshot>>>, @@ -54,19 +50,34 @@ impl Persister for MemPersister { } } +/// Serializer for the `String`-typed fixtures. +fn ser_string(s: &String) -> serde_json::Value { + serde_json::to_value(s).expect("serialize") +} + +/// Debounce disabled: the TestAppContext mock clock never advances wall-clock +/// timers on its own, so a non-zero debounce would leave the save un-fired +/// unless the test calls `advance_clock`. +fn zero_debounce() -> PersistOptions { + PersistOptions { + debounce: Duration::ZERO, + ..PersistOptions::default() + } +} + +const DAY: Duration = Duration::from_secs(24 * 60 * 60); + #[gpui::test] fn test_set_query_data_bumps_cache_mutation(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { - // Install persist_with so the CacheMutation observation is live; the - // bump must not panic. + // The observation must be live when the bump fires, and must not panic. let _handle = cx.update_global::<QueryClient, _>(|client, cx| { client.persist_with(NoopPersister, PersistOptions::default(), cx) }); cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>("k1", "v1".to_string(), cx); }); - // The marker global now exists. assert!(cx.has_global::<CacheMutation>()); }); } @@ -76,20 +87,14 @@ fn test_collect_persist_snapshot_uses_registered_serializer(cx: &mut TestAppCont setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); + client.register_serializer::<String, QueryError>(ser_string); let e = client.resource::<String, QueryError>(QueryKey::from("snap_k"), cx); e.update(cx, |r, _| { r.apply_success("payload".to_string(), crate::client::current_time_ms()) }); - let snap = client.collect_persist_snapshot( - &PersistFilter::All, - Duration::from_secs(60 * 60 * 24), - cx, - ); + let snap = client.collect_persist_snapshot(&PersistFilter::All, DAY, cx); assert_eq!(snap.entries.len(), 1); let entry = snap.entries.get("snap_k").expect("entry present"); assert_eq!(entry.value, serde_json::json!("payload")); @@ -102,17 +107,16 @@ fn test_collect_persist_snapshot_skips_unregistered_types(cx: &mut TestAppContex setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // NO serializer registered → entry skipped (metadata-only fallback). + // No serializer registered: the entry is skipped entirely. let e = client.resource::<String, QueryError>(QueryKey::from("unreg"), cx); e.update(cx, |r, _| { r.apply_success("data".to_string(), crate::client::current_time_ms()) }); - let snap = - client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(3600), cx); + let snap = client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(3600), cx); assert!( snap.entries.is_empty(), - "unregistered type → no value-carrying entry" + "unregistered type -> no value-carrying entry" ); }); }); @@ -123,22 +127,19 @@ fn test_collect_persist_snapshot_filter_and_max_age(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); + client.register_serializer::<String, QueryError>(ser_string); let now = crate::client::current_time_ms(); // Two recent entries under the "users" prefix. for parts in [["users", "1"], ["users", "2"]] { let e = client.resource::<String, QueryError>(QueryKey::from(parts), cx); e.update(cx, |r, _| r.apply_success("v".to_string(), now)); } - // One OLD entry (≈2.7 h in the past) under "posts". + // One entry ~2.8 h in the past under "posts". let e = client.resource::<String, QueryError>(QueryKey::from(["posts", "9"]), cx); e.update(cx, |r, _| { r.apply_success("old".to_string(), now.saturating_sub(10_000_000)) }); - // Prefix "users" → exactly the two users entries. let snap = client.collect_persist_snapshot( &PersistFilter::Prefix(QueryKey::from(["users"])), Duration::from_secs(3600), @@ -146,9 +147,8 @@ fn test_collect_persist_snapshot_filter_and_max_age(cx: &mut TestAppContext) { ); assert_eq!(snap.entries.len(), 2); - // All + 1 s max_age → the old "posts::9" entry is filtered out; the - // two recent users entries (age ~0) survive. (max_age = 0 means - // *disabled*, not "all too old", so a positive small max_age is used.) + // max_age = 0 means "disabled", not "everything is too old", so + // a small positive value is what filters the old entry out here. let snap_all = client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(1), cx); assert_eq!(snap_all.entries.len(), 2); @@ -163,32 +163,16 @@ fn test_persist_with_saves_on_mutation(cx: &mut TestAppContext) { let persister = MemPersister::default(); let captured = persister.last_saved.clone(); - // The bucket stores only `WeakEntity`, so a Success entry must be held by a - // live owner or it is dropped before the (async) observer callback collects - // the snapshot. The harness holds it for the life of the test, mirroring a - // real component holding the `Entity` from `use_query`. + // The bucket stores only WeakEntity, so a live owner must hold the + // Success entry or it dies before the observer collects the snapshot. struct H { _entity: Entity<QueryResource<String, QueryError>>, _handle: PersistHandle, } let harness = cx.new(|cx| { let (_handle, entity) = cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); - let handle = client.persist_with( - persister.clone(), - // Zero debounce: the TestAppContext mock clock does not advance - // wall-clock timers, so a non-zero debounce would never let the - // save fire here. The debounce *logic* (the `is_zero` - // short-circuit) is still exercised; a real app uses a non-zero - // debounce. - PersistOptions { - debounce: Duration::ZERO, - ..PersistOptions::default() - }, - cx, - ); + client.register_serializer::<String, QueryError>(ser_string); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); let entity = client.resource::<String, QueryError>(QueryKey::from("persisted"), cx); entity.update(cx, |r, _| { r.apply_success("data".to_string(), crate::client::current_time_ms()) @@ -201,8 +185,8 @@ fn test_persist_with_saves_on_mutation(cx: &mut TestAppContext) { } }); - // A mutation (on another key) bumps the dirty signal → persist_with collects - // the live cache (including the retained Success entry) and saves. + // A mutation on another key bumps the dirty signal; persist_with collects + // the live cache (including the retained Success entry) and saves it. cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>("trigger", "x".to_string(), cx); @@ -228,7 +212,6 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { setup_query_client(cx); let persister = MemPersister::default(); - // Pre-load a snapshot with one entry whose value is a JSON string. let mut snap = PersistSnapshot { entries: Default::default(), version: crate::client::PERSIST_VERSION, @@ -244,9 +227,8 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { ); *persister.load_value.lock().unwrap() = Some(snap); - // The bucket stores only `WeakEntity`, so hold the "hydrate_k" entity from a - // harness; hydrate's `set_query_data` then reuses the retained entity rather - // than creating one that is immediately dropped. + // Hold the "hydrate_k" entity alive so hydrate's set_query_data reuses it + // instead of creating an entity that is dropped immediately (WeakEntity). struct H { _entity: Entity<QueryResource<String, QueryError>>, } @@ -261,15 +243,12 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { H { _entity: entity } }); - // Run the async hydrate on the background executor, then assert priming. let filter = PersistFilter::All; - let max_age = Duration::from_secs(60 * 60 * 24); + let max_age = DAY; let outcome = cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Drive hydrate synchronously inside the global lease: it is an - // async fn whose future is immediately Ready (the MemPersister's - // load is a clone, no real await). - // We cannot `.await` here, so block on it via a tiny executor. + // The MemPersister load resolves immediately, so the hydrate + // future is Ready on first poll and can be driven synchronously. block_on_ready(hydrate(client, &persister, &filter, max_age, cx)) }) }); @@ -298,7 +277,6 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { setup_query_client(cx); let persister = MemPersister::default(); - // A snapshot with a bogus version. *persister.load_value.lock().unwrap() = Some(PersistSnapshot { entries: Default::default(), version: 9999, @@ -312,7 +290,7 @@ fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { }); let filter = PersistFilter::All; - let max_age = Duration::from_secs(60 * 60 * 24); + let max_age = DAY; let outcome = cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { block_on_ready(hydrate(client, &persister, &filter, max_age, cx)) @@ -328,14 +306,9 @@ fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { } } -// ── H1: a REAL FETCH COMPLETION drives persist_with ────────────────────── -// -// The marquee Core-Change-2 behavior: when a query transitions to -// `QueryStatus::Success` through the real hook fetch path (not via -// `set_query_data`), the `CacheMutation` dirty signal is bumped inside -// `run_query_retry_loop`, which the `persist_with` observer collects and saves. -// This was previously untested — every other test in this file primes the -// cache via `set_query_data` or `apply_success` directly. +// A real fetch completion (not set_query_data) drives persist_with: the +// success arm of the hook retry loop bumps CacheMutation, which the +// persist_with observer collects and saves. #[gpui::test] fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { @@ -343,40 +316,18 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { let persister = MemPersister::default(); let captured = persister.last_saved.clone(); - // The bucket stores only `WeakEntity`, so a Success entry must be held by a - // live owner or it is dropped before the (async) observer collects the - // snapshot. The harness holds the `use_query_manual` entity for the life of - // the test, mirroring a real component holding the `Entity` from - // `use_query`. struct H { entity: Entity<QueryResource<String, QueryError>>, _handle: PersistHandle, } let harness = cx.new(|cx| { - // Global-layer setup: register the serializer and install the - // `persist_with` driver. `cx` here is `&mut Context<H>`, which derefs - // to `&mut App` for `update_global`. let _handle = cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); - client.persist_with( - persister.clone(), - // Zero debounce: the save task runs immediately once the fetch - // resolves and bumps `CacheMutation`. (See - // `test_persist_with_debounce_coalesces` for the non-zero - // debounce / mock-clock path.) - PersistOptions { - debounce: Duration::ZERO, - ..PersistOptions::default() - }, - cx, - ) + client.register_serializer::<String, QueryError>(ser_string); + client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Create the resource via the REAL hook path so the bucket owns a - // `WeakEntity` keyed under "fetched". `use_query_manual` requires an - // entity `Context`, so it must run in the `cx.new` body (not inside - // `update_global`, where only `&mut App` is available). + // The real hook path keys the bucket under "fetched"; + // use_query_manual needs an entity Context, so it cannot run inside + // update_global (which only offers &mut App). let (entity, _sub) = use_query_manual::<String, QueryError, _>( QueryKey::from("fetched"), crate::core::CachePolicy::NoCache, @@ -386,10 +337,8 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { H { entity, _handle } }); - // Drive a REAL fetch to completion through the hook layer. The fetcher - // resolves with a concrete value; on success the retry loop calls - // `complete_success` and bumps `CacheMutation`, waking the `persist_with` - // observer. + // Resolve a real fetch; the success path bumps the dirty signal, waking + // the persist_with observer. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -397,13 +346,8 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { cx, ); }); - - // Pump the executor: the fetch future resolves, the success path bumps the - // dirty signal, the observer collects a fresh snapshot, and (with ZERO - // debounce) the spawned save task runs immediately. cx.run_until_parked(); - // Confirm the fetch really did complete (the trigger is NOT set_query_data). cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!( @@ -419,8 +363,6 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { .unwrap() .clone() .expect("persist_with should have saved after the real fetch completed"); - // The fetched entry's key and value must round-trip into the snapshot via - // the registered serializer. let entry = saved .entries .get("fetched") @@ -433,15 +375,9 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { let _ = harness; } -// ── H2: a REAL MUTATION completion drives persist_with ──────────────────── -// -// Mirrors `test_persist_with_driven_by_real_fetch_completion` for the mutation -// family. Mutations are not themselves persisted (mutation buckets are not -// collected into the snapshot), but a `use_mutation` resolve bumps -// `CacheMutation` inside `run_mutation_loop_inner`, so a retained Success query -// entry IS saved. This catches a regression that dropped the mutation-completion -// bump — previously the only "mutation" test triggered via `set_query_data` on -// a sibling key, so the real mutate() path was exercised only by compilation. +// A real mutation completion bumps the dirty signal the same way. Mutation +// buckets are never collected into the snapshot; what gets saved is the +// retained Success query entry the bump wakes the observer for. #[gpui::test] fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) { @@ -450,28 +386,17 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) let captured = persister.last_saved.clone(); struct H { - // A retained Success query entry — the thing actually persisted. _query: Entity<QueryResource<String, QueryError>>, - // The mutation entity; completing it bumps the dirty signal. mutation: Entity<MutationResource<String, String, QueryError>>, _handle: PersistHandle, } let harness = cx.new(|cx| { let _handle = cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); - client.persist_with( - persister.clone(), - PersistOptions { - debounce: Duration::ZERO, - ..PersistOptions::default() - }, - cx, - ) + client.register_serializer::<String, QueryError>(ser_string); + client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Prime a retained Success query entry. `apply_success` does NOT bump - // `CacheMutation`, so no save fires from this priming. + // Prime a retained Success query entry; apply_success does NOT bump + // CacheMutation, so no save fires from the priming itself. let query = cx.update_global::<QueryClient, _>(|client, cx| { let e = client.resource::<String, QueryError>(QueryKey::from("retained"), cx); e.update(cx, |r, _| { @@ -479,7 +404,6 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) }); e }); - // Create the mutation via the REAL hook path. let (mutation, _msub) = use_mutation::<String, String, QueryError, _>((), cx); H { _query: query, @@ -488,9 +412,6 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) } }); - // Drive a REAL mutation to success. The resolving fetcher hits - // `run_mutation_loop_inner`'s success arm, which bumps `CacheMutation`, - // waking `persist_with` to collect (and save) the retained Success entry. harness.update(cx, |this, cx| { mutate( &this.mutation, @@ -501,7 +422,6 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) }); cx.run_until_parked(); - // Confirm the mutation really did complete (the trigger is NOT set_query_data). cx.update(|cx| { let m = harness.read(cx).mutation.read(cx); assert_eq!( @@ -524,13 +444,9 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) let _ = harness; } -// ── H3: a REAL INFINITE first-page completion drives persist_with ────────── -// -// Mirrors the query marquee for the infinite family. Creating an infinite query -// via `use_infinite_query` auto-fetches the first page; when it resolves, the -// success arm in `run_fetch_next_page_with_id` bumps `CacheMutation`. Infinite -// buckets ARE collected into the snapshot (first page only), so the page is -// saved. This catches a regression that dropped the infinite-completion bump. +// The infinite family: use_infinite_query auto-fetches the first page, and +// its success arm bumps CacheMutation. Infinite buckets are collected (first +// page only), so the page lands in the snapshot. #[gpui::test] fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) { @@ -544,22 +460,12 @@ fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) } let harness = cx.new(|cx| { let _handle = cx.update_global::<QueryClient, _>(|client, cx| { - // The infinite page type is `Vec<String>`; register a serializer - // keyed on that `T` so `collect_persistable_into` emits the page. + // The page type is Vec<String>; the serializer is keyed on it. client.register_serializer::<Vec<String>, QueryError>(|v| { serde_json::to_value(v).expect("serialize") }); - client.persist_with( - persister.clone(), - PersistOptions { - debounce: Duration::ZERO, - ..PersistOptions::default() - }, - cx, - ) + client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Create the infinite query via the REAL hook path; the first-page fetch - // starts immediately and resolves on `run_until_parked`. let (infinite, _isub) = use_infinite_query( InfiniteQueryOptions::new("infinite-feed") .cache_policy(crate::core::CachePolicy::Ttl { ttl_ms: 0 }), @@ -569,11 +475,8 @@ fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) H { infinite, _handle } }); - // Let the first-page fetch resolve → bumps `CacheMutation` → `persist_with` - // collects the infinite first page and saves. cx.run_until_parked(); - // Confirm the infinite query really did fetch (the trigger is NOT set_query_data). cx.update(|cx| { let r = harness.read(cx).infinite.read(cx); assert_eq!( @@ -600,13 +503,9 @@ fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) let _ = harness; } -// ── H4: an IMPERATIVE PreparedFetch completion drives persist_with ───────── -// -// Guards the C1 fix: `PreparedFetch::complete_success` now bumps `CacheMutation` -// (gated on `persist`), so the imperative escape-hatch (`prepare_fetch_query`, -// the TanStack `queryClient.fetchQuery()` equivalent) is no longer invisible to -// `persist_with`. Without the bump, a resolved imperative fetch was silently -// never saved — the hook-layer completions all bumped, but this path did not. +// The imperative escape hatch (prepare_fetch_query, the fetchQuery +// equivalent) also bumps CacheMutation on completion, so imperative results +// are not invisible to persist_with. #[gpui::test] fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext) { @@ -620,21 +519,12 @@ fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext } let harness = cx.new(|cx| { let _handle = cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); - client.persist_with( - persister.clone(), - PersistOptions { - debounce: Duration::ZERO, - ..PersistOptions::default() - }, - cx, - ) + client.register_serializer::<String, QueryError>(ser_string); + client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Create + RETAIN the query resource via the REAL hook path so the - // bucket's WeakEntity stays alive until the observer collects it. - // (`prepare_fetch_query` reuses this same entity via `resource()`.) + // Retain the query via the real hook path so the bucket's WeakEntity + // survives until the observer collects (prepare_fetch_query reuses + // this same entity via resource()). let (query, _qsub) = use_query_manual::<String, QueryError, _>( QueryKey::from("imperative"), crate::core::CachePolicy::NoCache, @@ -644,9 +534,6 @@ fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext H { query, _handle } }); - // Start a request imperatively and complete it with success. The C1 fix - // bumps `CacheMutation` inside `PreparedFetch::complete_success`, waking - // `persist_with` to collect + save the now-Success entry. harness.update(cx, |_this, cx| { cx.update_global::<QueryClient, _>(|client, cx| { let prepared = client @@ -683,16 +570,9 @@ fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext let _ = harness; } -// ── L12: debounce COALESCING with a NON-zero debounce ──────────────────── -// -// `TestAppContext::run_until_parked` drives the executor with `tick(false)`, -// which only fires delayed tasks whose deadline has already passed — it does -// NOT advance the mock wall-clock. However the background executor exposes -// `advance_clock(duration)` (gpui `test-support`), which advances the test -// dispatcher's clock AND matures any due timers. So a non-zero debounce CAN be -// exercised deterministically: fire several rapid mutations, let the bumps -// propagate and stash the latest snapshot, advance the clock past the debounce -// window, then assert EXACTLY ONE save fired containing the latest value. +// Non-zero debounce with a deterministic clock: fire several rapid bumps, +// advance the mock clock past the window, and expect exactly one save +// containing the latest state. #[gpui::test] fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { @@ -703,17 +583,15 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { let debounce = Duration::from_millis(50); - // Hold a live Success entry on the "coalesced" key so the snapshot actually - // contains something to save (the bucket stores only WeakEntity). + // The bucket stores only WeakEntity; the harness keeps the Success entry + // alive so the snapshot has something to save. struct H { _entity: Entity<QueryResource<String, QueryError>>, _handle: PersistHandle, } let harness = cx.new(|cx| { let (_handle, entity) = cx.update_global::<QueryClient, _>(|client, cx| { - client.register_serializer::<String, QueryError>(|s| { - serde_json::to_value(s).expect("serialize") - }); + client.register_serializer::<String, QueryError>(ser_string); let handle = client.persist_with( persister.clone(), PersistOptions { @@ -734,12 +612,8 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { } }); - // Fire N>=3 rapid mutations on the same driver key. Each is issued in its - // OWN `cx.update` so each bump delivers a separate `observe_global` - // notification (GPUI coalesces notifications within a single update), - // spawning a fresh debounced save task. All of these tasks share the single - // `pending` slot, so only the latest snapshot can ever be saved, and only - // one task drains the slot per debounce window. + // Each bump needs its own cx.update: GPUI coalesces notifications raised + // within a single update, which would deliver only one observer call. for i in 0..5_u32 { cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -747,20 +621,15 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { }); }); } - // Let the bumps propagate and stash the latest snapshot; the debounced save - // tasks are now parked on their (un-matured) timers. cx.run_until_parked(); - // Nothing saved yet — the debounce window has not elapsed. assert_eq!( *save_count.lock().unwrap(), 0, "no save should fire before the debounce window elapses" ); - // Advance the mock clock past the debounce window. `advance_clock` matures - // the pending timer tasks; a subsequent `run_until_parked` lets exactly one - // of them drain the shared slot and run `save`. The remaining tasks wake to - // find the slot already empty and no-op. + // advance_clock matures the pending timer; exactly one task collects and + // saves, any others wake to find the window already drained. cx.background_executor .advance_clock(debounce + Duration::from_millis(1)); cx.run_until_parked(); @@ -775,10 +644,8 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { .unwrap() .clone() .expect("the single coalesced save should have produced a snapshot"); - // The "trigger" entities are not retained (created and dropped inside each - // `set_query_data`), so they do not appear in the snapshot — only the - // harness-retained "coalesced" Success entry survives. The point of this - // assertion is that the single coalesced save captured the live cache. + // The "trigger" entities die inside each update, so only the + // harness-retained "coalesced" entry can appear. assert!( saved.entries.contains_key("coalesced"), "the coalesced save should include the retained Success entry: {:?}", @@ -787,8 +654,8 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { let _ = harness; } -// Poll a future that is always immediately Ready (the MemPersister's load is a -// plain clone, no real async work) without pulling in an executor crate. +/// Poll a future that is always immediately Ready (MemPersister's load is a +/// plain clone, no real async work) without pulling in an executor crate. fn block_on_ready<R>(fut: impl std::future::Future<Output = R>) -> R { use std::future::Future; use std::pin::Pin; From 65dd9651d932ab115f779430987df3bdb4dfa59c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 14:38:05 +0200 Subject: [PATCH 025/111] fix: harden cache-control parsing and lock order in gpui-query-http - max-age/s-maxage/stale-while-revalidate digit-overflow now saturates to u64::MAX per RFC 9111 1.2.2 instead of failing the whole fetch - no-store/no-cache dominates from any position: parse errors deferred until after the directive scan (RFC 9111 5.2.2) - directive splitting is quote-aware (backslash escapes included), so quoted-string commas can no longer smuggle in a max-age directive - duplicate directives resolve first-occurrence-wins per RFC 9111 4.2.1 - store_fresh now locks meta then bodies, matching fetch, closing an AB-BA deadlock between the two sync critical sections - malformed Cache-Control on a 200 degrades to serve-with-NoCache instead of discarding a good body; InvalidPolicy stays in the pub enum - freshness lifetime uses checked_add so hydrated CacheMeta cannot panic - perf: eq_ignore_ascii_case directive matching, move cached_meta on 304, store_fresh is sync, reqwest backend takes the header map - 10 new adversarial tests (parsing + fetch-level) --- crates/gpui-query-http/src/backend.rs | 89 ++---- crates/gpui-query-http/src/cache.rs | 281 +++++++++--------- crates/gpui-query-http/src/lib.rs | 251 ++++++++++------ crates/gpui-query-http/src/reqwest_backend.rs | 42 +-- 4 files changed, 335 insertions(+), 328 deletions(-) diff --git a/crates/gpui-query-http/src/backend.rs b/crates/gpui-query-http/src/backend.rs index da9c2d4..4450749 100644 --- a/crates/gpui-query-http/src/backend.rs +++ b/crates/gpui-query-http/src/backend.rs @@ -1,19 +1,14 @@ //! Library-agnostic HTTP backend abstraction. //! //! [`HttpBackend`] abstracts a single conditional `GET` so [`crate::HttpCache`] -//! is not hardcoded to one HTTP client. The crate ships an optional -//! `reqwest`-based implementation behind the `reqwest` cargo feature -//! ([`crate::reqwest_backend::ReqwestBackend`]); any other request library can -//! implement this trait instead and feed [`crate::HttpCache::new`]. +//! is not tied to one HTTP client. The crate ships +//! [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature; +//! implement this trait to plug in any other client. //! -//! The trait deliberately uses `-> impl Future<Output = …> + MaybeSend` -//! (see [`MaybeSend`]) rather than `async fn` so the returned futures are -//! guaranteed `Send` on native targets and usable from any executor (GPUI's -//! `background_executor`, tokio, etc.); on `wasm32` the bound is a no-op -//! because JS interop types — and therefore `reqwest`'s browser-fetch futures -//! — are inherently `!Send`. This makes the trait non-object-safe; dispatch is -//! generic (`HttpCache<B: HttpBackend>`), which is intentional and avoids -//! `dyn` + `Pin<Box<dyn Future>>` overhead. +//! The trait returns `impl Future + MaybeSend` instead of using `async fn` so +//! the futures are `Send` on native targets (usable from any executor) while +//! `wasm32` still works (see [`MaybeSend`]). That makes it non-object-safe; +//! dispatch is static via `HttpCache<B: HttpBackend>`. use std::future::Future; @@ -22,12 +17,10 @@ use http::HeaderMap; use crate::CacheMeta; -/// Conditional request headers a backend should send on a revalidation fetch. -/// -/// Mirrors the two validator headers [`crate::CacheMeta`] tracks. The backend -/// should attach whichever are `Some` to the outgoing request and leave the -/// others unset; servers respond `304 Not Modified` when the validators still -/// match, which [`crate::HttpCache`] turns into a cheap cache hit. +/// Conditional request headers for a revalidation fetch, mirroring the two +/// validators [`crate::CacheMeta`] tracks. Attach whichever are `Some` to the +/// outgoing request; a server that still matches them answers `304 Not +/// Modified`, which [`crate::HttpCache`] turns into a cheap cache hit. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Conditionals { /// The `If-None-Match` header value (sourced from a cached `ETag`). @@ -38,10 +31,8 @@ pub struct Conditionals { } impl Conditionals { - /// Build the conditional headers for a refetch from cached [`CacheMeta`]. - /// - /// Returns [`Conditionals::default`] (no validators) when `meta` is `None`, - /// i.e. on a first fetch with nothing cached yet. + /// Validators from cached `meta`, or [`Conditionals::default`] when + /// `meta` is `None` (first fetch, nothing cached yet). pub fn from_meta(meta: Option<&CacheMeta>) -> Self { let Some(meta) = meta else { return Self::default(); @@ -55,12 +46,10 @@ impl Conditionals { /// An owned, library-agnostic HTTP response. /// -/// Backends translate their native response type into this shape so -/// [`crate::HttpCache`] can reason about status, headers and body without -/// depending on any particular client crate. `headers` is an -/// [`http::HeaderMap`] (the de-facto shared header container) and `body` is -/// owned [`bytes::Bytes`] so the response can outlive the underlying client -/// connection. +/// Backends translate their native response into this shape so +/// [`crate::HttpCache`] can reason about status, headers, and body without +/// depending on any client crate: `http::HeaderMap` headers and owned +/// [`Bytes`] let the response outlive the underlying connection. #[derive(Clone, Debug)] pub struct BackendResponse { /// The HTTP status code (e.g. `200`, `304`). @@ -73,15 +62,9 @@ pub struct BackendResponse { /// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. /// -/// [`HttpBackend::fetch`] bounds its returned future by `MaybeSend` so the -/// same trait works everywhere: on native targets the bound is exactly -/// [`Send`] (any executor may move the future across threads), while on -/// `wasm32` browser-fetch backends such as `reqwest`'s are inherently -/// `!Send` (their futures hold JS values) and execution is single-threaded, -/// so no `Send` requirement is imposed. -/// -/// Outside `wasm32` this is blanket-implemented: `T: MaybeSend` if and only -/// if `T: Send`. +/// Bounds [`HttpBackend::fetch`]'s future: on native targets the bound is +/// exactly [`Send`] (any executor may move the future across threads), and +/// every `Send` type implements it. #[cfg(not(target_arch = "wasm32"))] pub trait MaybeSend: Send {} #[cfg(not(target_arch = "wasm32"))] @@ -89,9 +72,9 @@ impl<T: ?Sized + Send> MaybeSend for T {} /// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. /// -/// On `wasm32` every type implements `MaybeSend`: the browser-fetch backend -/// of `reqwest` (and JS interop types generally) is `!Send` by design, and -/// wasm executes on a single thread, so the `Send` requirement is dropped. +/// On `wasm32` every type implements it: execution is single-threaded and +/// JS interop types (including `reqwest`'s browser-fetch futures) are +/// `!Send` by design, so no `Send` requirement is imposed. #[cfg(target_arch = "wasm32")] pub trait MaybeSend {} #[cfg(target_arch = "wasm32")] @@ -99,30 +82,20 @@ impl<T: ?Sized> MaybeSend for T {} /// A library-agnostic conditional `GET` backend. /// -/// Implement this for your HTTP client of choice (the crate ships -/// [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature) and -/// hand an instance to [`crate::HttpCache::new`]. -/// -/// On native targets the returned future must be `Send` so it can run on any -/// executor; the trait therefore uses `-> impl Future + MaybeSend` (not -/// `async fn`), where [`MaybeSend`] is [`Send`] everywhere except `wasm32`. -/// This makes the trait non-object-safe — dispatch is static, via -/// `HttpCache<B: HttpBackend>`. -/// -/// `fetch` must: +/// Implement this for your HTTP client (the crate ships +/// [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature) +/// and hand an instance to [`crate::HttpCache::new`]. Implementations must +/// attach the [`Conditionals`] validator headers when present, perform the +/// `GET`, and translate the native response into [`BackendResponse`]. /// -/// - attach the [`Conditionals`] validator headers (`If-None-Match` / -/// `If-Modified-Since`) to the outgoing request when present, -/// - perform a `GET`, -/// - translate the native response into [`BackendResponse`] (status, headers, -/// body). +/// The returned future must be [`MaybeSend`] (`Send` everywhere except +/// `wasm32`), which makes the trait non-object-safe; dispatch is static via +/// `HttpCache<B>`. pub trait HttpBackend: Send + Sync { /// The native error type returned by the underlying client. type Error: std::error::Error + Send + Sync + 'static; /// Perform a conditional `GET` against `url`. - /// - /// The returned future must be [`MaybeSend`] (`Send` on native targets). fn fetch( &self, url: &str, diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 714bf34..69d13e1 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -1,21 +1,13 @@ //! The URL-keyed HTTP cache, [`HttpCache`]. //! -//! [`HttpCache`] wraps any [`crate::backend::HttpBackend`] and adds an in-memory -//! cache keyed by URL string: fresh entries short-circuit the network entirely, -//! stale entries are revalidated with conditional headers (`If-None-Match` / -//! `If-Modified-Since`) and a `304 Not Modified` response re-serves the cached -//! body without transferring a new one. +//! [`HttpCache`] wraps any [`crate::backend::HttpBackend`] with an in-memory +//! cache keyed by URL string. Fresh entries skip the network; stale entries +//! revalidate with `If-None-Match` / `If-Modified-Since`, and a `304` +//! re-serves the cached body. //! -//! The cache is library-agnostic — it does not name `reqwest` anywhere. `reqwest` -//! is just one optional backend ([`crate::reqwest_backend::ReqwestBackend`]). -//! -//! # Concurrency -//! -//! State is guarded by [`std::sync::Mutex`]es (one for metadata, one for -//! bodies). The cache is `Send + Sync` and never holds a `std` mutex guard -//! across an `.await` point: a guard is acquired, the needed value is cloned -//! out, and the guard is dropped before any backend call yields. This keeps the -//! cache usable from any async runtime (it does not require `tokio`). +//! Concurrency: two [`std::sync::Mutex`]es (meta, bodies), always locked in +//! that order, never held across an `.await`. The cache is `Send + Sync` and +//! runtime-agnostic. use std::collections::HashMap; use std::sync::Mutex; @@ -32,46 +24,39 @@ use crate::{CacheMeta, ParseError, cache_policy_from_headers}; /// Errors raised by [`HttpCache::fetch`]. #[derive(Debug, Error)] pub enum HttpError { - /// The underlying backend (e.g. `reqwest`) failed to perform the request. - /// - /// The original error is preserved as the `#[source]` so callers can - /// downcast or walk the cause chain. + /// The underlying backend failed to perform the request; the source error + /// is preserved for downcasting or cause-chain walks. #[error("backend request failed")] Backend { /// The source error from the backend. #[source] source: Box<dyn std::error::Error + Send + Sync + 'static>, }, - /// The response cache headers could not be parsed into a [`CachePolicy`]. - /// - /// Wraps the [`ParseError`] produced by [`cache_policy_from_headers`]. + /// Response cache headers could not be parsed into a [`CachePolicy`]. + /// [`HttpCache::fetch`] itself degrades unparseable headers to + /// [`CachePolicy::NoCache`] instead of failing, so this surfaces only for + /// direct users of [`cache_policy_from_headers`]. #[error(transparent)] InvalidPolicy(#[from] ParseError), - /// The server returned `304 Not Modified` but the cache held no prior body - /// for this URL to fall back on. - /// - /// A `304` is only meaningful as a revalidation of a cached entry; without - /// a cached body there is nothing to serve. + /// The server returned `304 Not Modified` but the cache holds no body for + /// this URL to fall back on. #[error("received 304 without a cached body for {url:?}")] NotModifiedWithoutCachedBody { /// The URL that produced the spurious `304`. url: String, }, - /// A cache [`Mutex`] was poisoned by a panicking thread. - /// - /// Rather than panicking the caller (the previous `.expect` behavior), the - /// poison is surfaced as a typed error so a poisoned cache fails one - /// request instead of taking down the process. + /// A cache [`Mutex`] was poisoned; surfaced as a typed error so one + /// poisoned cache fails a request instead of panicking the caller. #[error("cache mutex poisoned")] Poisoned, } /// A URL-keyed HTTP cache layered over a [`HttpBackend`]. /// -/// Generic over the backend (`HttpCache<B: HttpBackend>`) so dispatch is static -/// and there is no `Box<dyn>` overhead. See the [crate docs](crate) for the -/// concurrency model and the backend module for how to plug in a non-`reqwest` -/// client. +/// Generic over the backend so dispatch is static (no `Box<dyn>` overhead). +/// Entries are keyed by the exact URL string: no normalization, and `Vary` is +/// ignored. There is no eviction: the cache grows with every distinct URL, +/// so scope instances accordingly. pub struct HttpCache<B: HttpBackend> { backend: B, meta: Mutex<HashMap<String, CacheMeta>>, @@ -79,7 +64,7 @@ pub struct HttpCache<B: HttpBackend> { } impl<B: HttpBackend> HttpCache<B> { - /// Create a new cache backed by `backend` and starting empty. + /// Create a new cache backed by `backend`, starting empty. pub fn new(backend: B) -> Self { Self { backend, @@ -88,52 +73,33 @@ impl<B: HttpBackend> HttpCache<B> { } } - /// Fetch `url`, serving a fresh cached entry without any network call when - /// possible and revalidating otherwise. - /// - /// Returns the body bytes, the [`CachePolicy`] currently in effect, and the - /// [`CacheMeta`] when an entry exists (it is `None` only for non-cacheable - /// responses, which never populate the cache). - /// - /// # Branches + /// Fetch `url`: a fresh cached entry skips the network, otherwise the + /// backend revalidates. /// - /// - **Fresh cache hit** (`stored_at + fresh_for > now`): returns the cached - /// body immediately; the backend is never called. - /// - **`304 Not Modified`**: returns the previously cached body and the - /// stored policy/meta (the entry is still considered valid). - /// - **`200 OK`**: parses the new cache headers, stores the body + meta, and - /// returns them along with the freshly-derived policy. - /// - **Any other status** (including `no-store` responses): returns the body - /// with [`CachePolicy::NoCache`] and `None` for meta; nothing is stored. + /// Returns `(body, policy, meta)`. Only a cacheable `200` populates the + /// cache and yields `meta`; every other status (and any `no-store`, + /// absent, or unparseable `Cache-Control`) returns the body with + /// [`CachePolicy::NoCache`] and `None`. A `304` re-serves the cached body. pub async fn fetch( &self, url: &str, ) -> Result<(Bytes, CachePolicy, Option<CacheMeta>), HttpError> { - // (a) Read cached meta, clone out, DROP the guard before any .await. let cached_meta = { let guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; guard.get(url).cloned() }; - // (b) Fresh short-circuit: no backend call at all. - if let Some(ref meta) = cached_meta - && meta.stored_at + meta.fresh_for > SystemTime::now() + // Fresh hit: no backend call at all. checked_add: never panic if a + // future serde-hydrated CacheMeta carries an extreme stored_at. + if let Some(meta) = cached_meta.as_ref() + && meta.stored_at.checked_add(meta.fresh_for).is_none_or(|t| t > SystemTime::now()) + && let Some(body) = self.cached_body(url)? { - let body = { - let guard = self.bodies.lock().map_err(|_| HttpError::Poisoned)?; - guard.get(url).cloned() - }; - if let Some(body) = body { - let policy = policy_from_meta(meta); - return Ok((body, policy, Some(meta.clone()))); - } - // Fall through: meta exists but body was evicted — revalidate. + return Ok((body, policy_from_meta(meta), cached_meta.clone())); } + // Not fresh, or meta without a body: revalidate. - // (c) Build conditional headers from the (possibly absent) cached meta. let conditionals = Conditionals::from_meta(cached_meta.as_ref()); - - // (d) Perform the backend fetch. let resp = self .backend .fetch(url, conditionals) @@ -142,13 +108,8 @@ impl<B: HttpBackend> HttpCache<B> { source: Box::new(e), })?; - // (e) 304 — revalidation succeeded: serve the cached body. if resp.status == 304 { - let body = { - let guard = self.bodies.lock().map_err(|_| HttpError::Poisoned)?; - guard.get(url).cloned() - }; - let Some(body) = body else { + let Some(body) = self.cached_body(url)? else { return Err(HttpError::NotModifiedWithoutCachedBody { url: url.to_string(), }); @@ -157,56 +118,57 @@ impl<B: HttpBackend> HttpCache<B> { .as_ref() .map(policy_from_meta) .unwrap_or(CachePolicy::NoCache); - let meta = cached_meta.clone(); - return Ok((body, policy, meta)); + return Ok((body, policy, cached_meta)); } - // (f) 200 — fresh response: parse policy, store body + meta. if resp.status == 200 { - return self.store_fresh(url, resp).await; + return self.store_fresh(url, resp); } - // (g) Any other status: do not cache; return body with NoCache. + // No other status is stored (conservative subset of RFC 9111 §3). Ok((resp.body, CachePolicy::NoCache, None)) } - /// Parse the policy from a `200` response, persist the body and meta, and - /// return the served triple. - async fn store_fresh( + fn cached_body(&self, url: &str) -> Result<Option<Bytes>, HttpError> { + let guard = self.bodies.lock().map_err(|_| HttpError::Poisoned)?; + Ok(guard.get(url).cloned()) + } + + /// Parse the policy from a `200`, store body + meta, return the triple. + fn store_fresh( &self, url: &str, resp: BackendResponse, ) -> Result<(Bytes, CachePolicy, Option<CacheMeta>), HttpError> { let BackendResponse { - status: _, - headers, - body, + headers, body, .. } = resp; - let policy = cache_policy_from_headers(&headers)?; + // A malformed cache hint must never fail the data fetch itself: + // serve the body uncacheable. + let Ok(policy) = cache_policy_from_headers(&headers) else { + return Ok((body, CachePolicy::NoCache, None)); + }; - // NoCache means the server forbade caching: serve the body but store - // nothing. if policy == CachePolicy::NoCache { return Ok((body, CachePolicy::NoCache, None)); } - let now = SystemTime::now(); let meta = CacheMeta { etag: header_str(&headers, "etag"), last_modified: header_str(&headers, "last-modified"), - stored_at: now, + stored_at: SystemTime::now(), fresh_for: fresh_for_from_policy(policy), stale_for: stale_for_from_policy(policy), }; - // Store under both locks, dropping each guard before yielding. + // Lock order is meta -> bodies everywhere. { - let mut bodies = self.bodies.lock().map_err(|_| HttpError::Poisoned)?; - bodies.insert(url.to_string(), body.clone()); + let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; + guard.insert(url.to_string(), meta.clone()); } { - let mut meta_guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; - meta_guard.insert(url.to_string(), meta.clone()); + let mut guard = self.bodies.lock().map_err(|_| HttpError::Poisoned)?; + guard.insert(url.to_string(), body.clone()); } Ok((body, policy, Some(meta))) @@ -222,22 +184,19 @@ fn header_str(headers: &HeaderMap, name: &str) -> Option<String> { .map(str::to_string) } -/// `fresh_for` ([`Duration`]) from a policy's TTL window. +/// `fresh_for` from a policy's TTL window. fn fresh_for_from_policy(policy: CachePolicy) -> Duration { Duration::from_millis(policy.ttl_ms().unwrap_or(0)) } -/// `stale_for` ([`Duration`]) from a policy's SWR window. +/// `stale_for` from a policy's SWR window. fn stale_for_from_policy(policy: CachePolicy) -> Duration { Duration::from_millis(policy.stale_ms().unwrap_or(0)) } -/// Reconstruct the [`CachePolicy`] a [`CacheMeta`] was derived from. -/// -/// Mirrors [`fresh_for_from_policy`] / [`stale_for_from_policy`]: a non-zero -/// `stale_for` selects [`CachePolicy::StaleWhileRevalidate`], otherwise a -/// non-zero `fresh_for` selects [`CachePolicy::Ttl`], and both-zero collapses -/// to [`CachePolicy::NoCache`]. +/// Invert the two helpers above: non-zero `stale_for` selects +/// [`CachePolicy::StaleWhileRevalidate`], non-zero `fresh_for` selects +/// [`CachePolicy::Ttl`], both-zero collapses to [`CachePolicy::NoCache`]. fn policy_from_meta(meta: &CacheMeta) -> CachePolicy { let ttl_ms = u64::try_from(meta.fresh_for.as_millis()).unwrap_or(0); let stale_ms = u64::try_from(meta.stale_for.as_millis()).unwrap_or(0); @@ -259,9 +218,8 @@ mod tests { use std::collections::VecDeque; use std::future::Future; - /// A mock backend that returns canned [`BackendResponse`]s from a FIFO - /// queue, recording the number of times `fetch` was actually invoked so - /// tests can assert short-circuit behavior. + /// Mock backend: pops canned responses from a FIFO queue and counts + /// calls so tests can assert short-circuit behavior. struct MockBackend { responses: Mutex<VecDeque<Result<BackendResponse, MockError>>>, calls: Mutex<usize>, @@ -296,18 +254,16 @@ mod tests { _url: &str, _conditionals: Conditionals, ) -> impl Future<Output = Result<BackendResponse, MockError>> + MaybeSend { - // Count the call and pop the next canned response. let next = { let mut calls = self.calls.lock().unwrap(); *calls += 1; - let mut responses = self.responses.lock().unwrap(); - responses.pop_front() + self.responses.lock().unwrap().pop_front() }; + // Queue exhausted -> mock error so the test fails loudly. async move { match next { Some(Ok(r)) => Ok(r), Some(Err(e)) => Err(e), - // Queue exhausted — surface a mock error so the test fails loudly. None => Err(MockError), } } @@ -334,8 +290,6 @@ mod tests { } } - /// (a) A `200` with `max-age` stores the body + meta and returns the - /// server-derived policy. #[tokio::test] async fn two_hundred_stores_body_and_meta() { let backend = MockBackend::new(vec![Ok(resp_200("hello", "max-age=600"))]); @@ -349,14 +303,10 @@ mod tests { assert_eq!(meta.stale_for, Duration::ZERO); } - /// (b) A second fetch while the entry is still fresh short-circuits: the - /// backend is never called for the second response, so the mock queue still - /// has it. #[tokio::test] async fn fresh_entry_short_circuits_no_backend_call() { let backend = MockBackend::new(vec![ Ok(resp_200("first", "max-age=600")), - // This would be returned on a second backend call — which must NOT happen. Ok(resp_200("should-not-happen", "max-age=1")), ]); let cache = HttpCache::new(backend); @@ -366,23 +316,13 @@ mod tests { assert_eq!(policy1, CachePolicy::Ttl { ttl_ms: 600_000 }); let (body2, policy2, _) = cache.fetch("https://example.test/b").await.unwrap(); - assert_eq!( - body2, - Bytes::from_static(b"first"), - "fresh hit serves cached body" - ); + assert_eq!(body2, Bytes::from_static(b"first"), "fresh hit serves cached body"); assert_eq!(policy2, CachePolicy::Ttl { ttl_ms: 600_000 }); - // Exactly one backend call happened; the queued second response is untouched. assert_eq!(cache.backend.calls(), 1); - assert_eq!( - cache.backend.remaining(), - 1, - "the second canned response must still be queued" - ); + assert_eq!(cache.backend.remaining(), 1, "second canned response untouched"); } - /// (c) A `304` to a conditional refetch returns the previously cached body. #[tokio::test] async fn not_modified_returns_cached_body() { let backend = MockBackend::new(vec![ @@ -394,18 +334,12 @@ mod tests { let (body1, _, _) = cache.fetch("https://example.test/c").await.unwrap(); assert_eq!(body1, Bytes::from_static(b"payload")); - // max-age=0 → not fresh → triggers a conditional refetch → 304. + // max-age=0 -> not fresh -> conditional refetch -> 304. let (body2, _, meta2) = cache.fetch("https://example.test/c").await.unwrap(); - assert_eq!( - body2, - Bytes::from_static(b"payload"), - "304 served cached body" - ); + assert_eq!(body2, Bytes::from_static(b"payload"), "304 served cached body"); assert!(meta2.is_some(), "304 still yields cached meta"); } - /// (d) A `200` with `no-store` returns [`CachePolicy::NoCache`] and stores - /// nothing. #[tokio::test] async fn no_store_returns_no_cache_and_stores_nothing() { let backend = MockBackend::new(vec![Ok(resp_200("ephemeral", "no-store"))]); @@ -415,8 +349,6 @@ mod tests { assert_eq!(body, Bytes::from_static(b"ephemeral")); assert_eq!(policy, CachePolicy::NoCache); assert!(meta.is_none(), "no-store must not produce meta"); - - // Nothing stored — meta map empty. assert!( cache .meta @@ -427,19 +359,80 @@ mod tests { ); } - /// (e) A `304` with no cached body for the URL surfaces a typed error rather - /// than serving nothing — a `304` is only meaningful as a revalidation of a - /// cached entry. + #[tokio::test] + async fn malformed_cache_control_degrades_to_no_cache() { + // A malformed cache hint must not fail the data fetch. + let backend = MockBackend::new(vec![Ok(resp_200("body", "max-age=abc"))]); + let cache = HttpCache::new(backend); + + let (body, policy, meta) = cache.fetch("https://example.test/f").await.unwrap(); + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!(policy, CachePolicy::NoCache); + assert!(meta.is_none()); + assert!( + cache + .meta + .lock() + .unwrap() + .get("https://example.test/f") + .is_none() + ); + } + + #[tokio::test] + async fn non_two_hundred_is_not_cached() { + let backend = MockBackend::new(vec![Ok(BackendResponse { + status: 404, + headers: HeaderMap::new(), + body: Bytes::copy_from_slice(b"missing"), + })]); + let cache = HttpCache::new(backend); + + let (body, policy, meta) = cache.fetch("https://example.test/g").await.unwrap(); + assert_eq!(body, Bytes::from_static(b"missing")); + assert_eq!(policy, CachePolicy::NoCache); + assert!(meta.is_none()); + assert!( + cache + .meta + .lock() + .unwrap() + .get("https://example.test/g") + .is_none() + ); + } + + #[tokio::test] + async fn overflow_max_age_caches_saturated() { + // RFC 9111 §1.2.2: over-large delta-seconds saturate; the entry is + // effectively fresh forever and later fetches short-circuit. + let backend = MockBackend::new(vec![ + Ok(resp_200("big", "max-age=99999999999999999999999")), + Ok(resp_200("second", "max-age=1")), + ]); + let cache = HttpCache::new(backend); + + let (body, policy, _) = cache.fetch("https://example.test/h").await.unwrap(); + assert_eq!(body, Bytes::from_static(b"big")); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: u64::MAX }); + + let (body2, policy2, meta2) = cache.fetch("https://example.test/h").await.unwrap(); + assert_eq!(body2, Bytes::from_static(b"big")); + assert_eq!(policy2, CachePolicy::Ttl { ttl_ms: u64::MAX }); + assert!(meta2.is_some()); + assert_eq!(cache.backend.calls(), 1); + } + #[tokio::test] async fn not_modified_without_cached_body_is_typed_error() { - // First fetch returns 304 directly (no prior cache to fall back on). + // A 304 is only meaningful as a revalidation of a cached entry. let backend = MockBackend::new(vec![Ok(resp_304_with_etag("\"v1\""))]); let cache = HttpCache::new(backend); let err = cache.fetch("https://example.test/e").await.unwrap_err(); assert!( matches!(err, HttpError::NotModifiedWithoutCachedBody { .. }), - "a 304 with no cached body should surface NotModifiedWithoutCachedBody, got {err:?}" + "got {err:?}" ); } } diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 7b62f63..088722e 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -1,20 +1,10 @@ -//! HTTP cache-header helpers for [`gpui_query`] — turn server cache headers into -//! a [`gpui_query::core::CachePolicy`] ("server wins") and layer an in-memory -//! [`HttpCache`] over any [`HttpBackend`]. +//! HTTP cache-header helpers for [`gpui_query`]: turn server cache headers +//! into a [`CachePolicy`] ("server wins") and layer an in-memory [`HttpCache`] +//! over any [`HttpBackend`]. //! -//! This crate depends on `gpui-query` with the **`core`** feature only (no -//! GPUI), so it is usable from any async context, and it keeps -//! `reqwest` / `http` / `bytes` out of the core crate (Guiding Principle 1 of -//! `docs/features.md`). -//! -//! # Library-agnostic by design -//! -//! [`HttpCache`] is generic over a [`HttpBackend`] — a trait that abstracts a -//! single conditional `GET`. The crate ships *one* optional backend, -//! [`ReqwestBackend`], behind the -//! `reqwest` cargo feature; any other request library can implement -//! [`HttpBackend`] and plug into [`HttpCache::new`](HttpCache::new) instead. -//! `reqwest` is never a hard dependency. +//! Depends on `gpui-query` core only (no GPUI), so this works from any async +//! runtime. [`HttpCache`] is library-agnostic; the optional `reqwest` feature +//! supplies [`ReqwestBackend`] as one backend. //! //! # Server wins //! @@ -35,8 +25,7 @@ #![deny(missing_docs)] // docs.rs renders with `--cfg docsrs` (see [package.metadata.docs.rs]); enable -// `#[doc(cfg(...))]` there so the `reqwest`-gated items are annotated with the -// feature that enables them, matching the main crate's convention. +// `#[doc(cfg(...))]` there so feature-gated items are annotated. #![cfg_attr(docsrs, feature(doc_cfg))] use std::time::Duration; @@ -60,14 +49,9 @@ pub use reqwest_backend::ReqwestBackend; /// HTTP cache metadata extracted from a response. /// -/// Serializable so a future persistence layer can store it alongside the body -/// and rehydrate a cold start with valid ETags, enabling cheap `304` refetches -/// on the first request after launch. -/// -/// Timestamps use [`SystemTime`](std::time::SystemTime) (serde-supported, -/// epoch-relative) — never [`std::time::Instant`], which has no serde impl and -/// is meaningless across process restarts. This matches the `current_time_ms()` -/// convention in `gpui-query`. +/// Serializable (epoch-based [`SystemTime`](std::time::SystemTime)) so a +/// persistence layer can store it alongside the body and rehydrate a cold +/// start with valid validators for cheap `304` refetches. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CacheMeta { /// `ETag` response header, if present (for `If-None-Match` on refetch). @@ -95,33 +79,30 @@ pub enum ParseError { /// Derive a [`CachePolicy`] from response cache headers ("server wins"). /// -/// Rules, in priority order: -/// -/// 1. `Cache-Control: no-store` or `no-cache` (any value, including bare) → -/// [`CachePolicy::NoCache`]. -/// 2. `Cache-Control: max-age=N` (seconds) → [`CachePolicy::Ttl`] with -/// `ttl_ms = N * 1000`. If `stale-while-revalidate=M` is also present, yields -/// [`CachePolicy::StaleWhileRevalidate`] instead. `s-maxage` is treated like -/// `max-age` (the shared-cache directive) and takes precedence when both are -/// present. -/// 3. Otherwise → [`CachePolicy::NoCache`] (no usable cache directives; an -/// `Expires`-based heuristic may be added later). +/// - `no-store` / `no-cache` anywhere returns [`CachePolicy::NoCache`], +/// regardless of position or malformed directives elsewhere (RFC 9111 +/// §5.2.2: storing is forbidden outright). +/// - Otherwise the first `s-maxage` (falling back to `max-age`) sets the TTL; +/// a `stale-while-revalidate` alongside yields +/// [`CachePolicy::StaleWhileRevalidate`]. Duplicates keep their first +/// occurrence (RFC 9111 §4.2.1), and a delta-seconds too large for `u64` +/// saturates instead of erroring (RFC 9111 §1.2.2). +/// - Anything else returns [`CachePolicy::NoCache`]; malformed values surface +/// as [`ParseError`]. /// -/// `max-age` / `s-maxage` take precedence over each other and any other -/// directive per [RFC 9111]. Directive names are matched case-insensitively and -/// values may be quoted (`max-age="600"`). -/// -/// [RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111 +/// Directive names match case-insensitively; values may be quoted. pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, ParseError> { - let mut s_maxage_secs: Option<u64> = None; - let mut max_age_secs: Option<u64> = None; - let mut stale_while_revalidate_secs: Option<u64> = None; + // Slots are Option<Result<..>>: first occurrence wins, and a malformed + // value is only surfaced after the scan so no-store/no-cache dominates. + let mut s_maxage: Option<Result<u64, ParseError>> = None; + let mut max_age: Option<Result<u64, ParseError>> = None; + let mut swr: Option<Result<u64, ParseError>> = None; for value in headers.get_all(http::header::CACHE_CONTROL).iter() { let Ok(raw) = value.to_str() else { continue; }; - for directive in raw.split(',') { + for directive in split_cache_directives(raw) { let directive = directive.trim(); if directive.is_empty() { continue; @@ -130,62 +111,93 @@ pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, Par Some((n, v)) => (n.trim(), Some(v.trim().trim_matches('"'))), None => (directive, None), }; - match name.to_ascii_lowercase().as_str() { - // no-store / no-cache win immediately per rule 1 and RFC 9111 §5.2.1.5: - // they are never cacheable, so a malformed directive that happens to - // follow them (e.g. `no-store, max-age=abc`) must not surface as a - // parse error. - "no-store" | "no-cache" => return Ok(CachePolicy::NoCache), - "s-maxage" => { - if let Some(v) = val { - s_maxage_secs = Some(parse_secs(false, v)?); - } - } - "max-age" => { - if let Some(v) = val { - max_age_secs = Some(parse_secs(false, v)?); - } - } - "stale-while-revalidate" => { - if let Some(v) = val { - stale_while_revalidate_secs = Some(parse_secs(true, v)?); - } - } - _ => {} + if name.eq_ignore_ascii_case("no-store") || name.eq_ignore_ascii_case("no-cache") { + return Ok(CachePolicy::NoCache); + } + let is_swr = name.eq_ignore_ascii_case("stale-while-revalidate"); + let slot = if is_swr { + Some(&mut swr) + } else if name.eq_ignore_ascii_case("s-maxage") { + Some(&mut s_maxage) + } else if name.eq_ignore_ascii_case("max-age") { + Some(&mut max_age) + } else { + None + }; + if let Some(slot) = slot + && slot.is_none() + && let Some(v) = val + { + *slot = Some(parse_secs(is_swr, v)); } } } - // s-maxage (shared cache) takes precedence over max-age when both are set. - let ttl_secs = s_maxage_secs.or(max_age_secs); - if let Some(secs) = ttl_secs { - let ttl_ms = secs.saturating_mul(1000); - return Ok(if let Some(stale_secs) = stale_while_revalidate_secs { - CachePolicy::StaleWhileRevalidate { - ttl_ms, - stale_ms: stale_secs.saturating_mul(1000), - } - } else { - CachePolicy::Ttl { ttl_ms } - }); + let s_maxage_secs = s_maxage.transpose()?; + let max_age_secs = max_age.transpose()?; + let swr_secs = swr.transpose()?; + + match (s_maxage_secs.or(max_age_secs), swr_secs) { + (Some(secs), Some(stale)) => Ok(CachePolicy::StaleWhileRevalidate { + ttl_ms: secs.saturating_mul(1000), + stale_ms: stale.saturating_mul(1000), + }), + (Some(secs), None) => Ok(CachePolicy::Ttl { + ttl_ms: secs.saturating_mul(1000), + }), + (None, _) => Ok(CachePolicy::NoCache), } +} - // No usable cache directives — do not cache. - Ok(CachePolicy::NoCache) +/// Split a `Cache-Control` value on commas outside quoted-strings, so a +/// quoted argument containing `,` cannot smuggle in extra directives. +fn split_cache_directives(raw: &str) -> impl Iterator<Item = &str> { + let mut pos = 0; + std::iter::from_fn(move || { + if pos > raw.len() { + return None; + } + let bytes = raw.as_bytes(); + let start = pos; + let mut end = bytes.len(); + let mut quoted = false; + let mut i = start; + while i < bytes.len() { + match bytes[i] { + // Skip the character after a backslash inside a quoted-string. + b'\\' if quoted => i += 1, + b'"' => quoted = !quoted, + b',' if !quoted => { + end = i; + break; + } + _ => {} + } + i += 1; + } + pos = if end < bytes.len() { + end + 1 + } else { + bytes.len() + 1 + }; + Some(&raw[start..end]) + }) } -/// Parse a `Cache-Control` delta-seconds value into seconds. -/// -/// `is_stale` selects which [`ParseError`] variant is returned on a malformed -/// value. HTTP delta-seconds must be non-negative integers; values larger than -/// `u64::MAX` are out of scope (the header itself is bounded far below that). +/// Parse a delta-seconds argument. All-digit values that overflow `u64` +/// saturate to `u64::MAX` per RFC 9111 §1.2.2; anything else is malformed. fn parse_secs(is_stale: bool, raw: &str) -> Result<u64, ParseError> { - raw.parse::<u64>().map_err(|_| { - if is_stale { - ParseError::InvalidStaleWhileRevalidate(raw.to_string()) - } else { - ParseError::InvalidMaxAge(raw.to_string()) - } + if !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit()) { + return Ok(match raw.parse::<u128>() { + Ok(n) => n.min(u64::MAX as u128) as u64, + // Longer than u128: still all digits, still saturate. + Err(_) => u64::MAX, + }); + } + Err(if is_stale { + ParseError::InvalidStaleWhileRevalidate(raw.to_string()) + } else { + ParseError::InvalidMaxAge(raw.to_string()) }) } @@ -245,12 +257,17 @@ mod tests { #[test] fn no_store_short_circuits_before_parsing_later_directives() { - // Rule 1 priority: `no-store` wins immediately, so a malformed trailing - // `max-age` must NOT surface as `InvalidMaxAge`. (RFC 9111 §5.2.1.5.) let policy = cache_policy_from_headers(&cc("no-store, max-age=abc")).unwrap(); assert_eq!(policy, CachePolicy::NoCache); } + #[test] + fn no_store_wins_even_after_malformed_value() { + // Order-independent: a malformed max-age must not mask no-store. + let policy = cache_policy_from_headers(&cc("max-age=abc, no-store")).unwrap(); + assert_eq!(policy, CachePolicy::NoCache); + } + #[test] fn no_cache_control_yields_no_cache() { let policy = cache_policy_from_headers(&HeaderMap::new()).unwrap(); @@ -269,13 +286,25 @@ mod tests { assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 60_000 }); } + #[test] + fn whitespace_around_equals_is_tolerated() { + let policy = cache_policy_from_headers(&cc("max-age = 120")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 120_000 }); + } + #[test] fn other_directives_are_ignored() { - // `public`/`private` don't map to a CachePolicy variant; max-age still applies. let policy = cache_policy_from_headers(&cc("public, max-age=5")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 5_000 }); } + #[test] + fn quoted_comma_cannot_smuggle_directives() { + // The "max-age" text is inside a quoted argument, not a directive. + let policy = cache_policy_from_headers(&cc("private=\"a, max-age=86400\"")).unwrap(); + assert_eq!(policy, CachePolicy::NoCache); + } + #[test] fn multiple_cache_control_headers_combine() { let mut h = HeaderMap::new(); @@ -294,6 +323,30 @@ mod tests { ); } + #[test] + fn duplicate_directives_keep_first_occurrence() { + // RFC 9111 §4.2.1: first occurrence wins, so a trailing injected + // duplicate cannot extend the TTL. + let policy = cache_policy_from_headers(&cc("max-age=600, max-age=86400")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 600_000 }); + } + + #[test] + fn digit_overflow_saturates_instead_of_erroring() { + // RFC 9111 §1.2.2: values too large to represent are the largest + // representable value, not an error. + let policy = cache_policy_from_headers(&cc("max-age=99999999999999999999999")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: u64::MAX }); + } + + #[test] + fn bare_max_age_without_value_is_ignored() { + assert_eq!( + cache_policy_from_headers(&cc("max-age")).unwrap(), + CachePolicy::NoCache + ); + } + #[test] fn invalid_max_age_is_typed_error() { let err = cache_policy_from_headers(&cc("max-age=abc")).unwrap_err(); @@ -301,6 +354,12 @@ mod tests { assert!(err.to_string().contains("max-age")); } + #[test] + fn negative_max_age_is_typed_error() { + let err = cache_policy_from_headers(&cc("max-age=-1")).unwrap_err(); + assert!(matches!(err, ParseError::InvalidMaxAge(_))); + } + #[test] fn invalid_swr_is_typed_error() { let err = diff --git a/crates/gpui-query-http/src/reqwest_backend.rs b/crates/gpui-query-http/src/reqwest_backend.rs index 0766a7c..4ae772b 100644 --- a/crates/gpui-query-http/src/reqwest_backend.rs +++ b/crates/gpui-query-http/src/reqwest_backend.rs @@ -1,42 +1,29 @@ -//! The optional `reqwest`-based [`HttpBackend`] implementation. -//! -//! This module is only compiled when the `reqwest` cargo feature is enabled: +//! The optional `reqwest`-based [`HttpBackend`] implementation, compiled when +//! the `reqwest` cargo feature is enabled: //! //! ```toml //! [dependencies] //! gpui-query-http = { version = "0.1", features = ["reqwest"] } //! ``` //! -//! `reqwest` is intentionally *one of many* possible backends — see -//! [`crate::backend`] for the library-agnostic trait. Any HTTP client that can -//! perform a conditional `GET` and produce a status + headers + body can plug -//! into [`crate::HttpCache`] instead. +//! `reqwest` is just one backend; any client that can perform a conditional +//! `GET` can implement [`crate::backend::HttpBackend`] instead. use std::future::Future; use crate::backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; -/// A [`HttpBackend`] backed by [`reqwest`]. -/// -/// Wraps a `reqwest::Client` (which can be configured with a TLS provider, -/// timeouts, proxies, etc. by the caller before construction). The client is -/// reused across requests, as `reqwest` intends. -#[cfg(feature = "reqwest")] +/// A [`HttpBackend`] backed by a caller-configured [`reqwest::Client`], +/// reused across requests as `reqwest` intends. pub struct ReqwestBackend(pub reqwest::Client); -#[cfg(feature = "reqwest")] impl ReqwestBackend { - /// Convenience constructor wrapping the default [`reqwest::Client`]. - /// - /// For non-default configuration (custom TLS, timeouts, …), construct the - /// client yourself and pass it to [`ReqwestBackend::from_client`] (or - /// `ReqwestBackend(client)` directly via the tuple struct). + /// Wrap a pre-configured client; `ReqwestBackend(client)` works too. pub fn from_client(client: reqwest::Client) -> Self { Self(client) } } -#[cfg(feature = "reqwest")] impl HttpBackend for ReqwestBackend { type Error = reqwest::Error; @@ -45,12 +32,8 @@ impl HttpBackend for ReqwestBackend { url: &str, conditionals: Conditionals, ) -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + MaybeSend { - // Build the request synchronously (no .await), then return the future - // for the send+collect half. Building eagerly here keeps the returned - // future `Send` on native targets even though `reqwest::RequestBuilder` - // itself is `!Send` in some configurations. On wasm32 the future is - // `!Send` by design (reqwest's browser-fetch backend holds JS values), - // which the `MaybeSend` bound accommodates. + // Build eagerly so the returned future stays `Send` on native targets + // even where RequestBuilder is not. let mut req = self.0.get(url); if let Some(etag) = conditionals.if_none_match { req = req.header(reqwest::header::IF_NONE_MATCH, etag); @@ -59,11 +42,10 @@ impl HttpBackend for ReqwestBackend { req = req.header(reqwest::header::IF_MODIFIED_SINCE, since); } async move { - let resp = req.send().await?; + let mut resp = req.send().await?; let status = resp.status().as_u16(); - // `reqwest`'s header map re-exports `http::HeaderMap`, so this clone - // is a cheap reference bump into the owned map. - let headers = resp.headers().clone(); + // Take the map: HeaderMap::clone would copy every entry. + let headers = std::mem::take(resp.headers_mut()); let body = resp.bytes().await?; Ok(BackendResponse { status, From c97a01046a24e719040ac0cd2e3e8145faf51cb0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 15:02:08 +0200 Subject: [PATCH 026/111] refactor: trim file persister plumbing and test-lock atomic write integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic-write path audited sound (NamedTempFile in the target directory, fsync before rename, parent-dir fsync); temp files now carry a .tmp suffix so crash orphans are identifiable. load no longer takes the write lock — the atomic rename guarantees lock-free reads always see a complete file. Owner-only 0o600 perms (tempfile + rename) documented and locked by a cfg(unix) mode test. Dead plumbing deleted (empty_snapshot, fullfsync_err, log_or_eprint); corrupt-load paths unified; meta Option via .transpose(). +4 integrity tests: missing parent dirs, no leftover temp files, 0o600 mode, second-instance overwrite stays parseable. Module doc and comments stripped. Gates: 833/0 tests, clippy -D warnings clean, doc 0 warnings. --- crates/gpui-query-persist/src/lib.rs | 284 +++++++----------- .../tests/file_persister.rs | 100 ++++-- 2 files changed, 182 insertions(+), 202 deletions(-) diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 0f50fd6..2c9d9a5 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -1,50 +1,44 @@ -//! Reference disk-based persistence adapter for [`gpui_query`]. -//! -//! Provides [`FilePersister`], a [`Persister`] -//! implementation that atomically writes a [`PersistSnapshot`] to disk and -//! tolerantly loads it back, plus a [`NoopPersister`] for tests/disabled modes. +//! Reference disk persistence adapter for [`gpui_query`]: [`FilePersister`], +//! an atomic, durable [`Persister`] over one JSON or bincode file, plus a +//! re-exported [`NoopPersister`] for tests and disabled modes. //! //! # Atomic write //! -//! Each save serializes the snapshot to a sibling `NamedTempFile` (via -//! [`tempfile`]), fsyncs it (issuing `F_FULLFSYNC` on macOS for true durability, -//! plain `fsync` elsewhere), then renames it over the target (`rename(2)` on -//! POSIX, `MoveFileEx` semantics on Windows via tempfile's `.persist()`). On -//! POSIX the parent directory is fsynced after the replace so the rename is -//! durable across power loss. +//! Each save serializes the snapshot to a sibling [`tempfile::NamedTempFile`] +//! (random name, created `O_EXCL` in the target's own directory, so a shared +//! `/tmp` is never involved and symlink planting fails), fsyncs it +//! (`F_FULLFSYNC` on macOS, plain `fsync` elsewhere), then renames it over +//! the target. On POSIX the parent directory is fsynced after the replace. +//! A crash mid-write therefore leaves the previous file intact plus, at +//! worst, one stray `<name>.<random>.tmp` sibling. The file is created with +//! owner-only permissions (`0o600` on Unix), since a query cache is app- and +//! user-private data. //! //! # Tolerant load //! -//! - Missing file → empty snapshot. -//! - Corrupt / unparseable file → logged + empty snapshot (no panic). -//! - Version mismatch → [`PersistError::VersionMismatch`] (typed, so callers -//! can distinguish "corrupt" from "wrong format"). +//! A missing file yields an empty snapshot; a corrupt or unparseable one is +//! logged and treated as empty; a version mismatch returns +//! [`PersistError::VersionMismatch`] so callers can tell "corrupt" from +//! "wrong format". //! //! # Concurrency //! -//! Writes are serialized via a `std::sync::Mutex` so concurrent `save` calls -//! from the GPUI background executor never interleave temp-file lifecycles. -//! Reads take the same lock briefly. No `tokio` is required: the persister -//! runs its async methods on GPUI's `background_executor`. -//! -//! # Blocking I/O note -//! -//! `FilePersister` performs synchronous `std::fs` I/O inside its async `load` -//! and `save` bodies and is intended to run on GPUI's `background_executor`, -//! which is a dedicated blocking-friendly thread pool. Callers running on a -//! `tokio` multi-thread runtime that need non-blocking semantics should wrap -//! `load`/`save` in `spawn_blocking` (e.g. via a `tokio::task::spawn_blocking` -//! → bridge) to avoid stalling executor threads. +//! Saves on one persister are serialized by a `std::sync::Mutex`. Loads skip +//! the lock: the rename is atomic, so a load concurrent with a save sees +//! either the old or the new complete file. Two persister instances on the +//! same path likewise cannot corrupt each other; each save replaces the +//! whole file and the last writer wins, matching the [`Persister`] contract. //! -//! # Windows `ERROR_ACCESS_DENIED` (retryable) +//! The async methods do synchronous `std::fs` I/O with no await points, which +//! is what GPUI's blocking-friendly `background_executor` is for. On a tokio +//! runtime, wrap `load`/`save` in `spawn_blocking` to avoid stalling worker +//! threads. //! -//! On Windows the atomic replace can fail with `ERROR_ACCESS_DENIED` when an -//! antivirus scanner or concurrent reader holds the destination. This is -//! surfaced as [`PersistError::Permission`] (rather than flattened into an IO -//! error), preserving the retryable signal so callers can back off and retry -//! the save. All other persist failures are returned as -//! [`PersistError::Io`] carrying the original `std::io::Error` (kind + source -//! chain intact) so no diagnostic detail is lost. +//! On Windows the atomic replace can fail with `ERROR_ACCESS_DENIED` while an +//! antivirus scanner or concurrent reader holds the destination; that is +//! surfaced as the retryable [`PersistError::Permission`] rather than +//! [`PersistError::Io`], which still carries the original error (kind and +//! source chain intact) for every other failure. #![deny(missing_docs)] @@ -70,10 +64,9 @@ pub enum PersistFormat { /// Atomic, durable disk-backed [`Persister`]. /// -/// Writes go to a sibling temp file (via [`tempfile::NamedTempFile`]), which is -/// fsynced and then renamed over the target so a crash mid-write never leaves a -/// truncated/corrupt cache file. See the [crate-level docs](crate) for the full -/// durability story. +/// Saves write a sibling [`tempfile::NamedTempFile`], fsync it, and rename it +/// over the target, so a crash never leaves a truncated file. See the +/// [crate docs](crate) for the durability and concurrency story. pub struct FilePersister { path: PathBuf, format: PersistFormat, @@ -100,21 +93,18 @@ impl FilePersister { Self::new(path, PersistFormat::Bincode) } - /// Construct a persister rooted at the OS cache directory (`dirs::cache_dir`) - /// joined with `app_name`, storing `cache` (JSON format). + /// Construct a JSON persister at `<cache_dir>/<app_name>/gpui-query-cache.json`. /// - /// Returns [`PersistError::BadPath`] when the OS reports no cache dir - /// (e.g. the platform does not define one). This matches the Open Question 8 - /// decision in the design doc: `cache_dir` (regenerable offline cache) - /// rather than Roaming, so a cold start after launch reconstructs the cache - /// without syncing stale state. + /// Returns [`PersistError::BadPath`] when the OS reports no cache dir. + /// The cache dir (rather than Roaming config) is deliberate: the file is + /// a regenerable offline cache, not state worth syncing. `app_name` is + /// joined as-is, so treat it as trusted configuration. pub fn in_cache_dir(app_name: impl AsRef<str>) -> Result<Self, PersistError> { let app_name = app_name.as_ref(); let dir = dirs::cache_dir().ok_or_else(|| { PersistError::BadPath(format!("no OS cache dir available for app {app_name:?}")) })?; - let path = dir.join(app_name).join("gpui-query-cache.json"); - Ok(Self::json(path)) + Ok(Self::json(dir.join(app_name).join("gpui-query-cache.json"))) } /// The on-disk path this persister writes to. @@ -129,8 +119,6 @@ impl FilePersister { .lock() .map_err(|_| PersistError::Permission("write lock poisoned".to_string()))?; - // Ensure the parent directory exists (best effort; a missing parent is - // an error we propagate). if let Some(parent) = self.path.parent() && !parent.as_os_str().is_empty() { @@ -139,11 +127,9 @@ impl FilePersister { let bytes: Vec<u8> = match self.format { PersistFormat::Json => serde_json::to_vec(snapshot)?, - // bincode cannot (de)serialize `serde_json::Value` directly (its - // Deserialize impl uses `deserialize_any`, which bincode's - // non-self-describing format can't drive). So for the bincode - // format we JSON-encode each entry's `value` to a String inside a - // bincode-safe adapter, then bincode the adapter. + // bincode's format is not self-describing, so it cannot drive + // serde_json::Value's deserialize_any; the adapter carries each + // value as a JSON String instead. PersistFormat::Bincode => { let adapter = BincodeSnapshot::from_snapshot(snapshot)?; bincode::serialize(&adapter).map_err(|e| { @@ -153,8 +139,8 @@ impl FilePersister { } }; - // Write to a sibling NamedTempFile, fsync, then rename over the target. - // `tempfile::NamedTempFile::persist` performs the atomic replace. + // Sibling temp file, fsync, rename over the target. The .tmp suffix + // keeps crash orphans identifiable for cleanup. let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); let mut tmp = tempfile::Builder::new() .prefix( @@ -163,28 +149,20 @@ impl FilePersister { .map(Path::new) .unwrap_or_else(|| Path::new("cache")), ) + .suffix(".tmp") .tempfile_in(parent)?; tmp.write_all(&bytes)?; - tmp.as_file().sync_all().map_err(fullfsync_err)?; - // Promote F_FULLFSYNC on macOS for true durability. + tmp.as_file().sync_all()?; #[cfg(target_os = "macos")] try_fullfsync(tmp.as_file()); - // tempfile's persist failure exposes the underlying io::Error via its - // `.error` field. We preserve that error's real ErrorKind/source chain - // (rather than flattening to a string) so callers can match on it. - // - // On Windows, an AV scanner or concurrent reader holding the destination - // surfaces `ERROR_ACCESS_DENIED` (5) from `MoveFileEx`; the std - // ErrorKind for that is `PermissionDenied`. Per the design doc, that - // case is *retryable* and is mapped to `PersistError::Permission` so a - // caller can back off and retry the save. Everything else is preserved - // as `PersistError::Io` with the original error (kind + source). + // A Windows AV scanner or concurrent reader holding the destination + // makes the replace fail with ERROR_ACCESS_DENIED; that case is + // retryable, so it maps to Permission instead of Io. tmp.persist(&self.path).map_err(|persist_err| { let io_err = persist_err.error; - let kind = io_err.kind(); - let is_access_denied = kind == std::io::ErrorKind::PermissionDenied + let denied = io_err.kind() == std::io::ErrorKind::PermissionDenied || is_windows_access_denied(io_err.raw_os_error()); - if is_access_denied { + if denied { PersistError::Permission(format!( "atomic persist of cache file was denied (retryable): {io_err}" )) @@ -193,25 +171,20 @@ impl FilePersister { } })?; - // On POSIX, fsync the parent directory so the rename is durable. #[cfg(unix)] fsync_parent(parent); Ok(()) } - /// Tolerantly read + deserialize the snapshot from disk. + /// Tolerantly read + deserialize the snapshot from disk. Lock-free: the + /// atomic rename means a concurrent save can only swap in another + /// complete file, never expose a partial one. fn read_tolerant(&self) -> Result<PersistSnapshot, PersistError> { - let _guard = self - .write_lock - .lock() - .map_err(|_| PersistError::Permission("write lock poisoned".to_string()))?; - let mut file = match File::open(&self.path) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Missing file → empty snapshot. - return Ok(empty_snapshot()); + return Ok(PersistSnapshot::new()); } Err(e) => return Err(e.into()), }; @@ -219,37 +192,21 @@ impl FilePersister { let mut buf = Vec::new(); file.read_to_end(&mut buf)?; - let snapshot: PersistSnapshot = match self.format { - PersistFormat::Json => match serde_json::from_slice(&buf) { - Ok(s) => s, - Err(e) => { - // Corrupt JSON → empty snapshot + log (tolerant load). - log_or_eprint(&format!( - "FilePersister: corrupt JSON cache at {}: {e}; treating as empty", - self.path.display() - )); - return Ok(empty_snapshot()); - } - }, - PersistFormat::Bincode => match bincode::deserialize::<BincodeSnapshot>(&buf) { - Ok(adapter) => match adapter.into_snapshot() { - Ok(s) => s, - Err(e) => { - log_or_eprint(&format!( - "FilePersister: corrupt bincode cache at {}: {e}; treating as empty", - self.path.display() - )); - return Ok(empty_snapshot()); - } - }, - Err(e) => { - log_or_eprint(&format!( - "FilePersister: corrupt bincode cache at {}: {e}; treating as empty", - self.path.display() - )); - return Ok(empty_snapshot()); - } - }, + let (label, parsed): (&str, Result<PersistSnapshot, String>) = match self.format { + PersistFormat::Json => { + ("JSON", serde_json::from_slice(&buf).map_err(|e| e.to_string())) + } + PersistFormat::Bincode => ("bincode", bincode_load(&buf)), + }; + let snapshot = match parsed { + Ok(s) => s, + Err(detail) => { + eprintln!( + "FilePersister: corrupt {label} cache at {}: {detail}; treating as empty", + self.path.display() + ); + return Ok(PersistSnapshot::new()); + } }; if snapshot.version != PERSIST_VERSION { @@ -272,22 +229,19 @@ impl Persister for FilePersister { } } -/// A [`Persister`] that persists nothing and loads an empty snapshot. -/// -/// Re-exported from `gpui_query::client` for the no-op case; this is the local -/// crate copy kept so consumers of `gpui-query-persist` have a one-stop import. +/// A [`Persister`] that persists nothing and loads an empty snapshot, for +/// tests or disabled modes. Re-exported from `gpui_query::client` so this +/// crate is a one-stop import. pub use gpui_query::client::NoopPersister; // ── helpers ───────────────────────────────────────────────────────────── /// Bincode-safe adapter for [`PersistSnapshot`]. /// -/// `serde_json::Value`'s `Deserialize` impl uses `deserialize_any`, which -/// bincode's non-self-describing format cannot drive. This adapter stores each -/// entry's `value` as a JSON `String` (the bytes round-trip through -/// `serde_json`), so bincode — which handles `String`, `u64`, `Option`, and -/// `HashMap` natively — can serialize the snapshot. The conversion is -/// lossless. +/// `serde_json::Value` deserializes via `deserialize_any`, which bincode's +/// non-self-describing format cannot drive. The adapter stores each entry's +/// `value` (and `meta`) as a JSON `String`, which bincode carries natively. +/// The conversion is lossless. #[derive(serde::Serialize, serde::Deserialize)] struct BincodeSnapshot { entries: HashMap<String, BincodeEntry>, @@ -313,10 +267,11 @@ impl BincodeSnapshot { value_json: serde_json::to_string(&e.value)?, cached_at: e.cached_at, cache_policy: e.cache_policy, - meta_json: match &e.meta { - Some(m) => Some(serde_json::to_string(m)?), - None => None, - }, + meta_json: e + .meta + .as_ref() + .map(serde_json::to_string) + .transpose()?, }, ); } @@ -330,10 +285,7 @@ impl BincodeSnapshot { let mut entries = HashMap::with_capacity(self.entries.len()); for (k, e) in self.entries { let value: serde_json::Value = serde_json::from_str(&e.value_json)?; - let meta = match e.meta_json { - Some(m) => Some(serde_json::from_str(&m)?), - None => None, - }; + let meta = e.meta_json.map(|m| serde_json::from_str(&m)).transpose()?; entries.insert( k, PersistedEntry { @@ -351,34 +303,16 @@ impl BincodeSnapshot { } } -fn empty_snapshot() -> PersistSnapshot { - PersistSnapshot { - entries: HashMap::new(), - version: PERSIST_VERSION, - } -} - -/// Log via `eprintln!` (no `log` dep). Tolerant-load warnings land here. -fn log_or_eprint(msg: &str) { - eprintln!("{msg}"); +/// Decode a bincode-format snapshot, flattening both the bincode step and the +/// inner JSON step into one String error (the tolerant path only logs it). +fn bincode_load(buf: &[u8]) -> Result<PersistSnapshot, String> { + let adapter: BincodeSnapshot = bincode::deserialize(buf).map_err(|e| e.to_string())?; + adapter.into_snapshot().map_err(|e| e.to_string()) } -/// Map a failed `sync_all` to a `PersistError`, capturing the platform detail. -/// -/// On macOS `sync_all` already issues `fsync`; `try_fullfsync` then attempts the -/// stronger `F_FULLFSYNC`. A failure here is propagated as an IO error. -fn fullfsync_err(e: std::io::Error) -> PersistError { - PersistError::Io(e) -} - -/// Returns `true` if the given raw OS error is Windows `ERROR_ACCESS_DENIED`. -/// -/// On Windows, antivirus scanners and concurrent readers can cause -/// `MoveFileEx` to fail with `ERROR_ACCESS_DENIED` (5) during the atomic -/// replace (rust-lang/rust#123985). Such failures are transient and retryable. -/// `std::io::ErrorKind::PermissionDenied` already maps this on Windows, but we -/// also check the raw code defensively (e.g. for errors constructed via -/// `from_raw_os_error` whose kind may not be normalized uniformly). +/// Also match raw Windows `ERROR_ACCESS_DENIED` (5); std maps it to +/// `PermissionDenied`, but errors built via `from_raw_os_error` on older +/// toolchains may not be normalized. #[cfg(windows)] const ERROR_ACCESS_DENIED: i32 = 5; fn is_windows_access_denied(raw: Option<i32>) -> bool { @@ -393,51 +327,43 @@ fn is_windows_access_denied(raw: Option<i32>) -> bool { } } -/// Issue `F_FULLFSYNC` on macOS for true durability (flushes the drive cache). -/// -/// `fsync` only flushes the kernel buffer cache; `F_FULLFSYNC` asks the drive to -/// flush its own write cache. Best-effort: a failure is logged but does not -/// fail the save (the prior `sync_all` already provides kernel-level durability). +/// macOS `F_FULLFSYNC`: unlike `fsync`, it also flushes the drive's write +/// cache. Best-effort; failure is logged and the save still succeeds on the +/// strength of the preceding `sync_all`. #[cfg(target_os = "macos")] fn try_fullfsync(file: &File) { - // F_FULLFSYNC = 0x00008027 (fcntl.h on Darwin). We declare the extern - // rather than taking a libc dependency. + // F_FULLFSYNC = 0x00008027 (fcntl.h on Darwin); extern declared here to + // avoid a libc dependency. unsafe extern "C" { fn fcntl(fd: std::os::fd::RawFd, cmd: std::ffi::c_int, ...) -> std::ffi::c_int; } const F_FULLFSYNC: std::ffi::c_int = 0x00008027; use std::os::fd::AsRawFd; - let fd = file.as_raw_fd(); - // SAFETY: `F_FULLFSYNC` takes no argument; the variadic tail is unused. - // The fd is a valid open file descriptor (the temp file we just wrote). - let rc = unsafe { fcntl(fd, F_FULLFSYNC) }; + // SAFETY: F_FULLFSYNC takes no argument (the variadic tail is unused) and + // the fd is the temp file we just wrote. + let rc = unsafe { fcntl(file.as_raw_fd(), F_FULLFSYNC) }; if rc != 0 { - log_or_eprint(&format!( - "FilePersister: F_FULLFSYNC failed (rc={rc}); relying on fsync" - )); + eprintln!("FilePersister: F_FULLFSYNC failed (rc={rc}); relying on fsync"); } } -/// fsync the parent directory so a rename is durable across power loss. +/// fsync the parent directory so the rename is durable across power loss. #[cfg(unix)] fn fsync_parent(parent: &Path) { use std::os::fd::AsRawFd; match OpenOptions::new().read(true).open(parent) { Ok(dir) => { - let fd = dir.as_raw_fd(); unsafe extern "C" { fn fsync(fd: std::ffi::c_int) -> std::ffi::c_int; } - // SAFETY: `fd` is a valid open directory file descriptor. - let rc = unsafe { fsync(fd) }; + // SAFETY: fd is a valid open directory file descriptor. + let rc = unsafe { fsync(dir.as_raw_fd()) }; if rc != 0 { - log_or_eprint("FilePersister: parent-dir fsync failed"); + eprintln!("FilePersister: parent-dir fsync failed"); } } Err(e) => { - log_or_eprint(&format!( - "FilePersister: could not open parent dir for fsync: {e}" - )); + eprintln!("FilePersister: could not open parent dir for fsync: {e}"); } } } diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index c2ec4c2..e9bc841 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -1,8 +1,6 @@ -//! Tests for `FilePersister`: round-trip, concurrent saves, corrupt-file -//! tolerance, version rejection, and the `NoopPersister` no-op. -//! -//! These are plain `#[test]`s; the persister's `save`/`load` futures do no real -//! async work, so `pollster::block_on` suffices (no tokio needed). +//! Round-trip, concurrency, and corrupt-file tests for `FilePersister`, plus +//! the `NoopPersister` no-op. Plain `#[test]`s: the futures do no real async +//! work, so `pollster::block_on` suffices. use std::collections::HashMap; @@ -35,12 +33,11 @@ fn file_persister_round_trip_json() { let path = dir.path().join("cache.json"); let p = FilePersister::json(&path); - // Load from missing file → empty snapshot. + // Missing file -> empty snapshot. let loaded = pollster::block_on(p.load()).expect("load missing"); assert!(loaded.entries.is_empty()); assert_eq!(loaded.version, PERSIST_VERSION); - // Save + reload round-trips. let snap = sample_snapshot(); pollster::block_on(p.save(&snap)).expect("save"); let reloaded = pollster::block_on(p.load()).expect("reload"); @@ -73,14 +70,13 @@ fn file_persister_corrupt_file_yields_empty_snapshot() { let p = FilePersister::json(&path); let loaded = pollster::block_on(p.load()).expect("tolerant load"); - assert!(loaded.entries.is_empty(), "corrupt cache → empty snapshot"); + assert!(loaded.entries.is_empty(), "corrupt cache -> empty snapshot"); } #[test] fn file_persister_version_mismatch_is_typed_error() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.json"); - // Hand-write a snapshot with a wrong version. let wrong = serde_json::json!({ "entries": {}, "version": 9999, @@ -104,8 +100,8 @@ fn file_persister_concurrent_saves_do_not_corrupt() { let path = dir.path().join("cache.json"); let p = std::sync::Arc::new(FilePersister::json(&path)); - // Many concurrent saves; the internal Mutex serializes them so the final - // on-disk file is always a complete, parseable snapshot. + // The internal Mutex serializes saves, so the final file is always one + // writer's complete snapshot. let mut handles = Vec::new(); for i in 0..16u64 { let p = std::sync::Arc::clone(&p); @@ -128,7 +124,6 @@ fn file_persister_concurrent_saves_do_not_corrupt() { } let final_ = pollster::block_on(p.load()).expect("final load"); - // The file is always parseable; the last writer's full snapshot survives. assert!( !final_.entries.is_empty(), "final snapshot should have at least one entry" @@ -161,10 +156,8 @@ fn file_persister_format_choice_round_trips() { #[test] fn file_persister_large_snapshot_round_trips() { - // A large snapshot with distinct keys and realistic-sized JSON values, - // round-tripped through BOTH formats. Covers the "large snapshot" row of - // docs/features.md and guards against truncation/size-sensitive regressions - // in the atomic-write and tolerant-load paths. + // Distinct keys with realistic JSON values guard against truncation and + // size-sensitive regressions in the atomic-write and tolerant-load paths. const N: usize = 10_000; let mut entries = HashMap::with_capacity(N); @@ -215,8 +208,7 @@ fn file_persister_large_snapshot_round_trips() { ); assert_eq!(reloaded.version, PERSIST_VERSION); - // Sample a well-known even-indexed entry (so active==true and policy is - // Ttl, matching the loop's parity rules) to lock in value-level integrity. + // Even index -> active + Ttl policy, per the loop's parity rules. let sample_key = "users::9000"; let entry = reloaded .entries @@ -243,10 +235,6 @@ fn file_persister_large_snapshot_round_trips() { #[test] fn file_persister_corrupt_bincode_yields_empty_snapshot() { - // Mirror of `file_persister_corrupt_file_yields_empty_snapshot`, but for - // the bincode deserialize-failure branch (src/lib.rs:208-214), which - // previously had zero test coverage: garbage bytes on disk must degrade to - // an empty snapshot without panicking or erroring. let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.bin"); std::fs::write( @@ -259,7 +247,73 @@ fn file_persister_corrupt_bincode_yields_empty_snapshot() { let loaded = pollster::block_on(p.load()).expect("tolerant load"); assert!( loaded.entries.is_empty(), - "corrupt bincode → empty snapshot" + "corrupt bincode -> empty snapshot" ); assert_eq!(loaded.version, PERSIST_VERSION); } + +#[test] +fn file_persister_save_creates_missing_parent_dirs() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("nested/deeper/cache.json"); + let p = FilePersister::json(&path); + + pollster::block_on(p.save(&sample_snapshot())).expect("save"); + let reloaded = pollster::block_on(p.load()).expect("load"); + assert_eq!(reloaded.entries.len(), 1); +} + +#[test] +fn file_persister_save_leaves_no_temp_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.json"); + let p = FilePersister::json(&path); + + pollster::block_on(p.save(&sample_snapshot())).expect("save"); + + let mut names: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read_dir") + .map(|e| e.expect("dir entry").file_name()) + .collect(); + names.sort(); + assert_eq!(names, vec!["cache.json"], "only the target file remains"); +} + +#[cfg(unix)] +#[test] +fn file_persister_cache_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.json"); + let p = FilePersister::json(&path); + + pollster::block_on(p.save(&sample_snapshot())).expect("save"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "query cache holds app-private data and must not be group/other-readable" + ); +} + +#[test] +fn file_persister_second_instance_overwrite_stays_parseable() { + // Separate instances share no lock; the atomic rename still guarantees + // the file is always one writer's complete snapshot. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.json"); + let a = FilePersister::json(&path); + let b = FilePersister::json(&path); + + pollster::block_on(a.save(&sample_snapshot())).expect("save a"); + pollster::block_on(b.save(&PersistSnapshot::new())).expect("save b"); + + let loaded = pollster::block_on(b.load()).expect("load"); + assert!(loaded.entries.is_empty(), "last writer wins whole-file"); + assert_eq!(loaded.version, PERSIST_VERSION); +} From ba4a3431cf7f145aabfb64517b720dd725babc8c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 15:19:32 +0200 Subject: [PATCH 027/111] fix: lock multi-segment hydrate rebuild and correct stale docs New regression test test_hydrate_rebuilds_multi_segment_keys: a stored "users::42::posts" entry primes under ["users","42","posts"], pinned via both Prefix and Exact filters (both fail on the pre-split single-segment rebuild). hydrate doc now discloses the lossy split - a single-segment key containing "::" hydrates under a different key; escaping the separator stays deferred with the PERSIST_VERSION bump. Fix one-char doc typo in select.rs (Option<Arc<T> missing its closing ">"). Correct the gpui-query-http cache comments to the real invariant (scoped blocks, never held across an .await; drop the false deadlock-avoidance rationale). Delete two gpui-query-persist inline comments that restated their docs.rs surface. Gates: 834/0 tests, clippy -D warnings clean, doc 0 warnings. --- crates/gpui-query-http/src/cache.rs | 8 +- crates/gpui-query-persist/src/lib.rs | 6 -- crates/gpui-query/src/client/persist.rs | 4 +- crates/gpui-query/src/core/select.rs | 2 +- .../client_operations/persist_with_hydrate.rs | 87 +++++++++++++++++++ 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 69d13e1..52c6fef 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -5,9 +5,9 @@ //! revalidate with `If-None-Match` / `If-Modified-Since`, and a `304` //! re-serves the cached body. //! -//! Concurrency: two [`std::sync::Mutex`]es (meta, bodies), always locked in -//! that order, never held across an `.await`. The cache is `Send + Sync` and -//! runtime-agnostic. +//! Concurrency: two [`std::sync::Mutex`]es (meta, bodies), each taken in a +//! short scoped block, never held across an `.await`. The cache is +//! `Send + Sync` and runtime-agnostic. use std::collections::HashMap; use std::sync::Mutex; @@ -161,7 +161,7 @@ impl<B: HttpBackend> HttpCache<B> { stale_for: stale_for_from_policy(policy), }; - // Lock order is meta -> bodies everywhere. + // Scoped blocks: neither guard is held while taking the other. { let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; guard.insert(url.to_string(), meta.clone()); diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 2c9d9a5..cbdbb9e 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -127,9 +127,6 @@ impl FilePersister { let bytes: Vec<u8> = match self.format { PersistFormat::Json => serde_json::to_vec(snapshot)?, - // bincode's format is not self-describing, so it cannot drive - // serde_json::Value's deserialize_any; the adapter carries each - // value as a JSON String instead. PersistFormat::Bincode => { let adapter = BincodeSnapshot::from_snapshot(snapshot)?; bincode::serialize(&adapter).map_err(|e| { @@ -155,9 +152,6 @@ impl FilePersister { tmp.as_file().sync_all()?; #[cfg(target_os = "macos")] try_fullfsync(tmp.as_file()); - // A Windows AV scanner or concurrent reader holding the destination - // makes the replace fail with ERROR_ACCESS_DENIED; that case is - // retryable, so it maps to Permission instead of Io. tmp.persist(&self.path).map_err(|persist_err| { let io_err = persist_err.error; let denied = io_err.kind() == std::io::ErrorKind::PermissionDenied diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 315866a..f839fff 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -475,7 +475,9 @@ impl Persister for NoopPersister { /// each one that decodes primes the value via `set_query_data`. Entries no /// deserializer accepts are skipped. Stored keys are `to_path()` strings; /// they are split back into segments so `Exact`/`Prefix` filters match the -/// live multi-segment key shapes. +/// live multi-segment key shapes. The split is lossy: a single-segment key +/// containing `"::"` hydrates as multiple segments (escaping the separator +/// needs a `PERSIST_VERSION` bump). /// /// Returns the loaded snapshot (post-filter) so callers can inspect entries /// or prime types with no registered deserializer themselves. Errors from diff --git a/crates/gpui-query/src/core/select.rs b/crates/gpui-query/src/core/select.rs index 00e14af..72e329b 100644 --- a/crates/gpui-query/src/core/select.rs +++ b/crates/gpui-query/src/core/select.rs @@ -137,7 +137,7 @@ impl<T, U> SelectTransform<T, U> { /// /// # Storage /// -/// Source data is held as `Option<Arc<T>` so cloning a `MappedQueryResource` +/// Source data is held as `Option<Arc<T>>` so cloning a `MappedQueryResource` /// (e.g. for derived views) is a cheap `Arc::clone` rather than a full copy /// of `T`. `Arc<T>` is `Send + Sync` exactly when `T` is, so the existing /// bounds are preserved. diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index b22a110..b8af705 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -273,6 +273,93 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { let _ = harness; } +// A stored multi-segment path must hydrate under the live segmented key: +// before the split fix the path string primed as one segment, so Exact and +// Prefix filters never matched it. + +#[gpui::test] +fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + + let mut snap = PersistSnapshot { + entries: Default::default(), + version: crate::client::PERSIST_VERSION, + }; + snap.entries.insert( + "users::42::posts".to_string(), + PersistedEntry { + value: serde_json::json!("post-data"), + cached_at: crate::client::current_time_ms(), + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + *persister.load_value.lock().unwrap() = Some(snap); + + // Retain the multi-segment entity so hydrate's set_query_data reuses it + // instead of creating one that dies immediately (WeakEntity). + struct H { + _entity: Entity<QueryResource<String, QueryError>>, + } + let harness = cx.new(|cx| { + cx.update_global::<QueryClient, _>(|client, _cx| { + client + .register_deserializer::<String, QueryError>(|v| v.as_str().map(|s| s.to_string())); + }); + let entity = cx.update_global::<QueryClient, _>(|client, cx| { + client.resource::<String, QueryError>(QueryKey::from(["users", "42", "posts"]), cx) + }); + H { _entity: entity } + }); + + let key = QueryKey::from(["users", "42", "posts"]); + let prefix = PersistFilter::Prefix(QueryKey::from(["users"])); + let outcome = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + block_on_ready(hydrate(client, &persister, &prefix, DAY, cx)) + }) + }); + assert!(outcome.is_ok(), "hydrate should succeed: {:?}", outcome.err()); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let data = client.get_query_data::<String, QueryError>(&key, cx); + assert_eq!( + data, + Some("post-data".to_string()), + "Prefix must match the reconstructed segments" + ); + }); + }); + + // Overwrite with a sentinel so the Exact pass has to re-prime to pass. + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key.clone(), "sentinel".to_string(), cx); + }); + }); + let exact = PersistFilter::Exact(key.clone()); + let outcome = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + block_on_ready(hydrate(client, &persister, &exact, DAY, cx)) + }) + }); + assert!(outcome.is_ok(), "hydrate should succeed: {:?}", outcome.err()); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let data = client.get_query_data::<String, QueryError>(&key, cx); + assert_eq!( + data, + Some("post-data".to_string()), + "Exact must match the reconstructed segments" + ); + }); + }); + let _ = harness; +} + #[gpui::test] fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { setup_query_client(cx); From aada8fbd3cf56e944d20c6aced5c960798436b9d Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 15:37:11 +0200 Subject: [PATCH 028/111] chore: humanize readmes and add hard comment rules Rewrite all four READMEs in the repo's plain house voice: no em or en dashes, no curly quotes, no padded triads or summary boilerplate. Code blocks and version literals untouched. Accuracy fixes ride along: the http README now documents the malformed-Cache-Control degrade (fetch serves the body uncacheable, InvalidPolicy only surfaces for direct policy callers) plus first-occurrence-wins duplicates and digit-overflow saturation; the persist README's stale 'reads take the same lock briefly' line now says loads skip the mutex and the atomic rename keeps them complete. AGENTS.md gains a Hard rules section: inline comments 1-2 lines of non-obvious constraints only, no narration or change history, no restating the code; doc comments 1-3 lines; all prose humanized before merge; trimming beats adding. CLAUDE.md's unique skills install note is merged into Conventions and the file becomes a symlink to AGENTS.md. --- AGENTS.md | 9 +++++++++ CLAUDE.md | 9 +-------- README.md | 24 ++++++++++++------------ crates/gpui-query-http/README.md | 20 ++++++++++---------- crates/gpui-query-persist/README.md | 6 +++--- crates/gpui-query/README.md | 5 ++--- 6 files changed, 37 insertions(+), 36 deletions(-) mode change 100644 => 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 5452e46..6b5e7ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,14 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy - Search is one combined Pagefind index. - Directory output (`build.format: "directory"`) with `trailingSlash: "ignore"` so both `/docs` and `/docs/` serve; the `Head.astro` override normalizes canonical/OG URLs. +## Hard rules + +Binding on every change; violations are review-blocking. + +- Comments: inline comments are 1-2 lines max and only for non-obvious constraints. No narration, no change-history notes ("T5:", "fixed:"), no restating what the code already says. Same for test comments. Doc comments stay at 1-3 lines plus `# Examples` blocks that carry doctests. Bloated comment blocks are review-blocking. +- Copywriting: all prose (comments, READMEs, docs) must read human-written. Apply the humanizer and humanize-writing skills to prose changes before merging: no em-dash cadence, no rule-of-three padding, no "seamless/robust/leverage" vocabulary. +- Trimming beats adding. Dead code, duplicated plumbing, and comments that restate their docs get deleted, not maintained. + ## Conventions - Tests are inline under `crates/gpui-query/src/tests/`, feature-gated per module. Naming: `core_*` = layer, `integration_*` = cross-layer, `property_tests` = proptest. @@ -88,6 +96,7 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy - Author identity is `authors = ["hmziqrs"]` in every published crate (no email). - Commits are conventional lower-case: `chore:`, `fix:`, `feat:`, `ci:`. - docs.rs: `all-features = true`, `--cfg docsrs`; `lib.rs` gates `#![cfg_attr(docsrs, feature(doc_cfg))]`. +- Two user-facing Claude Code skill packs live in `skills/`: `gpui-query` (hooks, in-memory caching, retry) and `gpui-query-extensions` (HTTP cache headers, disk persistence). They target apps that depend on gpui-query, not work on this repo. Install: `cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/`. ## Gotchas diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f5acd1f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,8 +0,0 @@ -@AGENTS.md - -## Claude Code notes - -- AGENTS.md above is the source of truth for layout, commands, features, and release flow. Keep this file thin; edit AGENTS.md when the facts change. -- Two installable skills ship at `skills/gpui-query/` (essentials: hooks, in-memory caching, retry, invalidation, observers) and `skills/gpui-query-extensions/` (HTTP cache-control + disk persistence). They're user-facing — for apps that *depend on* gpui-query. Install globally: `cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/`. For crate-internal work (layout, release flow), use AGENTS.md. -- Run `just test` (= `cargo test --all-features`) before claiming any Rust change is done. Bare `cargo test` uses default features (`client`) and skips the `hook`/`persist` test modules. -- Never edit `web/dist/**` or the generated `llms.txt` / `llms-full.txt` — they are produced by `just web-build`. Rebuild the site to refresh them. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 8253dbb..cbf667d 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by [TanStack Query](https://tanstack.com/query). -Fetch, cache, and synchronize async data in GPUI applications without manual lifecycle management. Built for the framework that powers the [Zed editor](https://zed.dev). +Fetch, cache, and synchronize async data in GPUI applications without hand-rolling the lifecycle. GPUI is the framework behind the [Zed editor](https://zed.dev). [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ## what it is -GPUI renders synchronously on the main thread. That makes async data fetching awkward: you need to track loading states, handle errors, cache responses, deduplicate concurrent requests, and retry on failure. gpui-query handles all of it. +GPUI renders synchronously on the main thread. That makes async data awkward: you have to track loading states, handle errors, cache responses, deduplicate concurrent requests, and retry on failure. gpui-query handles all of it. -You write a fetcher function. The library manages caching, retry, deduplication, stale-while-revalidate, garbage collection, and cooperative cancellation. It works with GPUI's `Entity` and `ViewContext` system, not against it. +You write a fetcher function. The library manages caching, retry, deduplication, stale-while-revalidate, garbage collection, and cooperative cancellation, on top of GPUI's `Entity` and `ViewContext` system. -The API mirrors what TanStack Query popularized in the JavaScript ecosystem: `use_query`, `use_mutation`, and `use_infinite_query` hooks that return `Entity` handles you read from in your view's `render` method. +The API mirrors TanStack Query: `use_query`, `use_mutation`, and `use_infinite_query` hooks that return `Entity` handles you read from in your view's `render` method. ## install @@ -37,11 +37,11 @@ To use only the core state machine with no GPUI dependency: gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` -The `core` layer also builds for `wasm32-unknown-unknown` — the crate handles the wasm-specific setup internally (ahash switches to compile-time RNG on wasm targets), so no consumer configuration is needed. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. +The `core` layer also builds for `wasm32-unknown-unknown`. The wasm-specific setup (ahash switches to compile-time RNG on wasm targets) is handled internally, so there is nothing to configure. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. ## quick start -Set up the `QueryClient` as a GPUI global during app initialization: +Set up the `QueryClient` as a GPUI global when your app starts: ```rust use gpui::App; @@ -219,7 +219,7 @@ let policy = RetryPolicy::new(5) // max retries .with_max_delay(60_000); // cap at 60s ``` -Retry delay is `base * 2^attempt`, capped at `max_delay`. The fetcher's `QuerySignal` is checked between attempts so cancelled queries stop retrying immediately. +Retry delay is `base * 2^attempt`, capped at `max_delay`. The fetcher's `QuerySignal` is checked between attempts, so cancelled queries stop retrying immediately. ## persistence @@ -251,7 +251,7 @@ Only `Success` entries with a registered serializer are persisted; the typed rou ## other things worth knowing -`QueryObserver` and `MutationObserver` wrap entities and only call `cx.notify()` when the status changes. This avoids unnecessary re-renders. +`QueryObserver` and `MutationObserver` wrap entities and only call `cx.notify()` when the status changes, which keeps unrelated views from re-rendering. `QueryError::sanitized()` redacts connection strings, bearer tokens, file paths, emails, and hex keys from error messages. Useful for logging without leaking secrets. @@ -265,10 +265,10 @@ Garbage collection runs on idle resources older than `gc_time_ms` (default: 5 mi ## claude code skills -Two installable [Claude Code](https://claude.com/claude-code) skills ship in this repo under [`skills/`](./skills) — knowledge packs that teach an AI assistant the real gpui-query API (signatures, defaults, lifecycle, gotchas) so it writes correct hooks, caching, retry, and persistence code instead of guessing. +Two [Claude Code](https://claude.com/claude-code) skills ship in this repo under [`skills/`](./skills). They teach an AI assistant the real gpui-query API (signatures, defaults, lifecycle, gotchas) so it writes correct hooks, caching, retry, and persistence code. -- **`gpui-query`** — the essentials: `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`, in-memory `CachePolicy`, `RetryPolicy`, `QueryKey` filters, `QueryClient` bulk ops, observers, GC. -- **`gpui-query-extensions`** — the satellites: HTTP `Cache-Control` → `CachePolicy` + `HttpCache` (`gpui-query-http`), and durable disk persistence with `FilePersister` + the `persist` feature (`gpui-query-persist`). +- `gpui-query`: the essentials. `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`, in-memory `CachePolicy`, `RetryPolicy`, `QueryKey` filters, `QueryClient` bulk ops, observers, GC. +- `gpui-query-extensions`: the satellites. HTTP `Cache-Control` → `CachePolicy` + `HttpCache` (`gpui-query-http`), and durable disk persistence with `FilePersister` + the `persist` feature (`gpui-query-persist`). Install both globally (available in every project), from a clone of the repo: @@ -284,7 +284,7 @@ curl -fsSL https://raw.githubusercontent.com/freeoxide/gpui-query/master/skills/ -o ~/.claude/skills/gpui-query/SKILL.md ``` -Once installed, the skills activate automatically when you work on a GPUI app that depends on gpui-query — no manual invocation needed. See the [Claude Code skills guide](https://gpui-query.freeoxide.com/docs/guides/claude-skills) for details. +Once installed, the skills activate automatically whenever you work on a GPUI app that depends on gpui-query. See the [Claude Code skills guide](https://gpui-query.freeoxide.com/docs/guides/claude-skills) for details. ## links diff --git a/crates/gpui-query-http/README.md b/crates/gpui-query-http/README.md index 827419c..1f55271 100644 --- a/crates/gpui-query-http/README.md +++ b/crates/gpui-query-http/README.md @@ -24,17 +24,17 @@ The usage examples also reference `gpui-query` (for `core::{CachePolicy, Fetched ## What it does -- **Header → policy ("server wins").** `cache_policy_from_headers` reads RFC 9111 `Cache-Control` directives and returns the matching `gpui_query::core::CachePolicy`. -- **In-memory HTTP cache.** `HttpCache<B>` wraps any `HttpBackend`: fresh entries short-circuit the network entirely, stale entries revalidate with `If-None-Match` / `If-Modified-Since`, and a `304 Not Modified` refreshes the entry without transferring a body. -- **Pluggable backend.** `HttpBackend` abstracts a single conditional `GET`. The crate ships `ReqwestBackend` behind the `reqwest` feature; any other client can implement the trait and feed `HttpCache::new`. -- **Serializable metadata.** `CacheMeta` (ETag, `Last-Modified`, `stored_at`, `fresh_for`, `stale_for`) is serde-serializable, so it round-trips through a persistence layer for cheap `304` refetches on cold start. -- **Typed errors.** `ParseError` (`InvalidMaxAge`, `InvalidStaleWhileRevalidate`) for malformed directives; `HttpError` for backend failures, bad policies, poisoned mutexes, and spurious `304`s. +- Header → policy ("server wins"): `cache_policy_from_headers` reads RFC 9111 `Cache-Control` directives and returns the matching `gpui_query::core::CachePolicy`. +- In-memory HTTP cache: `HttpCache<B>` wraps any `HttpBackend`. Fresh entries skip the network entirely, stale entries revalidate with `If-None-Match` / `If-Modified-Since`, and a `304 Not Modified` refreshes the entry without transferring a body. +- Pluggable backend: `HttpBackend` abstracts a single conditional `GET`. The crate ships `ReqwestBackend` behind the `reqwest` feature; any other client can implement the trait and feed `HttpCache::new`. +- Serializable metadata: `CacheMeta` (ETag, `Last-Modified`, `stored_at`, `fresh_for`, `stale_for`) is serde-serializable, so it round-trips through a persistence layer for cheap `304` refetches on cold start. +- Typed errors: `ParseError` (`InvalidMaxAge`, `InvalidStaleWhileRevalidate`) for malformed directives, `HttpError` for backend failures, poisoned mutexes, and spurious `304`s. A malformed `Cache-Control` never fails a fetch: `HttpCache::fetch` serves the body uncacheable and stores nothing. `ParseError` (wrapped as `HttpError::InvalidPolicy`) surfaces only for direct callers of `cache_policy_from_headers`. Parsing rules (priority order): -1. `no-store` or `no-cache` → `CachePolicy::NoCache` (short-circuits immediately). -2. `max-age=N` (seconds) → `CachePolicy::Ttl { ttl_ms: N * 1000 }`; add `stale-while-revalidate=M` → `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }`. -3. Otherwise → `CachePolicy::NoCache`. +1. `no-store` or `no-cache` anywhere → `CachePolicy::NoCache`, regardless of position or malformed directives elsewhere. +2. `max-age=N` (seconds) → `CachePolicy::Ttl { ttl_ms: N * 1000 }`; add `stale-while-revalidate=M` → `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }`. Duplicated directives keep their first occurrence (RFC 9111 §4.2.1), and a delta-seconds too large for `u64` saturates instead of erroring (RFC 9111 §1.2.2). +3. Otherwise → `CachePolicy::NoCache`; malformed values surface as `ParseError`. `s-maxage` takes precedence over `max-age` when both are set. Directive names are matched case-insensitively and values may be quoted (`max-age="600"`). @@ -69,9 +69,9 @@ let (body, policy, meta) = cache.fetch("https://example.test/data").await?; ## WebAssembly -The crate compiles for `wasm32-unknown-unknown` with the default feature set and with the `reqwest` feature — the same feature flags work on both targets. On wasm, `ReqwestBackend` runs on reqwest's browser-fetch backend and TLS is the browser's job; the `rustls-tls` feature that the `reqwest` feature enables for native use is inert there, so it is harmless to leave on. +The crate compiles for `wasm32-unknown-unknown` with the default feature set and with the `reqwest` feature; the same feature flags work on both targets. On wasm, `ReqwestBackend` runs on reqwest's browser-fetch backend and TLS is the browser's job. The `rustls-tls` feature that `reqwest` enables for native use is inert there, so it is harmless to leave on. -For custom backends, `HttpBackend::fetch` bounds its returned future with `MaybeSend` (exported at the crate root) instead of `Send`. On native targets `MaybeSend` is exactly `Send`, so an existing impl written with `+ Send` keeps compiling unchanged — no migration needed. Use `+ MaybeSend` only when a backend must compile on both native and wasm targets: on `wasm32`, browser-fetch futures (reqwest's included) are inherently `!Send` because they hold JS values, so a `+ Send` bound would not compile there. +For custom backends, `HttpBackend::fetch` bounds its returned future with `MaybeSend` (exported at the crate root) instead of `Send`. On native targets `MaybeSend` is exactly `Send`, so an existing impl written with `+ Send` keeps compiling unchanged. Use `+ MaybeSend` only when a backend must compile on both native and wasm: browser-fetch futures on `wasm32` (reqwest's included) hold JS values and are therefore `!Send`, so a `+ Send` bound would not compile there. ```rust use std::future::Future; diff --git a/crates/gpui-query-persist/README.md b/crates/gpui-query-persist/README.md index 9ac31b1..255bc1b 100644 --- a/crates/gpui-query-persist/README.md +++ b/crates/gpui-query-persist/README.md @@ -19,9 +19,9 @@ The crate pulls in [gpui-query](https://crates.io/crates/gpui-query) with the `p ## What it does -- **Atomic write.** Each save serializes the snapshot to a sibling `NamedTempFile` (via [`tempfile`]), fsyncs it (`F_FULLFSYNC` on macOS, plain `fsync` elsewhere), then renames it over the target. On POSIX the parent directory is fsynced after the replace so the rename survives power loss. -- **Tolerant load.** A missing file yields an empty snapshot. A corrupt or unparseable file is logged and treated as empty (no panic). A version mismatch returns `PersistError::VersionMismatch`, so callers can distinguish "corrupt" from "wrong format". -- **No `tokio`.** Writes are serialized through a `std::sync::Mutex` and run on GPUI's `background_executor`; reads take the same lock briefly. The persister performs synchronous `std::fs` I/O, which is what the background executor is designed for. +- Atomic write: each save serializes the snapshot to a sibling `NamedTempFile` (via [`tempfile`]), fsyncs it (`F_FULLFSYNC` on macOS, plain `fsync` elsewhere), then renames it over the target. On POSIX the parent directory is fsynced after the replace so the rename survives power loss. +- Tolerant load: a missing file yields an empty snapshot. A corrupt or unparseable file is logged and treated as empty (no panic). A version mismatch returns `PersistError::VersionMismatch`, so callers can distinguish "corrupt" from "wrong format". +- Locking: saves on one persister are serialized through a `std::sync::Mutex`; loads skip the lock entirely. The rename is atomic, so a load concurrent with a save always sees a complete file, old or new. The persister performs synchronous `std::fs` I/O, which is what GPUI's background executor is designed for. ## Quick start diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 344133c..6852c01 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -27,7 +27,7 @@ If you only want the core state machine without pulling in GPUI: gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` -The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only — they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. +The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only because they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. ## Quick start @@ -97,7 +97,7 @@ The crate is split into four layers, each behind a feature flag: - Mutation callbacks for success, error, and settled states. - Infinite queries for paginated data. - Error sanitization that strips connection strings, tokens, paths, emails, and hex keys from messages. -- Async persistence through the `Persister` trait (`persist` feature); a disk adapter ships in the [`gpui-query-persist`](https://crates.io/crates/gpui-query-persist) crate, and HTTP cache-header support in [`gpui-query-http`](https://crates.io/crates/gpui-query-http). +- Async persistence through the `Persister` trait (`persist` feature). A disk adapter ships in the [`gpui-query-persist`](https://crates.io/crates/gpui-query-persist) crate, and HTTP cache-header support in [`gpui-query-http`](https://crates.io/crates/gpui-query-http). ## Links @@ -118,4 +118,3 @@ The crate is split into four layers, each behind a feature flag: ## License MIT. See the [LICENSE](../../LICENSE) file for details. - From 792c3e7cc603961732c7d5e305a44b8d9d56b9c0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 16:05:03 +0200 Subject: [PATCH 029/111] refactor: drop request-id rehash and tighten bucket comments - link the sanitized-length doc to SANITIZE_MAX_LEN instead of a hard-coded 512, silencing the private-doc-link lint with an expect - collapse the near-duplicate bucket-sequencer comments in the infinite-query helpers to one tight note each - mint request ids inside the bucket lookup via QueryClient::resource_with_request_id so prepare_fetch_query and prepare_prefetch_query stop hashing the key twice --- crates/gpui-query/src/client/bucket/ops.rs | 13 +++++ crates/gpui-query/src/client/bucket/shared.rs | 42 +++++++++++++--- crates/gpui-query/src/client/lifecycle.rs | 50 ++++++++----------- crates/gpui-query/src/client/mod.rs | 22 ++++++++ crates/gpui-query/src/core/error/types.rs | 7 ++- .../hook/use_infinite_query/fetch_helpers.rs | 6 +-- .../src/hook/use_infinite_query/hook.rs | 6 +-- .../client_operations/persist_with_hydrate.rs | 5 +- 8 files changed, 104 insertions(+), 47 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/ops.rs b/crates/gpui-query/src/client/bucket/ops.rs index 19c7f53..13cddec 100644 --- a/crates/gpui-query/src/client/bucket/ops.rs +++ b/crates/gpui-query/src/client/bucket/ops.rs @@ -30,6 +30,19 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> QueryBu self.inner.get_or_create(key, cache_policy, request_policy, cx) } + /// Get-or-create that also mints the next `RequestId` from the entry's + /// sequencer in the same lookup. + pub(crate) fn get_or_create_with_request_id( + &mut self, + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + cx: &mut App, + ) -> (Entity<QueryResource<T, E>>, crate::core::RequestId) { + self.inner + .get_or_create_with_request_id(key, cache_policy, request_policy, cx) + } + pub(crate) fn get(&self, key: &QueryKey) -> Option<Entity<QueryResource<T, E>>> { self.inner.get(key) } diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index a9d5142..8d9354e 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -11,7 +11,7 @@ use gpui::{App, AppContext as _, Entity}; use crate::client::devtools::QueryDiagnostic; use crate::core::{ CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, QueryResource, QueryStatus, - RequestPolicy, + RequestId, RequestPolicy, }; use super::types::{BucketEntry, DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; @@ -133,17 +133,42 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } /// Get an existing entity or create a new one. - /// + pub(crate) fn get_or_create( + &mut self, + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + cx: &mut App, + ) -> Entity<R> { + self.get_or_create_impl(key, cache_policy, request_policy, cx, false) + .0 + } + + /// Get-or-create that also mints the next `RequestId` from the entry's + /// sequencer in the same lookup. + pub(crate) fn get_or_create_with_request_id( + &mut self, + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + cx: &mut App, + ) -> (Entity<R>, RequestId) { + let (entity, request_id) = + self.get_or_create_impl(key, cache_policy, request_policy, cx, true); + (entity, request_id.expect("impl inserts the entry before returning")) + } + /// Live entries get their policies refreshed in place when they differ. /// A dead weak reference is overwritten in place (length unchanged, no /// eviction); a vacant insert at capacity evicts the oldest entry first. - pub(crate) fn get_or_create( + fn get_or_create_impl( &mut self, key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy, cx: &mut App, - ) -> Entity<R> { + mint_request_id: bool, + ) -> (Entity<R>, Option<RequestId>) { if let Some(entry) = self.entries.get_mut(&key) { if let Some(entity) = entry.entity.upgrade() { let (needs_update, last_updated, loading) = @@ -164,23 +189,26 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { resource.set_resource_request_policy(request_policy); }); } - return entity; + let request_id = mint_request_id.then(|| entry.sequencer.next_request()); + return (entity, request_id); } } else if self.entries.len() >= self.max_entries { self.evict_oldest(cx); } + let mut sequencer = crate::core::RequestSequencer::new(); + let request_id = mint_request_id.then(|| sequencer.next_request()); let entity = cx.new(|_| R::new_resource(key.clone(), cache_policy, request_policy)); self.entries.insert( key, BucketEntry { entity: entity.downgrade(), - sequencer: crate::core::RequestSequencer::new(), + sequencer, last_updated_ms: None, loading: false, }, ); - entity + (entity, request_id) } /// Evict the least-recently-updated entry to make room for a new one. diff --git a/crates/gpui-query/src/client/lifecycle.rs b/crates/gpui-query/src/client/lifecycle.rs index 2baba99..f7f7f8e 100644 --- a/crates/gpui-query/src/client/lifecycle.rs +++ b/crates/gpui-query/src/client/lifecycle.rs @@ -252,20 +252,21 @@ impl QueryClient { key: impl Into<QueryKey>, cx: &mut App, ) -> Option<PreparedFetch<T, E>> { - let key = key.into(); - let entity = self.resource::<T, E>(key.clone(), cx); let now_ms = current_time_ms(); - let request_id = self.next_request_id_for_key::<T, E>(&key); + let (entity, request_id) = self.resource_with_request_id::<T, E>( + key, + self.default_cache_policy, + self.default_request_policy, + cx, + ); // Begin the request and pull the signal from the same update. let (request_id, signal) = entity.update(cx, |resource, _| { - if let Some(rid) = request_id { - let _ = resource.begin_request_with_id( - Some(rid), - now_ms, - crate::core::QueryFetchMode::Force, - ); - } + let _ = resource.begin_request_with_id( + Some(request_id), + now_ms, + crate::core::QueryFetchMode::Force, + ); let rid = resource.active_request_id()?; let signal = resource.signal().cloned()?; Some((rid, signal)) @@ -300,29 +301,22 @@ impl QueryClient { request_policy: RequestPolicy, cx: &mut App, ) -> Option<PreparedFetch<T, E>> { - let key = key.into(); - let entity = - self.resource_with_policies::<T, E>(key.clone(), cache_policy, request_policy, cx); let now_ms = current_time_ms(); - let request_id = self.next_request_id_for_key::<T, E>(&key); + let (entity, request_id) = + self.resource_with_request_id::<T, E>(key, cache_policy, request_policy, cx); // Normal mode respects the cache policy; only Started and // StaleCacheHit mean a fetch is actually wanted. let (request_id, signal) = entity.update(cx, |resource, _| { - let started = match request_id { - Some(rid) => { - matches!( - resource.begin_request_with_id( - Some(rid), - now_ms, - crate::core::QueryFetchMode::Normal - ), - crate::core::QueryBeginResult::Started { .. } - | crate::core::QueryBeginResult::StaleCacheHit { .. } - ) - } - None => false, - }; + let started = matches!( + resource.begin_request_with_id( + Some(request_id), + now_ms, + crate::core::QueryFetchMode::Normal + ), + crate::core::QueryBeginResult::Started { .. } + | crate::core::QueryBeginResult::StaleCacheHit { .. } + ); if !started { return None; } diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index d7178da..0cb1fbf 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -215,6 +215,28 @@ impl QueryClient { entity } + /// Private [`resource_with_policies`](Self::resource_with_policies) + /// variant that mints the request id in the same bucket lookup, skipping + /// the second TypeId+key hash of a follow-up `next_request_id_for_key`. + fn resource_with_request_id<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( + &mut self, + key: impl Into<QueryKey>, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + cx: &mut App, + ) -> (Entity<QueryResource<T, E>>, crate::core::RequestId) { + let type_id = TypeId::of::<(T, E)>(); + let bucket = self + .buckets + .entry(type_id) + .or_insert_with(|| Box::new(QueryBucket::<T, E>::new())); + let typed = Self::bucket_or_recreate::<T, E>(bucket); + let (entity, request_id) = + typed.get_or_create_with_request_id(key.into(), cache_policy, request_policy, cx); + self.maybe_opportunistic_gc(cx); + (entity, request_id) + } + /// Get all query entities of a given type pair. pub fn all_queries<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, diff --git a/crates/gpui-query/src/core/error/types.rs b/crates/gpui-query/src/core/error/types.rs index e2c3a51..89e20ea 100644 --- a/crates/gpui-query/src/core/error/types.rs +++ b/crates/gpui-query/src/core/error/types.rs @@ -113,7 +113,12 @@ impl QueryError { /// - Email-like strings /// - Long hex sequences (likely API keys) /// - /// Also truncates the message to `SANITIZE_MAX_LEN` (512) bytes. + /// Also truncates the message to + /// [`SANITIZE_MAX_LEN`](super::sanitize::SANITIZE_MAX_LEN) bytes. + #[expect( + rustdoc::private_intra_doc_links, + reason = "the const stays internal; the link renders under --document-private-items" + )] pub fn sanitized(&self) -> Self { let redacted = sanitize_message(&self.message); Self { diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs index 6346842..c2d6618 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs @@ -82,10 +82,8 @@ fn fetch_page_infinite<T, E, C, F, Fut>( { let weak = entity.downgrade(); - // Mint the RequestId from the bucket's persistent sequencer so ids stay - // monotonic; pass it into begin_fetch_*_with_id so the resource's - // active_request_id matches the bucket's counter. Falls back to None - // (transient sequencer) when no QueryClient is available. + // Bucket-sequenced RequestId so event-driven page fetches stay monotonic + // per key; without a QueryClient the resource mints a transient one. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { diff --git a/crates/gpui-query/src/hook/use_infinite_query/hook.rs b/crates/gpui-query/src/hook/use_infinite_query/hook.rs index 661f308..c2e8207 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/hook.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/hook.rs @@ -127,10 +127,8 @@ where // Start the initial fetch if idle if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { - // Mint the RequestId from the bucket's persistent sequencer so ids - // stay monotonic across the resource lifetime; pass it into - // begin_fetch_next_with_id so the resource's active_request_id matches - // the bucket's counter. + // The initial fetch also mints from the bucket's sequencer, so later + // fetch_next/previous_page ids continue the same sequence. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index b8af705..908cd22 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -273,9 +273,8 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { let _ = harness; } -// A stored multi-segment path must hydrate under the live segmented key: -// before the split fix the path string primed as one segment, so Exact and -// Prefix filters never matched it. +// A stored multi-segment path must hydrate as a segmented key; priming it +// as one flat segment would hide it from Exact/Prefix filters. #[gpui::test] fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { From f9165c3d22805435383bc62431f31f7246991179 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 16:16:05 +0200 Subject: [PATCH 030/111] chore: drop unused persist deps and cover bincode corrupt-inner arm --- crates/gpui-query-persist/Cargo.toml | 2 - crates/gpui-query-persist/src/lib.rs | 40 +++++++++++++++++++ .../tests/file_persister.rs | 4 +- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index 3409e74..d04f34e 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -23,10 +23,8 @@ tempfile = "3" bincode = "1" serde = { workspace = true } serde_json = { workspace = true } -thiserror = "2" [dev-dependencies] -gpui = { workspace = true, features = ["test-support"] } pollster = "0.4" [package.metadata.docs.rs] diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index cbdbb9e..3e6d038 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -361,3 +361,43 @@ fn fsync_parent(parent: &Path) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Valid bincode frame, garbage inside value_json. The frame round-trips + // through bincode, so the failure must come from into_snapshot's JSON + // step, not the bincode deserialize step. + #[test] + fn bincode_corrupt_inner_json_is_tolerated() { + let adapter = BincodeSnapshot { + entries: HashMap::from([( + "users::42".to_string(), + BincodeEntry { + value_json: "{ this is not valid json".to_string(), + cached_at: 1_700_000_000_000, + cache_policy: CachePolicy::NoCache, + meta_json: None, + }, + )]), + version: PERSIST_VERSION, + }; + let bytes = bincode::serialize(&adapter).expect("serialize adapter frame"); + assert!( + bincode_load(&bytes).is_err(), + "corrupt inner JSON must surface as a load error" + ); + + // Same bytes on disk go through the tolerant path: logged, empty. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.bin"); + std::fs::write(&path, &bytes).expect("write framed payload"); + let p = FilePersister::bincode(&path); + let loaded = pollster::block_on(p.load()).expect("tolerant load"); + assert!( + loaded.entries.is_empty(), + "corrupt inner JSON -> empty snapshot" + ); + } +} diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index e9bc841..29c7d05 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -303,8 +303,8 @@ fn file_persister_cache_file_is_owner_only() { #[test] fn file_persister_second_instance_overwrite_stays_parseable() { - // Separate instances share no lock; the atomic rename still guarantees - // the file is always one writer's complete snapshot. + // Two instances share no lock; the second save simply replaces the + // first whole-file. Sequential only, no concurrent writer here. let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.json"); let a = FilePersister::json(&path); From 5de7d736f0e9cca29e5773593d4c8e3e6abc0904 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 16:29:09 +0200 Subject: [PATCH 031/111] fix: tighten persist test comment and drop stale thiserror note --- crates/gpui-query-persist/README.md | 2 +- crates/gpui-query-persist/src/lib.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/gpui-query-persist/README.md b/crates/gpui-query-persist/README.md index 255bc1b..d91e59e 100644 --- a/crates/gpui-query-persist/README.md +++ b/crates/gpui-query-persist/README.md @@ -15,7 +15,7 @@ cargo add gpui-query-persist gpui-query-persist = "0.1.0" ``` -The crate pulls in [gpui-query](https://crates.io/crates/gpui-query) with the `persist`, `client`, and `hook` features already enabled, plus `dirs`, `tempfile`, `bincode`, `serde`, `serde_json`, and `thiserror`. +The crate pulls in [gpui-query](https://crates.io/crates/gpui-query) with the `persist`, `client`, and `hook` features already enabled, plus `dirs`, `tempfile`, `bincode`, `serde`, and `serde_json`. ## What it does diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 3e6d038..fa4101e 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -366,9 +366,8 @@ fn fsync_parent(parent: &Path) { mod tests { use super::*; - // Valid bincode frame, garbage inside value_json. The frame round-trips - // through bincode, so the failure must come from into_snapshot's JSON - // step, not the bincode deserialize step. + // Valid bincode frame with garbage value_json: the frame itself + // round-trips, so only into_snapshot's JSON step can fail. #[test] fn bincode_corrupt_inner_json_is_tolerated() { let adapter = BincodeSnapshot { From 884fc61859f8bb17498c16d05b78b8e4aa693dde Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 16:29:14 +0200 Subject: [PATCH 032/111] feat: prefer max-age over s-maxage in private http cache and refresh agents docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpCache is a private (client-side) cache, and RFC 9111 §5.2.2.10 scopes s-maxage to shared caches, so max-age now wins when both are present while s-maxage still applies as the fallback when alone. Tests cover the new precedence, the s-maxage-alone fallback, and no-store overriding s-maxage. AGENTS.md version literals move 0.2.0 to 0.2.1, em dashes become plain punctuation, and the skills bullet regains invalidation and observers. --- AGENTS.md | 56 +++++++++++++++---------------- crates/gpui-query-http/README.md | 2 +- crates/gpui-query-http/src/lib.rs | 32 ++++++++++++++---- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b5e7ab..f6060b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,39 +6,39 @@ Async state management for [GPUI](https://github.com/zed-industries/zed/tree/mai Workspace (`resolver = "3"`, edition 2024) with three published members: -- `crates/gpui-query` — main crate (v0.2.0), four gated layers (see Feature matrix). -- `crates/gpui-query-http` — satellite (v0.1.0): RFC 9111 `Cache-Control` → `CachePolicy`, URL-keyed `HttpCache<B>` over a pluggable `HttpBackend`. GPUI-free. -- `crates/gpui-query-persist` — satellite (v0.1.0): reference atomic disk `FilePersister` (JSON/bincode) implementing the main crate's async `Persister`. -- `crates/gpui-query-legacy` — DEPRECATED and EXCLUDED from the workspace (`exclude = [...]`). Frozen artifact; publish only via its own workflow. +- `crates/gpui-query`: main crate (v0.2.1), four gated layers (see Feature matrix). +- `crates/gpui-query-http`: satellite (v0.1.0), RFC 9111 `Cache-Control` → `CachePolicy`, URL-keyed `HttpCache<B>` over a pluggable `HttpBackend`. GPUI-free. +- `crates/gpui-query-persist`: satellite (v0.1.0), reference atomic disk `FilePersister` (JSON/bincode) implementing the main crate's async `Persister`. +- `crates/gpui-query-legacy`: DEPRECATED and EXCLUDED from the workspace (`exclude = [...]`). Frozen artifact; publish only via its own workflow. `gpui-query` layers, each behind a Cargo feature flag: -- `core` — serde-only state machine (`QueryResource`, `MutationResource`, `CachePolicy`, `RetryPolicy`, `QueryKey`). Zero GPUI. -- `client` — `QueryClient` GPUI `Global`: type-partitioned `QueryBucket<T,E>`, GC, invalidation, observers. -- `hook` — `use_query` / `use_mutation` / `use_infinite_query`, returning `(Entity, Subscription)`. -- `persist` — async `Persister` trait, `persist_with` debounced driver, `hydrate()`, serializer registries. +- `core`: serde-only state machine (`QueryResource`, `MutationResource`, `CachePolicy`, `RetryPolicy`, `QueryKey`). Zero GPUI. +- `client`: `QueryClient` GPUI `Global` (type-partitioned `QueryBucket<T,E>`, GC, invalidation, observers). +- `hook`: `use_query` / `use_mutation` / `use_infinite_query`, returning `(Entity, Subscription)`. +- `persist`: async `Persister` trait, `persist_with` debounced driver, `hydrate()`, serializer registries. -The public API is glob re-exported at the crate root (`pub use core::*; pub use client::*; pub use hook::*;`) — import from `gpui_query::`. +The public API is glob re-exported at the crate root (`pub use core::*; pub use client::*; pub use hook::*;`). Import from `gpui_query::`. ## Commands Task runner is `just` (justfile at repo root). Recipes shown with their raw equivalent. -- Test everything — `just test` / `cargo test --all-features`. -- Test one layer — `just test-feature hook` / `cargo test --features "hook"`. -- Core-only (no GPUI) — `cargo test --no-default-features --features core`. -- Build all — `cargo build --all-features`. -- Docs — `cargo doc --all-features`. +- Test everything: `just test` / `cargo test --all-features`. +- Test one layer: `just test-feature hook` / `cargo test --features "hook"`. +- Core-only (no GPUI): `cargo test --no-default-features --features core`. +- Build all: `cargo build --all-features`. +- Docs: `cargo doc --all-features`. Default features are `client` only, so bare `cargo test` builds the `core` + `client` test modules but skips the `hook`/`persist`-gated ones. Always use `just test` (`--all-features`) for the full suite. -Web docs (Astro + Starlight, bun-managed) — run from `web/`: +Web docs (Astro + Starlight, bun-managed); run from `web/`: - `just web-install` / `bun install`. -- `just web-dev` / `bun run dev` — dev server on port 3000. -- `just web-build` / `bun run build` — full build to `dist/client/` (OG images → `astro build` → Pagefind → `llms.txt` → md-alt). +- `just web-dev` / `bun run dev`: dev server on port 3000. +- `just web-build` / `bun run build`: full build to `dist/client/` (OG images → `astro build` → Pagefind → `llms.txt` → md-alt). - `just web-preview` / `bun run preview`. -- `just deploy` — triggers the Deploy Website workflow. +- `just deploy`: triggers the Deploy Website workflow. ## Feature matrix @@ -46,10 +46,10 @@ Main crate, strictly additive (`core` ← `client` ← `hook` ← `persist`): | Layer | Cargo line | |---|---| -| core only (no GPUI) | `gpui-query = { version = "0.2.0", default-features = false, features = ["core"] }` | -| client (DEFAULT) | `gpui-query = "0.2.0"` | -| hooks | `gpui-query = { version = "0.2.0", features = ["hook"] }` | -| persistence | `gpui-query = { version = "0.2.0", features = ["persist"] }` | +| core only (no GPUI) | `gpui-query = { version = "0.2.1", default-features = false, features = ["core"] }` | +| client (DEFAULT) | `gpui-query = "0.2.1"` | +| hooks | `gpui-query = { version = "0.2.1", features = ["hook"] }` | +| persistence | `gpui-query = { version = "0.2.1", features = ["persist"] }` | ``` default = ["client"] @@ -66,7 +66,7 @@ Satellites depend on `gpui-query` core-only by default: `gpui-query-http` (optio CHANGELOG-driven. The `CHANGELOG.md` version heading is the source of truth; CI bumps `Cargo.toml` and syncs the version literal into the READMEs and the docs install page. 1. Add a `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`. -2. `just release x.y.z` — verifies the heading, commits `chore: release vX`, pushes master. +2. `just release x.y.z`: verifies the heading, commits `chore: release vX`, pushes master. 3. The push triggers `Changelog Release`: tag, GitHub Release, `cargo publish`, and web deploy. Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy` (Deploy Website). The legacy crate has its own `Publish Legacy Crate (Manual)` workflow. @@ -76,8 +76,8 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy `web/` is Astro + Starlight, bun-managed, deployed to Cloudflare Pages project `gpui-query` from `web/dist/client`. Docs served at `/docs/**`; site root is the marketing page. - `llms.txt` and `llms-full.txt` are AUTO-GENERATED into the site root by `web/scripts/generate-llms-txt.ts` during `just web-build`. Do NOT hand-edit them or anything under `web/dist/**`. -- Per-page `.md` and `.txt` alternates (one per public page — append the extension to any URL, e.g. `/docs/guides/caching.md`; root → `/index.{md,txt}`, docs index → `/docs.{md,txt}`) are AUTO-GENERATED by `web/scripts/generate-page-alts.ts` from `web/scripts/lib/pages.ts`, and each HTML page advertises them via `<link rel="alternate">`. They run ~80–90% smaller than the HTML. Do NOT hand-edit them. -- Page copy shared between the rendered page and its `.md`/`.txt` alternates is authored ONCE: FAQ in `web/src/lib/faq-data.ts`, privacy + terms in `web/src/lib/legal-content.ts` (rendered to HTML by `web/src/lib/inline-md.ts`). Edit there — never duplicate it into the `.astro` files or the generator. +- Per-page `.md` and `.txt` alternates (one per public page: append the extension to any URL, e.g. `/docs/guides/caching.md`; root → `/index.{md,txt}`, docs index → `/docs.{md,txt}`) are AUTO-GENERATED by `web/scripts/generate-page-alts.ts` from `web/scripts/lib/pages.ts`, and each HTML page advertises them via `<link rel="alternate">`. They run ~80–90% smaller than the HTML. Do NOT hand-edit them. +- Page copy shared between the rendered page and its `.md`/`.txt` alternates is authored ONCE: FAQ in `web/src/lib/faq-data.ts`, privacy + terms in `web/src/lib/legal-content.ts` (rendered to HTML by `web/src/lib/inline-md.ts`). Edit there. Never duplicate it into the `.astro` files or the generator. - Search is one combined Pagefind index. - Directory output (`build.format: "directory"`) with `trailingSlash: "ignore"` so both `/docs` and `/docs/` serve; the `Head.astro` override normalizes canonical/OG URLs. @@ -96,11 +96,11 @@ Binding on every change; violations are review-blocking. - Author identity is `authors = ["hmziqrs"]` in every published crate (no email). - Commits are conventional lower-case: `chore:`, `fix:`, `feat:`, `ci:`. - docs.rs: `all-features = true`, `--cfg docsrs`; `lib.rs` gates `#![cfg_attr(docsrs, feature(doc_cfg))]`. -- Two user-facing Claude Code skill packs live in `skills/`: `gpui-query` (hooks, in-memory caching, retry) and `gpui-query-extensions` (HTTP cache headers, disk persistence). They target apps that depend on gpui-query, not work on this repo. Install: `cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/`. +- Two user-facing Claude Code skill packs live in `skills/`: `gpui-query` (hooks, in-memory caching, invalidation, observers, retry) and `gpui-query-extensions` (HTTP cache headers, disk persistence). They target apps that depend on gpui-query, not work on this repo. Install: `cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/`. ## Gotchas - `Cargo.lock` is NOT committed (library convention; `.gitignore` line 4). Do not `git add` it. - macOS builds of the `client`/`hook`/`persist` layers fail without the Metal Toolchain. Run `xcodebuild -downloadComponent MetalToolchain` once. Core-only builds need nothing extra. -- `gpui-query-legacy` is excluded from the workspace — invisible to `cargo build --workspace`, but still published. Treat as frozen; edit it only via its own workflow. -- GPUI is pinned to `gpui = "0.2.2"` on crates.io. A `zed-industries/zed` git-rev pin is commented out in the root `Cargo.toml` for local debugging — never commit it uncommented (breaks `cargo publish`). +- `gpui-query-legacy` is excluded from the workspace: invisible to `cargo build --workspace`, but still published. Treat as frozen; edit it only via its own workflow. +- GPUI is pinned to `gpui = "0.2.2"` on crates.io. A `zed-industries/zed` git-rev pin is commented out in the root `Cargo.toml` for local debugging; never commit it uncommented (breaks `cargo publish`). diff --git a/crates/gpui-query-http/README.md b/crates/gpui-query-http/README.md index 1f55271..357a205 100644 --- a/crates/gpui-query-http/README.md +++ b/crates/gpui-query-http/README.md @@ -36,7 +36,7 @@ Parsing rules (priority order): 2. `max-age=N` (seconds) → `CachePolicy::Ttl { ttl_ms: N * 1000 }`; add `stale-while-revalidate=M` → `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }`. Duplicated directives keep their first occurrence (RFC 9111 §4.2.1), and a delta-seconds too large for `u64` saturates instead of erroring (RFC 9111 §1.2.2). 3. Otherwise → `CachePolicy::NoCache`; malformed values surface as `ParseError`. -`s-maxage` takes precedence over `max-age` when both are set. Directive names are matched case-insensitively and values may be quoted (`max-age="600"`). +When both are set, `max-age` wins: `HttpCache` is a private cache, and RFC 9111 §5.2.2.10 scopes `s-maxage` to shared caches. `s-maxage` alone still sets the TTL. Directive names are matched case-insensitively and values may be quoted (`max-age="600"`). ## Usage diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 088722e..eb05817 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -82,11 +82,12 @@ pub enum ParseError { /// - `no-store` / `no-cache` anywhere returns [`CachePolicy::NoCache`], /// regardless of position or malformed directives elsewhere (RFC 9111 /// §5.2.2: storing is forbidden outright). -/// - Otherwise the first `s-maxage` (falling back to `max-age`) sets the TTL; -/// a `stale-while-revalidate` alongside yields -/// [`CachePolicy::StaleWhileRevalidate`]. Duplicates keep their first -/// occurrence (RFC 9111 §4.2.1), and a delta-seconds too large for `u64` -/// saturates instead of erroring (RFC 9111 §1.2.2). +/// - Otherwise the first `max-age` sets the TTL, falling back to `s-maxage` +/// when absent ([`HttpCache`] is a private cache, and RFC 9111 §5.2.2.10 +/// scopes `s-maxage` to shared caches). A `stale-while-revalidate` +/// alongside yields [`CachePolicy::StaleWhileRevalidate`]. Duplicates keep +/// their first occurrence (RFC 9111 §4.2.1), and a delta-seconds too +/// large for `u64` saturates instead of erroring (RFC 9111 §1.2.2). /// - Anything else returns [`CachePolicy::NoCache`]; malformed values surface /// as [`ParseError`]. /// @@ -137,7 +138,7 @@ pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, Par let max_age_secs = max_age.transpose()?; let swr_secs = swr.transpose()?; - match (s_maxage_secs.or(max_age_secs), swr_secs) { + match (max_age_secs.or(s_maxage_secs), swr_secs) { (Some(secs), Some(stale)) => Ok(CachePolicy::StaleWhileRevalidate { ttl_ms: secs.saturating_mul(1000), stale_ms: stale.saturating_mul(1000), @@ -234,11 +235,28 @@ mod tests { } #[test] - fn s_maxage_takes_precedence() { + fn max_age_wins_over_s_maxage() { + // Private cache: only a shared cache may prefer s-maxage + // (RFC 9111 §5.2.2.10), so max-age wins when both are present. let policy = cache_policy_from_headers(&cc("max-age=10, s-maxage=30")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 10_000 }); + } + + #[test] + fn s_maxage_alone_sets_ttl() { + // No max-age to shadow it: s-maxage still applies as the fallback. + let policy = cache_policy_from_headers(&cc("s-maxage=30")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 30_000 }); } + #[test] + fn no_store_wins_over_s_maxage() { + assert_eq!( + cache_policy_from_headers(&cc("s-maxage=30, no-store")).unwrap(), + CachePolicy::NoCache + ); + } + #[test] fn no_store_yields_no_cache() { assert_eq!( From ee6f01f686a908ac7ecdc472092f6cc5b7acc91b Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 16:56:29 +0200 Subject: [PATCH 033/111] fix: error on empty query-key array in serde and widen token redaction whitespace - QueryKey Deserialize returns Err instead of panicking on an empty array, closing the derived-Deserialize leak-through; 2 tests added. - redact_tokens accepts ascii-whitespace variants around bearer and token separators; 2 pinning tests added. - prepared_fetch closures bind _cx so client-only builds stay quiet. --- .../gpui-query/src/client/prepared_fetch.rs | 9 ++-- crates/gpui-query/src/core/error/sanitize.rs | 47 +++++++++++++++---- crates/gpui-query/src/core/key.rs | 33 ++++++++++++- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/gpui-query/src/client/prepared_fetch.rs b/crates/gpui-query/src/client/prepared_fetch.rs index 8687dfd..4abf034 100644 --- a/crates/gpui-query/src/client/prepared_fetch.rs +++ b/crates/gpui-query/src/client/prepared_fetch.rs @@ -49,13 +49,14 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare /// Complete the fetch with success. A no-op if the request ID is no /// longer active (replaced by a newer request). pub fn complete_success(self, data: T, cx: &mut App) { - self.entity.update(cx, |resource, cx| { + // `_cx`: used only under the persist feature. + self.entity.update(cx, |resource, _cx| { let accepted = resource.complete_current_success(self.request_id, data, self.now_ms); // Wake the persistence driver, but only when the completion was // actually accepted, so a stale no-op does not schedule a save. if accepted { #[cfg(feature = "persist")] - cx.default_global::<crate::client::CacheMutation>(); + _cx.default_global::<crate::client::CacheMutation>(); } }); } @@ -63,11 +64,11 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare /// Complete the fetch with failure. A no-op if the request ID is no /// longer active (replaced by a newer request). pub fn complete_failure(self, error: E, cx: &mut App) { - self.entity.update(cx, |resource, cx| { + self.entity.update(cx, |resource, _cx| { let accepted = resource.complete_current_failure(self.request_id, error, self.now_ms); if accepted { #[cfg(feature = "persist")] - cx.default_global::<crate::client::CacheMutation>(); + _cx.default_global::<crate::client::CacheMutation>(); } }); } diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 6696d1b..e219da1 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -161,7 +161,8 @@ fn redact_until_whitespace( result } -/// Redact bearer/token patterns. +/// Redact bearer/token patterns. ASCII whitespace after the keyword (or +/// after the `=`/`:` separator) is tolerated, per `bearer\s+|token[=:]\s*`. fn redact_tokens(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let chars: Vec<char> = text.chars().collect(); @@ -170,14 +171,17 @@ fn redact_tokens(text: &str, replacement: &str) -> String { let mut i = 0; while i < len { - if lower_matches_at(&lower, i, "bearer ") { + if lower_matches_at(&lower, i, "bearer") + && i + 6 < len + && chars[i + 6].is_ascii_whitespace() + { + // Keep "bearer" plus its first whitespace char, then swallow any + // extra whitespace so "bearer<TAB>x" redacts like "bearer x". for c in &chars[i..i + 7] { result.push(*c); } i += 7; - while i < len && !chars[i].is_ascii_whitespace() { - i += 1; - } + skip_whitespace_and_token(&chars, &mut i, &mut result); result.push_str(replacement); continue; } @@ -186,9 +190,7 @@ fn redact_tokens(text: &str, replacement: &str) -> String { result.push(*c); } i += 6; - while i < len && !chars[i].is_ascii_whitespace() { - i += 1; - } + skip_whitespace_and_token(&chars, &mut i, &mut result); result.push_str(replacement); continue; } @@ -198,6 +200,19 @@ fn redact_tokens(text: &str, replacement: &str) -> String { result } +/// Copy the ASCII-whitespace run into `result`, then skip past the +/// non-whitespace token that follows (dropped from the output). +fn skip_whitespace_and_token(chars: &[char], i: &mut usize, result: &mut String) { + let len = chars.len(); + while *i < len && chars[*i].is_ascii_whitespace() { + result.push(chars[*i]); + *i += 1; + } + while *i < len && !chars[*i].is_ascii_whitespace() { + *i += 1; + } +} + /// Check whether `lower` contains the ASCII `pat` (already-lowercased) at index `i`. fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { let pb = pat.as_bytes(); @@ -329,6 +344,22 @@ mod tests { assert!(out.contains("[REDACTED_TOKEN]")); } + #[test] + fn redact_tokens_tolerates_tab_after_bearer() { + let out = redact_tokens("auth failed: bearer\tabc123", "[REDACTED_TOKEN]"); + assert!(!out.contains("abc123")); + assert!(out.contains("bearer\t")); + assert!(out.contains("[REDACTED_TOKEN]")); + } + + #[test] + fn redact_tokens_tolerates_space_after_equals() { + let out = redact_tokens("token= abc123", "[REDACTED_TOKEN]"); + assert!(out.contains("token= ")); + assert!(!out.contains("abc123")); + assert!(out.contains("[REDACTED_TOKEN]")); + } + #[test] fn redact_users_path_mixed_case() { let msg = "error in /Users/admin/.env leaked"; diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 886d900..6c5c1b3 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -17,7 +17,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq} /// /// A `QueryKey` must contain at least one segment. Zero-length keys are /// unsupported: constructing one via [`QueryKey::new`] with an empty iterator -/// will panic unconditionally in all build modes. +/// will panic unconditionally in all build modes. Deserializing an empty key +/// array returns a serde error rather than panicking. /// /// # Examples /// @@ -189,7 +190,12 @@ impl<'de> Deserialize<'de> for QueryKey { } match KeyRepr::deserialize(deserializer)? { - KeyRepr::Array(parts) => Ok(Self::new(parts)), + // An empty array must surface as an Err, never reach new()'s + // assert: deserializing untrusted blobs cannot panic. + KeyRepr::Array(parts) if !parts.is_empty() => Ok(Self::new(parts)), + KeyRepr::Array(_) => Err(serde::de::Error::custom( + "QueryKey must contain at least one segment", + )), KeyRepr::String(s) => Ok(Self::from_single(s)), } } @@ -244,6 +250,29 @@ mod tests { assert_eq!(key, back); } + #[test] + fn serde_empty_array_returns_err_not_panic() { + let err = serde_json::from_str::<QueryKey>("[]").unwrap_err(); + assert!(err.to_string().contains("at least one segment")); + } + + #[test] + fn serde_resource_empty_key_field_returns_err_not_panic() { + use crate::core::{CachePolicy, QueryResource, RequestPolicy}; + + let resource = QueryResource::<String>::new( + QueryKey::from(["users"]), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + ); + let mut value = serde_json::to_value(&resource).unwrap(); + value["key"] = serde_json::Value::Array(Vec::new()); + let json = value.to_string(); + + let err = serde_json::from_str::<QueryResource<String>>(&json).unwrap_err(); + assert!(err.to_string().contains("at least one segment")); + } + #[test] #[should_panic(expected = "QueryKey must contain at least one segment")] fn new_rejects_empty_parts() { From 5e136f18be130f2d24c549a0b106c0826fafe7fc Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 17:57:02 +0200 Subject: [PATCH 034/111] chore: codify audit quality bars as hard rules --- AGENTS.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6060b2..2915b5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy `web/` is Astro + Starlight, bun-managed, deployed to Cloudflare Pages project `gpui-query` from `web/dist/client`. Docs served at `/docs/**`; site root is the marketing page. - `llms.txt` and `llms-full.txt` are AUTO-GENERATED into the site root by `web/scripts/generate-llms-txt.ts` during `just web-build`. Do NOT hand-edit them or anything under `web/dist/**`. -- Per-page `.md` and `.txt` alternates (one per public page: append the extension to any URL, e.g. `/docs/guides/caching.md`; root → `/index.{md,txt}`, docs index → `/docs.{md,txt}`) are AUTO-GENERATED by `web/scripts/generate-page-alts.ts` from `web/scripts/lib/pages.ts`, and each HTML page advertises them via `<link rel="alternate">`. They run ~80–90% smaller than the HTML. Do NOT hand-edit them. +- Per-page `.md` and `.txt` alternates (one per public page: append the extension to any URL, e.g. `/docs/guides/caching.md`; root → `/index.{md,txt}`, docs index → `/docs.{md,txt}`) are AUTO-GENERATED by `web/scripts/generate-page-alts.ts` from `web/scripts/lib/pages.ts`, and each HTML page advertises them via `<link rel="alternate">`. They run ~80-90% smaller than the HTML. Do NOT hand-edit them. - Page copy shared between the rendered page and its `.md`/`.txt` alternates is authored ONCE: FAQ in `web/src/lib/faq-data.ts`, privacy + terms in `web/src/lib/legal-content.ts` (rendered to HTML by `web/src/lib/inline-md.ts`). Edit there. Never duplicate it into the `.astro` files or the generator. - Search is one combined Pagefind index. - Directory output (`build.format: "directory"`) with `trailingSlash: "ignore"` so both `/docs` and `/docs/` serve; the `Head.astro` override normalizes canonical/OG URLs. @@ -85,9 +85,18 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy Binding on every change; violations are review-blocking. -- Comments: inline comments are 1-2 lines max and only for non-obvious constraints. No narration, no change-history notes ("T5:", "fixed:"), no restating what the code already says. Same for test comments. Doc comments stay at 1-3 lines plus `# Examples` blocks that carry doctests. Bloated comment blocks are review-blocking. -- Copywriting: all prose (comments, READMEs, docs) must read human-written. Apply the humanizer and humanize-writing skills to prose changes before merging: no em-dash cadence, no rule-of-three padding, no "seamless/robust/leverage" vocabulary. +- Comments: don't comment unless the code can't say it. Inline comments are 1-2 lines max and only for non-obvious constraints. No narration, no change-history notes ("T5:", "fixed:"), no restating what the code already says. Same for test comments. Doc comments stay at 1-3 lines plus `# Examples` blocks that carry doctests. +- Skills: load rust-best-practices and rust-testing before writing or reviewing Rust; add rust-async-patterns for async paths and gpui-kit for GPUI-facing code. Run humanizer and humanize-writing over any prose change before merging. +- Copywriting: all prose (comments, READMEs, docs) must read human-written: no em-dash cadence, no rule-of-three padding, no "seamless/robust/leverage" vocabulary. +- Gates: `cargo test --all-features`, `cargo clippy --all-features --all-targets -- -D warnings`, and `cargo doc --all-features --no-deps` (zero warnings) all pass before a change is done. Bare `cargo test` skips the hook/persist modules; that is not a green run. +- API stability: these are published crates. Never remove, rename, or retype a pub item in a patch release. Add instead of break. +- Untrusted input: parsing or deserializing anything from disk or network returns `Err`, never panics. No `unwrap`/`expect`/indexing on unbounded values, and arithmetic on them uses checked or saturating ops. New parse or serde paths get adversarial tests. +- Secrets in errors: error text that can carry user data goes through sanitization before Display, Debug, or serde. New redaction gaps (case or whitespace variants) are bugs, not style. +- Cache semantics: `gpui-query-http` follows RFC 9111. It is a private cache: `max-age` beats `s-maxage`, `no-store` dominates from any position, duplicate directives are first-occurrence-wins, and directive values saturate instead of erroring. +- Disk writes: atomic only. Temp file, fsync, rename, fsync the parent dir. Cache files are `0o600`. +- Perf: no redundant hashing or serialization per debounce or poll cycle. Clone at ownership boundaries, not for convenience. - Trimming beats adding. Dead code, duplicated plumbing, and comments that restate their docs get deleted, not maintained. +- Every bug fix lands with a test that fails on the old code. ## Conventions From 87fdf83bd531d595d326d74da409ac46dc0ffbce Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 18:28:53 +0200 Subject: [PATCH 035/111] refactor: purge core comments to one-line-or-examples and fold test helpers --- crates/gpui-query/Cargo.toml | 6 +- crates/gpui-query/src/core/error/convert.rs | 11 +- crates/gpui-query/src/core/error/mod.rs | 13 +- crates/gpui-query/src/core/error/sanitize.rs | 37 +---- crates/gpui-query/src/core/error/types.rs | 44 +----- crates/gpui-query/src/core/fetched.rs | 46 +------ .../src/core/infinite_query/accessors.rs | 108 ++------------- .../src/core/infinite_query/lifecycle.rs | 120 +++------------- .../gpui-query/src/core/infinite_query/mod.rs | 40 +----- .../core/infinite_query/page_management.rs | 42 ++---- .../src/core/infinite_query/resource.rs | 65 +++------ crates/gpui-query/src/core/key.rs | 62 ++------- crates/gpui-query/src/core/key_filter.rs | 7 +- crates/gpui-query/src/core/mod.rs | 43 +----- crates/gpui-query/src/core/mutation.rs | 128 +++--------------- crates/gpui-query/src/core/network_mode.rs | 11 +- crates/gpui-query/src/core/policy.rs | 100 +++----------- crates/gpui-query/src/core/refetch.rs | 9 +- crates/gpui-query/src/core/request.rs | 81 ++--------- crates/gpui-query/src/core/resource.rs | 21 +-- .../gpui-query/src/core/resource/accessors.rs | 32 +---- crates/gpui-query/src/core/resource/cache.rs | 59 +------- .../src/core/resource/completion.rs | 43 +----- .../gpui-query/src/core/resource/lifecycle.rs | 116 +++------------- crates/gpui-query/src/core/retry.rs | 30 +--- crates/gpui-query/src/core/select.rs | 89 ++---------- crates/gpui-query/src/core/signal.rs | 13 +- crates/gpui-query/src/core/status.rs | 16 +-- crates/gpui-query/src/lib.rs | 46 +------ .../src/tests/core_cache/cache_ops.rs | 10 -- .../src/tests/core_cache/cache_policy.rs | 23 ---- .../src/tests/core_cache/data_retention.rs | 6 - crates/gpui-query/src/tests/core_cache/mod.rs | 30 +--- .../tests/core_cache/request_interactions.rs | 6 - .../src/tests/core_infinite_query/helpers.rs | 30 +--- .../core_infinite_query/initial_state.rs | 14 +- .../tests/core_infinite_query/max_pages.rs | 15 +- .../tests/core_infinite_query/page_fetch.rs | 31 +---- .../stale_and_completion.rs | 13 +- .../core_infinite_query/state_transitions.rs | 24 ---- .../core_lifecycle/cancel_and_signals.rs | 45 ------ .../core_lifecycle/data_and_lifecycle.rs | 38 +----- .../src/tests/core_lifecycle/mod.rs | 5 - .../core_lifecycle/policies_and_cache.rs | 47 ------- .../tests/core_lifecycle/reset_and_retry.rs | 19 --- .../src/tests/core_lifecycle/transitions.rs | 48 ------- .../src/tests/core_mutation/cancellation.rs | 8 -- .../src/tests/core_mutation/lifecycle.rs | 42 ------ .../gpui-query/src/tests/core_mutation/mod.rs | 2 - .../src/tests/core_mutation/retry.rs | 45 ------ .../src/tests/core_policy_types/mod.rs | 7 - .../policy_and_status_types.rs | 28 +--- .../tests/core_policy_types/query_error.rs | 12 -- .../tests/core_policy_types/retry_policy.rs | 30 +--- .../gpui-query/src/tests/core_request/mod.rs | 10 -- .../core_request/request_id_sequencer.rs | 24 ---- .../tests/core_request/request_lifecycle.rs | 27 ---- .../src/tests/core_request/request_policy.rs | 42 ------ .../infinite_query_resource_advanced.rs | 52 ------- .../query_resource_advanced.rs | 43 +----- .../gpui-query/src/tests/core_select/mod.rs | 23 ---- 61 files changed, 250 insertions(+), 1987 deletions(-) diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index 27899ef..82e86c1 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -24,10 +24,8 @@ serde_json = { workspace = true, optional = true } thiserror = { version = "2", optional = true } gpui = { workspace = true, optional = true } -# ahash's default `runtime-rng` feature pulls getrandom, which hard-errors on -# wasm32-unknown-unknown. The two declarations are mutually exclusive so the -# native build keeps the default feature set (runtime-rng) while wasm uses -# compile-time RNG. +# ahash's runtime-rng pulls getrandom, which hard-errors on wasm32-unknown-unknown; +# wasm builds use compile-time RNG instead. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ahash = { version = "0.8" } diff --git a/crates/gpui-query/src/core/error/convert.rs b/crates/gpui-query/src/core/error/convert.rs index 21b42a4..c43ea13 100644 --- a/crates/gpui-query/src/core/error/convert.rs +++ b/crates/gpui-query/src/core/error/convert.rs @@ -23,21 +23,14 @@ impl AsRef<std::sync::Arc<str>> for QueryError { } impl From<String> for QueryError { - /// Creates a [`QueryError`] with kind [`QueryErrorKind::Unknown`](super::QueryErrorKind::Unknown). - /// - /// `From<String>` and `From<&str>` always map to `Unknown` because the - /// original error category cannot be recovered from a plain string. Use - /// [`QueryError::transport`], [`QueryError::response`], or - /// [`QueryError::cancelled`] for typed errors. + /// Always maps to `Unknown` because the category cannot be recovered from + /// a plain string; use `transport`/`response`/`cancelled` for typed errors. fn from(value: String) -> Self { Self::unknown(value) } } impl From<&str> for QueryError { - /// Creates a [`QueryError`] with kind [`QueryErrorKind::Unknown`](super::QueryErrorKind::Unknown). - /// - /// See [`From<String>`] for rationale on the `Unknown` mapping. fn from(value: &str) -> Self { Self::unknown(value) } diff --git a/crates/gpui-query/src/core/error/mod.rs b/crates/gpui-query/src/core/error/mod.rs index 079fd32..9c7bc5c 100644 --- a/crates/gpui-query/src/core/error/mod.rs +++ b/crates/gpui-query/src/core/error/mod.rs @@ -1,16 +1,7 @@ //! Error types for query operations. //! -//! [`QueryError`] is the default error type for query resources. It implements -//! [`std::fmt::Display`] and [`std::error::Error`] for ecosystem interop -//! with `?` propagation and `anyhow`. -//! -//! # Security note -//! -//! Error messages passed to [`QueryError`] are stored verbatim and may appear -//! in logs, DevTools diagnostics, and serialized output. Callers **must** -//! sanitize server responses before constructing a `QueryError` to avoid -//! leaking sensitive data (internal paths, credentials, auth tokens, etc.). -//! Use [`QueryError::sanitized`] to redact known sensitive patterns. +//! Messages are stored verbatim and may reach logs and serialized output; +//! use [`QueryError::sanitized`] on server responses. mod convert; mod sanitize; diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index e219da1..5cd81e6 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -1,23 +1,15 @@ //! Redaction of sensitive patterns from error messages, without a `regex` -//! dependency. Covers connection strings, bearer tokens, file paths, -//! emails, and long hex runs. +//! dependency. -/// Maximum length for sanitized error messages. pub const SANITIZE_MAX_LEN: usize = 512; -/// Lowercase needles for recognized connection-string schemes. const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; -/// Lowercase needles for filesystem path prefixes, including macOS home dirs. const PATH_NEEDLES: [&str; 4] = ["/home/", "/users/", "/etc/", "/var/"]; -/// Redact known sensitive patterns from a message string and truncate to -/// [`SANITIZE_MAX_LEN`]. pub(crate) fn sanitize_message(msg: &str) -> String { use std::borrow::Cow; - // Cow pipeline: a clean message stays borrowed through every rule and - // never allocates until the final `into_owned`. let mut out: Cow<str> = Cow::Borrowed(msg); out = replace_regex( @@ -55,18 +47,15 @@ pub(crate) fn sanitize_message(msg: &str) -> String { s } -/// Apply one redaction rule. The `pattern` string only selects which rule -/// runs; each rule starts with a cheap `contains` guard so clean messages -/// skip the full scan, and returns the input unchanged (still borrowed) -/// when nothing can match. +/// The `pattern` string selects which rule runs; each rule guards with a cheap +/// `contains` and returns the input still-borrowed when nothing can match. fn replace_regex<'a>( input: std::borrow::Cow<'a, str>, pattern: &str, replacement: &str, ) -> std::borrow::Cow<'a, str> { let text: &str = &input; - // ASCII lowercasing preserves byte offsets, so positions found in a - // lowercased copy are valid slice indices into `text`. + // ASCII lowercasing preserves byte offsets, so lowercased positions are valid indices into `text`. let owned = match pattern { p if p.contains("postgres") => { let lower = text.to_ascii_lowercase(); @@ -109,7 +98,6 @@ fn replace_regex<'a>( std::borrow::Cow::Owned(owned) } -/// Whether `text` contains a run of 16+ hex digits. fn has_long_hex_run(text: &str) -> bool { let mut run = 0usize; for c in text.chars() { @@ -125,8 +113,6 @@ fn has_long_hex_run(text: &str) -> bool { false } -/// Redact every occurrence of any `needle` (located via its lowercase copy -/// `lower`) from the match start through the next whitespace character. fn redact_until_whitespace( text: &str, lower: &str, @@ -161,8 +147,6 @@ fn redact_until_whitespace( result } -/// Redact bearer/token patterns. ASCII whitespace after the keyword (or -/// after the `=`/`:` separator) is tolerated, per `bearer\s+|token[=:]\s*`. fn redact_tokens(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let chars: Vec<char> = text.chars().collect(); @@ -175,8 +159,6 @@ fn redact_tokens(text: &str, replacement: &str) -> String { && i + 6 < len && chars[i + 6].is_ascii_whitespace() { - // Keep "bearer" plus its first whitespace char, then swallow any - // extra whitespace so "bearer<TAB>x" redacts like "bearer x". for c in &chars[i..i + 7] { result.push(*c); } @@ -200,8 +182,6 @@ fn redact_tokens(text: &str, replacement: &str) -> String { result } -/// Copy the ASCII-whitespace run into `result`, then skip past the -/// non-whitespace token that follows (dropped from the output). fn skip_whitespace_and_token(chars: &[char], i: &mut usize, result: &mut String) { let len = chars.len(); while *i < len && chars[*i].is_ascii_whitespace() { @@ -213,7 +193,6 @@ fn skip_whitespace_and_token(chars: &[char], i: &mut usize, result: &mut String) } } -/// Check whether `lower` contains the ASCII `pat` (already-lowercased) at index `i`. fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { let pb = pat.as_bytes(); if i + pb.len() > lower.len() { @@ -227,7 +206,6 @@ fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { true } -/// Redact email addresses (simple heuristic: word@word.tld). fn redact_emails(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let chars: Vec<char> = text.chars().collect(); @@ -246,14 +224,12 @@ fn redact_emails(text: &str, replacement: &str) -> String { result } -/// Try to match an email at position `start` in `chars`. Returns end index if matched. fn try_match_email(chars: &[char], start: usize) -> Option<usize> { let len = chars.len(); if start >= len { return None; } - // Local part: alphanumeric + ._%+- let mut i = start; if !chars[i].is_alphanumeric() { return None; @@ -264,9 +240,8 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { if i >= len || chars[i] != '@' { return None; } - i += 1; // skip '@' + i += 1; - // Domain: alphanumeric + .- if i >= len || !chars[i].is_alphanumeric() { return None; } @@ -274,7 +249,6 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { i += 1; } - // Must end with a dot followed by 2+ alpha chars (TLD). let domain_end = i; if domain_end <= start + 2 { return None; @@ -288,7 +262,6 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { } } -/// Redact long hex sequences (16+ hex chars). fn redact_hex(text: &str, replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let chars: Vec<char> = text.chars().collect(); diff --git a/crates/gpui-query/src/core/error/types.rs b/crates/gpui-query/src/core/error/types.rs index 89e20ea..f4feabf 100644 --- a/crates/gpui-query/src/core/error/types.rs +++ b/crates/gpui-query/src/core/error/types.rs @@ -6,16 +6,11 @@ use serde::{Deserialize, Serialize}; use super::sanitize::sanitize_message; -/// The kind of error that occurred during a query operation. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QueryErrorKind { - /// The request was cancelled (cooperative cancellation). Cancelled, - /// The server returned an error response. Response, - /// A transport-level error occurred (network, timeout). Transport, - /// An unknown error occurred. Unknown, } @@ -30,14 +25,11 @@ impl std::fmt::Display for QueryErrorKind { } } -/// An error produced by a query or mutation operation. +/// Implements [`std::error::Error`] for `?`/`anyhow` interop; the `Arc<str>` +/// message makes clones cheap in high-retry scenarios. /// -/// Implements [`std::error::Error`] so it can be used with `?` propagation -/// and libraries like `anyhow`. -/// -/// The internal message uses `Arc<str>` so cloning is cheap, making this -/// suitable for high-retry scenarios where the same error is stored in -/// multiple locations. +/// Messages are stored verbatim and may be serialized; use [`QueryError::sanitized`] +/// for server responses. /// /// # Example /// @@ -47,12 +39,6 @@ impl std::fmt::Display for QueryErrorKind { /// let err = QueryError::response("not found"); /// assert_eq!(err.to_string(), "response error: not found"); /// ``` -/// -/// # Security -/// -/// Error messages are included in debug/display output and may be serialized. -/// When constructing errors from server responses, sanitize the message first -/// or use [`QueryError::sanitized`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct QueryError { pub(super) kind: QueryErrorKind, @@ -60,7 +46,6 @@ pub struct QueryError { } impl QueryError { - /// Create a new error with the given kind and message. pub fn new(kind: QueryErrorKind, message: impl Into<Arc<str>>) -> Self { Self { kind, @@ -68,53 +53,36 @@ impl QueryError { } } - /// Create a cancellation error. pub fn cancelled(message: impl Into<Arc<str>>) -> Self { Self::new(QueryErrorKind::Cancelled, message) } - /// Create a response error (server-side). pub fn response(message: impl Into<Arc<str>>) -> Self { Self::new(QueryErrorKind::Response, message) } - /// Create a transport error (network, timeout). pub fn transport(message: impl Into<Arc<str>>) -> Self { Self::new(QueryErrorKind::Transport, message) } - /// Create an unknown error. pub fn unknown(message: impl Into<Arc<str>>) -> Self { Self::new(QueryErrorKind::Unknown, message) } - /// The kind of error. pub fn kind(&self) -> QueryErrorKind { self.kind } - /// The error message. pub fn message(&self) -> &str { &self.message } - /// Returns a reference to the inner `Arc<str>` message, allowing cheap - /// clones of the message without re-allocation. pub fn message_arc(&self) -> &Arc<str> { &self.message } - /// Return a sanitized copy of this error with known sensitive patterns redacted. - /// - /// Redacts common patterns such as: - /// - Database connection strings (`postgres://...`, `mysql://...`) - /// - Bearer / token headers (`Bearer ...`, `token=...`) - /// - File paths (`/home/...`, `/Users/...`, `/etc/...`) - /// - Email-like strings - /// - Long hex sequences (likely API keys) - /// - /// Also truncates the message to - /// [`SANITIZE_MAX_LEN`](super::sanitize::SANITIZE_MAX_LEN) bytes. + /// Redacts connection strings, bearer tokens, file paths, emails, and long + /// hex runs; truncates to [`SANITIZE_MAX_LEN`](super::sanitize::SANITIZE_MAX_LEN) bytes. #[expect( rustdoc::private_intra_doc_links, reason = "the const stays internal; the link renders under --document-private-items" diff --git a/crates/gpui-query/src/core/fetched.rs b/crates/gpui-query/src/core/fetched.rs index 2db0e3f..276a5ed 100644 --- a/crates/gpui-query/src/core/fetched.rs +++ b/crates/gpui-query/src/core/fetched.rs @@ -1,51 +1,25 @@ -//! Fetcher result wrapper for server-derived cache policy ("server wins"). +//! Fetcher result wrapper for "server wins" cache policy. //! -//! A fetcher normally returns `Result<T, E>`. When it instead returns -//! `Result<Fetched<T>, E>` (via the `*_with_policy` hooks in the `hook` layer), -//! it can attach a server-derived [`CachePolicy`] that overrides the caller's -//! per-query policy on success — so a fetcher that just read -//! `Cache-Control: max-age=30` can push that TTL back into the resource. -//! -//! This type carries the data and an optional [`CachePolicy`]. Behind the -//! `persist` feature it also carries an optional `meta` ([`serde_json::Value`]) -//! for HTTP `CacheMeta` round-trip through persistence; the ungated `core` -//! layer (built without `persist`) stays free of `serde_json`. +//! Returned by `*_with_policy` fetchers; a `Some` policy overrides the caller's +//! per-query policy. `meta` exists only under `persist` so core stays serde_json-free. use crate::core::policy::CachePolicy; #[cfg(feature = "persist")] use serde_json::Value as JsonValue; -/// A fetcher success carrying an optional server-derived cache policy. -/// -/// Return this from a `*_with_policy` fetcher to let the server override the -/// caller's [`CachePolicy`] for the resolved resource ("server wins"): -/// -/// - [`Fetched::new`] — no policy override; the resource keeps the caller's policy. -/// - [`Fetched::with_policy`] — override the resource's policy with the server's. -/// - [`Fetched::with_meta`] (`persist` feature) — attach opaque metadata that -/// flows into the persistence layer's `PersistedEntry::meta` so it can be -/// rehydrated on a cold start (e.g. an HTTP `CacheMeta` for cheap `304` -/// refetches after relaunch). -/// -/// `cache_policy: None` (the default) keeps the caller's per-query policy -/// unchanged, matching the plain `Result<T, E>` fetcher behavior exactly. +/// `cache_policy: None` keeps the caller's per-query policy, matching a plain +/// `Result<T, E>` fetcher. #[derive(Debug, Clone)] pub struct Fetched<T> { - /// The fetched data. pub data: T, - /// Server-derived cache policy. `None` keeps the caller's policy. + /// Server-derived policy; `None` keeps the caller's. pub cache_policy: Option<CachePolicy>, - /// Opaque metadata carried through to persistence (e.g. HTTP `CacheMeta`). - /// `None` unless set via [`Fetched::with_meta`]. Only present under the - /// `persist` feature so the ungated `core` layer stays `serde_json`-free. + /// Flows into `PersistedEntry::meta` for cold-start rehydration (e.g. HTTP `CacheMeta`). #[cfg(feature = "persist")] pub meta: Option<JsonValue>, } impl<T> Fetched<T> { - /// Wrap fetched data with **no** policy override (keep the caller's policy). - /// - /// Equivalent to returning the bare `T` from a plain `Result<T, E>` fetcher. pub fn new(data: T) -> Self { Self { data, @@ -55,8 +29,6 @@ impl<T> Fetched<T> { } } - /// Wrap fetched data and override the resource's cache policy with the - /// server's. pub fn with_policy(data: T, policy: CachePolicy) -> Self { Self { data, @@ -66,10 +38,6 @@ impl<T> Fetched<T> { } } - /// Attach opaque metadata (e.g. a serialized HTTP `CacheMeta`) to this - /// fetched value. Requires the `persist` feature; the metadata flows into - /// the persistence layer's `PersistedEntry::meta` when the resource is - /// persisted, enabling cold-start revalidation. #[cfg(feature = "persist")] pub fn with_meta(mut self, meta: JsonValue) -> Self { self.meta = Some(meta); diff --git a/crates/gpui-query/src/core/infinite_query/accessors.rs b/crates/gpui-query/src/core/infinite_query/accessors.rs index 9d0707b..55906b5 100644 --- a/crates/gpui-query/src/core/infinite_query/accessors.rs +++ b/crates/gpui-query/src/core/infinite_query/accessors.rs @@ -9,68 +9,41 @@ use crate::core::{ RetryPolicy, }; -// ── Accessors ──────────────────────────────────────────────────────────── - impl<T, E> InfiniteQueryResource<T, E> { - /// All loaded pages, in order from first to last. - /// - /// Pages are stored as `Arc<T>` so fetchers can receive a cheap - /// `Arc::clone` via [`first_page_arc`](Self::first_page_arc) / - /// [`last_page_arc`](Self::last_page_arc) instead of copying the page - /// data. Most call sites only need a `&T` view — use - /// [`first_page`](Self::first_page) / [`last_page`](Self::last_page), or - /// iterate with `.iter().map(|a| a.as_ref())`. - /// - /// **Note**: When `status()` is `Failure`, previously loaded pages are still - /// present and valid — the failure applies only to the most recent page fetch. - /// Use [`is_page_data_valid`](Self::is_page_data_valid) to check whether the - /// current page data can be relied upon. + /// On `Failure`, previously loaded pages remain present and valid; the + /// failure applies only to the most recent page fetch. pub fn pages(&self) -> &VecDeque<Arc<T>> { &self.pages } - /// Number of loaded pages. pub fn page_count(&self) -> usize { self.pages.len() } - /// The first loaded page, if any (borrowed view). pub fn first_page(&self) -> Option<&T> { self.pages.front().map(|a| a.as_ref()) } - /// The last loaded page, if any (borrowed view). pub fn last_page(&self) -> Option<&T> { self.pages.back().map(|a| a.as_ref()) } - /// Cheap `Arc::clone` of the first page, if any. - /// - /// Hand this to a `fetch_previous_page` fetcher instead of cloning the - /// full page data — only the refcount is bumped. pub fn first_page_arc(&self) -> Option<Arc<T>> { self.pages.front().cloned() } - /// Cheap `Arc::clone` of the last page, if any. - /// - /// Hand this to a `fetch_next_page` fetcher instead of cloning the full - /// page data — only the refcount is bumped. pub fn last_page_arc(&self) -> Option<Arc<T>> { self.pages.back().cloned() } - /// Whether there are more pages after the last loaded page. pub fn has_next_page(&self) -> bool { self.has_next_page } - /// Whether there are more pages before the first loaded page. pub fn has_previous_page(&self) -> bool { self.has_previous_page } - /// Whether a `fetch_next_page` request is in flight. pub fn is_fetching_next_page(&self) -> bool { matches!( self.fetching_direction, @@ -78,7 +51,6 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Whether a `fetch_previous_page` request is in flight. pub fn is_fetching_previous_page(&self) -> bool { matches!( self.fetching_direction, @@ -86,167 +58,112 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Maximum number of pages to retain. pub fn max_pages(&self) -> Option<usize> { self.max_pages } - /// The fetch direction mode for this query. - /// - /// Controls the default `has_next_page` / `has_previous_page` assumptions - /// after construction and after `reset()`. pub fn direction(&self) -> FetchDirection { self.direction } - /// Current status. pub fn status(&self) -> QueryStatus { self.status } - /// Most recent error. pub fn error(&self) -> Option<&E> { self.error.as_ref() } - /// Whether loading. pub fn is_loading(&self) -> bool { self.status.is_loading() } - /// Cache key. pub fn key(&self) -> &QueryKey { &self.key } - /// Active request id. pub fn active_request_id(&self) -> Option<RequestId> { self.active_request_id } - /// Cache policy. pub fn cache_policy(&self) -> CachePolicy { self.cache_policy } - /// Request policy. pub fn request_policy(&self) -> RequestPolicy { self.request_policy } - /// Set the cache policy. - /// - /// This allows policy updates on existing resources when the same key is - /// reused with different policies (e.g., a different TTL). pub fn set_cache_policy(&mut self, policy: CachePolicy) { self.cache_policy = policy; } - /// Set the request policy. - /// - /// This allows policy updates on existing resources when the same key is - /// reused with different request behavior. pub fn set_request_policy(&mut self, policy: RequestPolicy) { self.request_policy = policy; } - /// The retry policy for page fetches. pub fn retry_policy(&self) -> &RetryPolicy { &self.retry_policy } - /// Set the retry policy. - /// - /// Stored by `use_infinite_query` from its `retry_policy` option so that - /// fetch helpers can read it from the entity. + /// Stored by `use_infinite_query` from its option so fetch helpers can + /// read it back from the entity. pub fn set_retry_policy(&mut self, policy: RetryPolicy) { self.retry_policy = policy; } - /// When the current request started (ms). pub fn started_at_ms(&self) -> Option<u64> { self.started_at.map(QueryTimestamp::as_millis) } - /// When data was last updated (ms). pub fn last_updated_at_ms(&self) -> Option<u64> { self.last_updated_at.map(QueryTimestamp::as_millis) } - /// Cache age in milliseconds. - /// - /// Mirrors [`QueryResource::cache_age_ms`]: returns `None` when there is - /// no recorded `last_updated_at`, and `None` on clock skew (`now_ms` - /// before the recorded timestamp) via `checked_sub`. - /// - /// [`QueryResource::cache_age_ms`]: crate::core::QueryResource::cache_age_ms + /// `None` when nothing was recorded or on clock skew (`checked_sub`). pub fn cache_age_ms(&self, now_ms: u64) -> Option<u64> { QueryTimestamp::from(now_ms).elapsed_since(self.last_updated_at?) } - /// Total cache hits. pub fn cache_hits(&self) -> u64 { self.cache_hits } - /// Total cancelled requests. pub fn cancelled_count(&self) -> u64 { self.cancelled_count } - /// Total ignored results (completed requests whose ID no longer matched). - /// - /// Incremented when `complete_page_success` or `complete_page_failure` - /// receives a stale request ID, i.e. the result was produced by a fetch - /// that was subsequently replaced by a newer one. + /// Bumped when a completion arrives with a stale request id, i.e. the + /// fetch was replaced by a newer one before it finished. pub fn ignored_results(&self) -> u64 { self.ignored_results } - /// Number of retry attempts for the current page fetch. pub fn retry_count(&self) -> u32 { self.retry_count } - /// Increment the retry counter. pub fn increment_retry(&mut self) { self.retry_count = self.retry_count.saturating_add(1); } - /// Increment the ignored-results counter. - /// - /// Mirrors `QueryResource::mark_ignored_result` so the client layer's - /// bulk-cancel path can bump `ignored_results` for infinite queries the - /// same way it does for regular queries. + /// Mirrors `QueryResource::mark_ignored_result` for the client layer's + /// bulk-cancel path. pub fn mark_ignored_result(&mut self) { self.ignored_results = self.ignored_results.saturating_add(1); } - /// Reset the retry counter to zero. pub fn reset_retry_count(&mut self) { self.retry_count = 0; } - /// Whether any pages have been loaded. pub fn has_data(&self) -> bool { !self.pages.is_empty() } - /// Whether the currently loaded page data is valid. - /// - /// Returns `true` when: - /// - Status is `Success` (pages are up to date), or - /// - Status is `LoadingWithData` or `LoadingEmpty` (pages from a previous - /// successful fetch are still valid while a new page is being fetched). - /// - /// Returns `false` when: - /// - Status is `Idle` (no pages have been fetched yet), or - /// - Status is `Cancelled` (data was explicitly cleared). - /// - /// **Important**: When status is `Failure`, this returns `true` if pages were - /// previously loaded. A `Failure` status means the *last page fetch* failed, - /// but all previously loaded pages remain valid. This is distinct from - /// `QueryResource` where `Failure` invalidates the single data slot. + /// `Failure` returns `true` when pages were previously loaded: unlike + /// `QueryResource`, where `Failure` invalidates the single data slot, the + /// already-fetched pages stay valid. pub fn is_page_data_valid(&self) -> bool { match self.status { QueryStatus::Success | QueryStatus::LoadingWithData => true, @@ -255,7 +172,6 @@ impl<T, E> InfiniteQueryResource<T, E> { } } - /// Cancellation signal. pub fn signal(&self) -> Option<&QuerySignal> { self.signal.as_ref() } diff --git a/crates/gpui-query/src/core/infinite_query/lifecycle.rs b/crates/gpui-query/src/core/infinite_query/lifecycle.rs index 881a2b4..13b71fa 100644 --- a/crates/gpui-query/src/core/infinite_query/lifecycle.rs +++ b/crates/gpui-query/src/core/infinite_query/lifecycle.rs @@ -10,42 +10,23 @@ use crate::core::{ use super::FetchDirection; use super::InfiniteQueryResource; -/// Direction of an infinite-query page fetch, used internally to share logic -/// between the four `begin_fetch_*` entry points. -/// -/// `pub(super)` because it backs the serde-serialized `fetching_direction` -/// field on [`InfiniteQueryResource`]; a single `Option<PageDirection>` makes -/// the "only one direction in flight" invariant unrepresentable to violate. +/// Backs the serde-serialized `fetching_direction` field; a single +/// `Option<PageDirection>` makes the one-direction-in-flight invariant hold +/// by construction. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(super) enum PageDirection { Next, Previous, } -/// Source of the [`RequestId`] for [`InfiniteQueryResource::begin_fetch`]: -/// a caller-supplied sequencer, or an optional pre-generated id with a -/// per-resource fallback. The sequencer variant only calls `next_request()` -/// after the early-return guards, so guards never consume a sequence number. enum MaybeRequestId<'a> { FromSequencer(&'a mut RequestSequencer), Provided(Option<RequestId>), } impl<T, E> InfiniteQueryResource<T, E> { - /// Begin fetching the next page. - /// - /// Cancels any in-flight request's signal before starting the new one. - /// - /// Under `RequestPolicy::LatestWins`, this replaces an active - /// `begin_fetch_previous` request: the old signal is cancelled and the - /// previous-page result is discarded by `complete_page_success` (it - /// returns `false` for stale IDs). Callers can check - /// `is_fetching_next_page()` / `is_fetching_previous_page()` before - /// completing if they need to detect direction changes. - /// - /// The `IgnoreWhileLoading` guard only applies within the same direction: - /// a next-page fetch while a previous-page fetch is active bypasses the - /// guard and replaces it (and vice versa). + /// Under `LatestWins` this replaces an in-flight request in either + /// direction; `IgnoreWhileLoading` only guards within the same direction. pub fn begin_fetch_next( &mut self, sequencer: &mut RequestSequencer, @@ -58,20 +39,8 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Begin fetching the previous page. - /// - /// Cancels any in-flight request's signal before starting the new one. - /// - /// Under `RequestPolicy::LatestWins`, this replaces an active - /// `begin_fetch_next` request: the old signal is cancelled and the - /// next-page result is discarded by `complete_page_success` (it returns - /// `false` for stale IDs). Callers can check - /// `is_fetching_next_page()` / `is_fetching_previous_page()` before - /// completing if they need to detect direction changes. - /// - /// The `IgnoreWhileLoading` guard only applies within the same direction: - /// a previous-page fetch while a next-page fetch is active bypasses the - /// guard and replaces it (and vice versa). + /// Mirror of [`begin_fetch_next`](Self::begin_fetch_next) for the + /// previous direction. pub fn begin_fetch_previous( &mut self, sequencer: &mut RequestSequencer, @@ -84,15 +53,9 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Like [`begin_fetch_next`](Self::begin_fetch_next) but accepts an optional - /// pre-generated `RequestId` instead of a `RequestSequencer`. - /// - /// When `maybe_request_id` is `Some`, uses that ID directly — the - /// preferred call when the bucket's co-located sequencer has already - /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`, - /// so the resource's `active_request_id` matches the id the bucket already - /// consumed. When `None`, falls back to the resource's own stored - /// sequencer. + /// `Some(id)` is used as-is, matching ids the bucket pre-allocated via + /// `QueryClient::next_request_id_for_infinite_key`; `None` falls back to + /// the resource's own sequencer. pub fn begin_fetch_next_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -105,15 +68,7 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Like [`begin_fetch_previous`](Self::begin_fetch_previous) but accepts - /// an optional pre-generated `RequestId` instead of a `RequestSequencer`. - /// - /// When `maybe_request_id` is `Some`, uses that ID directly — the - /// preferred call when the bucket's co-located sequencer has already - /// pre-allocated an ID via `QueryClient::next_request_id_for_infinite_key`, - /// so the resource's `active_request_id` matches the id the bucket already - /// consumed. When `None`, falls back to the resource's own stored - /// sequencer. + /// See [`begin_fetch_next_with_id`](Self::begin_fetch_next_with_id). pub fn begin_fetch_previous_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -126,7 +81,6 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Shared implementation behind the four `begin_fetch_*` entry points. fn begin_fetch( &mut self, direction: PageDirection, @@ -152,7 +106,6 @@ impl<T, E> InfiniteQueryResource<T, E> { self.cancelled_count = self.cancelled_count.saturating_add(1); } - // Cancel old signal before replacing. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } @@ -178,11 +131,8 @@ impl<T, E> InfiniteQueryResource<T, E> { Some(request_id) } - /// Accept the current request for two-phase completion. - /// - /// Returns a [`RequestGuard`] if the request is still active, or `None` - /// if it was replaced or cancelled. A stale/replaced request's result is - /// ignored, bumping `ignored_results`. + /// `None` when the request was replaced or cancelled; stale completions + /// bump `ignored_results`. pub fn accept_current_request(&mut self, request_id: RequestId) -> Option<RequestGuard> { if self.is_current_request(request_id) { self.active_request_id = None; @@ -193,12 +143,8 @@ impl<T, E> InfiniteQueryResource<T, E> { } } - /// Complete a page fetch with success using a guard (two-phase protocol). - /// - /// Appends (`is_next`) or prepends the page in O(1) amortized. Pages - /// evicted by the `max_pages` bound are dropped here (refcounts release, - /// nothing leaks); the `append_page`/`prepend_page` methods are the - /// variants that return evicted pages. + /// Pages evicted by the `max_pages` bound are dropped here; + /// `append_page`/`prepend_page` are the variants that return them. pub fn complete_success_with_guard( &mut self, _guard: RequestGuard, @@ -224,12 +170,8 @@ impl<T, E> InfiniteQueryResource<T, E> { self.signal = None; } - /// Complete a page fetch with failure using a guard (two-phase protocol). - /// - /// Does NOT clear previously loaded pages: `Failure` means the last page - /// fetch failed, but loaded pages remain accessible via - /// [`pages`](Self::pages). Use [`is_page_data_valid`](Self::is_page_data_valid) - /// to check whether the page data can be relied upon. + /// Previously loaded pages are NOT cleared; see + /// [`is_page_data_valid`](Self::is_page_data_valid). pub fn complete_failure_with_guard(&mut self, _guard: RequestGuard, error: E) { self.status = QueryStatus::Failure; self.error = Some(error); @@ -237,12 +179,7 @@ impl<T, E> InfiniteQueryResource<T, E> { self.signal = None; } - /// Complete a page fetch with success. - /// - /// Convenience method that accepts and completes in one call. Appends - /// (`is_next`) or prepends the page in O(1) amortized; pages evicted by - /// the `max_pages` bound are dropped (see - /// [`complete_success_with_guard`](Self::complete_success_with_guard)). + /// Accept-and-complete in one call; evicted pages are dropped. pub fn complete_page_success( &mut self, request_id: RequestId, @@ -276,13 +213,7 @@ impl<T, E> InfiniteQueryResource<T, E> { true } - /// Complete a page fetch with failure. - /// - /// Convenience method that accepts and completes in one call. Does NOT - /// clear previously loaded pages: `Failure` applies to the most recent - /// page fetch attempt only. Use - /// [`is_page_data_valid()`](Self::is_page_data_valid) to check whether - /// the page data can be relied upon. + /// Accept-and-complete in one call; loaded pages are NOT cleared. pub fn complete_page_failure(&mut self, request_id: RequestId, error: E) -> bool { if self.active_request_id != Some(request_id) { self.ignored_results = self.ignored_results.saturating_add(1); @@ -298,19 +229,13 @@ impl<T, E> InfiniteQueryResource<T, E> { true } - /// Whether the given request id is the current active request. pub fn is_current_request(&self, request_id: RequestId) -> bool { self.active_request_id == Some(request_id) } - /// Reset to idle, clearing everything. - /// - /// `max_pages` and `direction` are preserved across resets. - /// `has_next_page` / `has_previous_page` are reset to the defaults of the - /// current [`FetchDirection`] (`ForwardOnly` → `true`/`false`, - /// `Bidirectional` → both `false`). If the resource was previously - /// exhausted, set the flags again after reset if the direction-based - /// defaults are wrong. + /// `max_pages` and `direction` persist; the flags reset to the current + /// direction's defaults, so re-set them after reset if the query was + /// previously exhausted. pub fn reset(&mut self) { if let Some(signal) = self.signal.as_ref() { signal.cancel(); @@ -335,7 +260,6 @@ impl<T, E> InfiniteQueryResource<T, E> { self.signal = None; } - /// Invalidate the cache (clear last-updated timestamp). pub fn invalidate(&mut self) { self.last_updated_at = None; } diff --git a/crates/gpui-query/src/core/infinite_query/mod.rs b/crates/gpui-query/src/core/infinite_query/mod.rs index 876aff7..6ab6077 100644 --- a/crates/gpui-query/src/core/infinite_query/mod.rs +++ b/crates/gpui-query/src/core/infinite_query/mod.rs @@ -1,11 +1,8 @@ //! Infinite query resource for managing paginated data. //! -//! Page storage is a `VecDeque<Arc<T>>`, so appending and prepending pages -//! are both O(1) amortized. A bounded `max_pages` (default 50) evicts the -//! oldest pages on the opposite side of a push; `set_max_pages(Some(0))` is -//! treated as unbounded. [`FetchDirection`] controls the default -//! `has_next_page` / `has_previous_page` assumptions on construction and -//! after `reset()`. +//! Pages live in a `VecDeque<Arc<T>>` (O(1) append/prepend); a bounded +//! `max_pages` (default 50) evicts from the opposite side, and +//! `set_max_pages(Some(0))` means unbounded. mod accessors; mod lifecycle; @@ -127,8 +124,6 @@ mod tests { assert!(back.signal().is_none()); } - // ── Two-phase protocol tests ──────────────────────────────────────── - #[test] fn accept_current_request_returns_guard_for_active_request() { let mut r = make_resource(); @@ -137,7 +132,7 @@ mod tests { let guard = r.accept_current_request(id); assert!(guard.is_some()); - assert_eq!(r.active_request_id(), None); // cleared on accept + assert_eq!(r.active_request_id(), None); } #[test] @@ -169,23 +164,19 @@ mod tests { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Load one page successfully let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id1, vec!["page1".to_string()], true, true, 2_000); assert_eq!(r.page_count(), 1); - // Attempt next page but fail — using two-phase protocol let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); let guard = r.accept_current_request(id2).unwrap(); r.complete_failure_with_guard(guard, "network error".into()); assert_eq!(r.status(), QueryStatus::Failure); - assert_eq!(r.page_count(), 1); // pages preserved + assert_eq!(r.page_count(), 1); assert!(r.is_page_data_valid()); } - // ── is_page_data_valid tests ──────────────────────────────────────── - #[test] fn is_page_data_valid_idle_no_pages() { let r = make_resource(); @@ -206,15 +197,12 @@ mod tests { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Load a page let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id1, vec!["page1".to_string()], true, true, 2_000); - // Fail next fetch let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); r.complete_page_failure(id2, "network error".into()); - // Pages are still valid despite failure assert!(r.is_page_data_valid()); assert_eq!(r.first_page(), Some(&vec!["page1".to_string()])); } @@ -224,7 +212,6 @@ mod tests { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Fail without ever loading a page let id = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_failure(id, "network error".into()); @@ -236,7 +223,6 @@ mod tests { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Load 3 pages let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id1, vec!["a".to_string()], true, true, 2_000); let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); @@ -244,7 +230,6 @@ mod tests { let id3 = r.begin_fetch_next(&mut seq, 5_000).unwrap(); r.complete_page_success(id3, vec!["c".to_string()], true, true, 6_000); - // Setting max_pages to 0 is treated as None (unbounded) — no pages evicted r.set_max_pages(Some(0)); assert_eq!(r.max_pages(), None); assert_eq!(r.page_count(), 3); @@ -297,7 +282,6 @@ mod tests { assert_eq!(evicted.len(), 1); assert_eq!(evicted[0].as_ref(), &vec!["a".to_string()]); assert_eq!(r.page_count(), 2); - // c, b are the remaining pages (c was prepended most recently) assert_eq!(r.first_page(), Some(&vec!["c".to_string()])); } @@ -309,13 +293,11 @@ mod tests { let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); let id2 = r.begin_fetch_next(&mut seq, 2_000).unwrap(); - // id1 is stale — complete_page_success should increment ignored_results assert!(!r.complete_page_success(id1, vec!["stale".to_string()], true, true, 3_000)); assert_eq!(r.ignored_results(), 1); - // id2 succeeds assert!(r.complete_page_success(id2, vec!["fresh".to_string()], false, true, 3_000)); - assert_eq!(r.ignored_results(), 1); // no increment for successful completion + assert_eq!(r.ignored_results(), 1); } #[test] @@ -349,7 +331,6 @@ mod tests { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Load data and change max_pages let id = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id, vec!["page1".to_string()], true, true, 2_000); r.set_max_pages(Some(10)); @@ -358,13 +339,10 @@ mod tests { r.reset(); - // max_pages preserved assert_eq!(r.max_pages(), Some(10)); - // diagnostics cleared assert_eq!(r.retry_count(), 0); assert_eq!(r.ignored_results(), 0); assert_eq!(r.cancelled_count(), 0); - // has_next_page reset to ForwardOnly default (true) assert!(r.has_next_page()); } @@ -388,7 +366,6 @@ mod tests { fn bidirectional_begin_fetch_next_returns_none_without_opt_in() { let mut r = make_bidirectional_resource(); let mut seq = RequestSequencer::new(); - // has_next_page is false by default for bidirectional — fetch should be rejected let id = r.begin_fetch_next(&mut seq, 1_000); assert!(id.is_none()); } @@ -411,7 +388,6 @@ mod tests { r.complete_page_success(id, vec!["page1".to_string()], true, true, 2_000); r.reset(); - // Bidirectional: both reset to false assert!(!r.has_next_page()); assert!(!r.has_previous_page()); assert_eq!(r.direction(), FetchDirection::Bidirectional); @@ -420,12 +396,9 @@ mod tests { #[test] fn set_direction_changes_reset_behavior() { let mut r = make_resource(); - // Start as ForwardOnly assert!(r.has_next_page()); - // Switch to Bidirectional r.set_direction(FetchDirection::Bidirectional); r.reset(); - // Now reset uses Bidirectional defaults assert!(!r.has_next_page()); assert!(!r.has_previous_page()); } @@ -441,7 +414,6 @@ mod tests { let json = serde_json::to_string(&r).unwrap(); - // Verify the wire format is a plain array (backward compatible with old Vec format) assert!(json.contains("\"pages\":[")); assert!(!json.contains("VecDeque")); diff --git a/crates/gpui-query/src/core/infinite_query/page_management.rs b/crates/gpui-query/src/core/infinite_query/page_management.rs index b53bd04..5b43f68 100644 --- a/crates/gpui-query/src/core/infinite_query/page_management.rs +++ b/crates/gpui-query/src/core/infinite_query/page_management.rs @@ -4,35 +4,22 @@ use std::sync::Arc; use super::{FetchDirection, InfiniteQueryResource}; -// ── Page management ───────────────────────────────────────────────────── - impl<T, E> InfiniteQueryResource<T, E> { - /// Set whether more pages are available after the last loaded page. pub fn set_has_next_page(&mut self, has_next: bool) { self.has_next_page = has_next; } - /// Set whether more pages are available before the first loaded page. pub fn set_has_previous_page(&mut self, has_prev: bool) { self.has_previous_page = has_prev; } - /// Set the fetch direction mode. - /// - /// This does not change the current `has_next_page` / `has_previous_page` - /// flags — it only affects what `reset()` restores them to. + /// Only changes what `reset()` restores the flags to; current flags stay. pub fn set_direction(&mut self, direction: FetchDirection) { self.direction = direction; } - /// Set the maximum number of pages to retain. - /// - /// A value of `Some(0)` is treated as unbounded (`None`) to prevent - /// accidentally draining all pages. Callers that want no page retention - /// should use `reset()` instead. - /// - /// Returns evicted pages (if any) as `Arc<T>` handles so the caller can - /// log or process them without cloning the page data. + /// `Some(0)` is treated as unbounded (`None`) so a zero cannot drain all + /// pages. Returns evicted pages (if any) as `Arc` handles. pub fn set_max_pages(&mut self, max: Option<usize>) -> Vec<Arc<T>> { self.max_pages = match max { Some(0) => None, @@ -41,26 +28,19 @@ impl<T, E> InfiniteQueryResource<T, E> { self.enforce_max_pages_remove_front() } - /// Append a page to the end. - /// - /// Returns evicted pages (if any) as `Arc<T>` handles. + /// Returns evicted pages (if any) as `Arc` handles. pub fn append_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_back(Arc::new(page)); self.enforce_max_pages_remove_front() } - /// Prepend a page to the beginning. - /// - /// Returns evicted pages (if any) as `Arc<T>` handles. + /// Returns evicted pages (if any) as `Arc` handles. pub fn prepend_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_front(Arc::new(page)); self.enforce_max_pages_remove_back() } - /// Evict pages from the front until within `max_pages`. - /// - /// At least 1 page is always retained. O(k) where k is the number of - /// evicted pages. + /// At least 1 page is always retained. pub(super) fn enforce_max_pages_remove_front(&mut self) -> Vec<Arc<T>> { if let Some(max) = self.max_pages && max > 0 @@ -71,13 +51,9 @@ impl<T, E> InfiniteQueryResource<T, E> { Vec::new() } - /// Evict pages from the back until within `max_pages`. - /// - /// At least 1 page is always retained. The survivors are the first `max` - /// pages (indices `0..max`), so the drain starts at `max` — not at - /// `len - max`, which would leave too few behind. Drained in reverse so - /// the returned vector preserves back-to-front eviction order - /// (most-recently-prepended page first) without a separate reverse pass. + /// Survivors are the first `max` pages, so the drain starts at `max`, not + /// `len - max`. Drained in reverse so the returned vector preserves + /// most-recently-prepended-first eviction order without a second pass. pub(super) fn enforce_max_pages_remove_back(&mut self) -> Vec<Arc<T>> { if let Some(max) = self.max_pages && max > 0 diff --git a/crates/gpui-query/src/core/infinite_query/resource.rs b/crates/gpui-query/src/core/infinite_query/resource.rs index 9bc27d6..402e192 100644 --- a/crates/gpui-query/src/core/infinite_query/resource.rs +++ b/crates/gpui-query/src/core/infinite_query/resource.rs @@ -1,4 +1,4 @@ -//! Struct definition, serde helpers, constants, and constructors for +//! Struct definition, serde helpers, and constructors for //! [`InfiniteQueryResource`]. use std::collections::VecDeque; @@ -11,36 +11,20 @@ use crate::core::{ RequestPolicy, RequestSequencer, RetryPolicy, }; -/// Default maximum number of pages to retain. const DEFAULT_MAX_PAGES: usize = 50; -/// Direction mode for an infinite query. -/// -/// Controls the default assumptions for `has_next_page` and -/// `has_previous_page` on construction and after `reset()`: -/// -/// - **ForwardOnly** (default): `has_next_page` starts `true` (feed-style -/// pagination assumes more pages exist until the fetcher says otherwise), -/// `has_previous_page` starts `false`. -/// - **Bidirectional**: both start `false`; the query fetches nothing until -/// the caller sets a flag or the fetcher returns `has_more = true`. +/// `ForwardOnly` (default) starts `has_next_page = true`; `Bidirectional` +/// starts both flags `false`, so nothing is fetched until the caller or +/// fetcher opts in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum FetchDirection { - /// Fetch next pages only. `has_next_page` defaults to `true`. #[default] ForwardOnly, - /// Fetch in both directions. Both flags default to `false`. Bidirectional, } -/// An infinite query resource that manages paginated data. -/// -/// Inspired by TanStack Query's `useInfiniteQuery`. Each "page" is a `T` — -/// typically a batch of items fetched from an API. -/// -/// Pages are stored internally as `Arc<T>` so that [`last_page_arc`](Self::last_page_arc) -/// and [`first_page_arc`](Self::first_page_arc) can hand the fetcher a cheap -/// `Arc::clone` instead of cloning the full page data. +/// Pages are stored as `Arc<T>` so the `*_arc` accessors can hand the fetcher +/// a cheap clone instead of copying the page. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound(serialize = "T: serde::Serialize, E: serde::Serialize"))] #[serde(bound(deserialize = "T: serde::de::DeserializeOwned, E: serde::de::DeserializeOwned"))] @@ -61,17 +45,14 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(super) retry_count: u32, pub(super) has_next_page: bool, pub(super) has_previous_page: bool, - /// Which direction (if any) is currently being fetched. A single - /// `Option<PageDirection>` (instead of two booleans) makes the - /// "only one direction in flight" invariant hold by construction. + /// One `Option` instead of two booleans: "only one direction in flight" + /// holds by construction. pub(super) fetching_direction: Option<super::lifecycle::PageDirection>, pub(super) max_pages: Option<usize>, pub(super) direction: FetchDirection, pub(super) retry_policy: RetryPolicy, - /// Per-resource sequencer used by the `_with_id` fetch entry points when - /// no external id is supplied, so callers without a `QueryClient` still - /// get monotonic, collision-free ids. `#[serde(skip)]` — runtime state, - /// not persisted. + /// Runtime state, not persisted; supplies monotonic ids when no external + /// sequencer is provided. #[serde(skip)] pub(super) transient_sequencer: RequestSequencer, #[serde(skip)] @@ -81,10 +62,8 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(crate) current_task: crate::core::current_task::CurrentTask, } -/// Serde helpers for `VecDeque<Arc<T>>`: serialize as a plain sequence, -/// deserialize from a plain sequence. The wire format stays identical to the -/// old `Vec<T>` representation (`Arc<T>` serializes transparently as `T`), -/// so previously cached data remains readable. +/// The wire format is a plain sequence, identical to the old `Vec<T>` +/// representation, so previously persisted data stays readable. pub(super) mod vec_deque_serde { use std::collections::VecDeque; use std::sync::Arc; @@ -100,8 +79,7 @@ pub(super) mod vec_deque_serde { { let mut seq = serializer.serialize_seq(Some(deque.len()))?; for item in deque { - // Serialize the inner `T` directly rather than the `Arc<T>`: this - // avoids requiring `Arc<T>: Serialize` (serde's `rc` feature). + // Serialize the inner T: requiring `Arc<T>: Serialize` would need serde's `rc` feature. seq.serialize_element(&**item)?; } seq.end() @@ -118,12 +96,8 @@ pub(super) mod vec_deque_serde { } impl<T, E> InfiniteQueryResource<T, E> { - /// Create a new infinite query resource. - /// - /// `max_pages` defaults to `Some(50)` to bound memory growth, and the - /// direction is [`FetchDirection::ForwardOnly`], so `has_next_page` - /// starts `true`. Use [`new_bidirectional`](Self::new_bidirectional) for - /// queries that paginate in both directions. + /// `max_pages` defaults to `Some(50)`; ForwardOnly, so `has_next_page` + /// starts `true`. pub fn new( key: impl Into<QueryKey>, cache_policy: CachePolicy, @@ -137,11 +111,8 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Create a new infinite query resource configured for bidirectional paging. - /// - /// Both `has_next_page` and `has_previous_page` default to `false`. The - /// query will not attempt to fetch in either direction until the caller - /// explicitly enables it. + /// Both flags start `false`; the query fetches nothing until the caller + /// explicitly enables a direction. pub fn new_bidirectional( key: impl Into<QueryKey>, cache_policy: CachePolicy, @@ -155,7 +126,6 @@ impl<T, E> InfiniteQueryResource<T, E> { ) } - /// Create a new infinite query resource with an explicit [`FetchDirection`]. pub(crate) fn with_direction( key: impl Into<QueryKey>, cache_policy: CachePolicy, @@ -196,7 +166,6 @@ impl<T, E> InfiniteQueryResource<T, E> { #[cfg(feature = "client")] impl<T, E> InfiniteQueryResource<T, E> { - /// Store a new background task, cancelling any previously stored task. pub(crate) fn set_current_task(&mut self, task: gpui::Task<()>) { self.current_task.set(task); } diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 6c5c1b3..0e333dd 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -3,22 +3,9 @@ use std::sync::Arc; use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq}; -/// A structured, hierarchical cache key for query resources. -/// -/// Inspired by TanStack Query's array keys (`["todos", id]`), a `QueryKey` -/// is an ordered sequence of string segments. -/// -/// # Cheap cloning -/// -/// Internally uses `Arc<[Arc<str>]>`, so cloning is a single atomic ref-count -/// increment regardless of key length. -/// -/// # Invariants -/// -/// A `QueryKey` must contain at least one segment. Zero-length keys are -/// unsupported: constructing one via [`QueryKey::new`] with an empty iterator -/// will panic unconditionally in all build modes. Deserializing an empty key -/// array returns a serde error rather than panicking. +/// Hierarchical key (`["todos", id]` style). Must contain at least one +/// segment: [`QueryKey::new`](Self::new) panics on empty, serde returns an +/// error instead. Cloning is one refcount bump (`Arc<[Arc<str>]>`). /// /// # Examples /// @@ -33,11 +20,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq} pub struct QueryKey(Arc<[Arc<str>]>); impl QueryKey { - /// Create a key from an iterator of string-like parts. - /// /// # Panics /// - /// Panics if the iterator yields zero segments. + /// If the iterator yields zero segments. pub fn new(parts: impl IntoIterator<Item: AsRef<str>>) -> Self { let segments: Vec<Arc<str>> = parts.into_iter().map(|s| Arc::from(s.as_ref())).collect(); assert!( @@ -47,19 +32,16 @@ impl QueryKey { Self(segments.into()) } - /// Create a single-segment key from a string. #[must_use] pub fn from_single(value: impl AsRef<str>) -> Self { Self(Arc::from([Arc::from(value.as_ref())])) } - /// The key segments. #[must_use] pub fn parts(&self) -> &[Arc<str>] { &self.0 } - /// Returns the single segment if this key has exactly one part, else `None`. #[must_use] pub fn as_single(&self) -> Option<&str> { if self.0.len() == 1 { @@ -69,12 +51,6 @@ impl QueryKey { } } - /// Returns the first segment as a string slice. - /// - /// This yields only the **first** segment of the key, not the full key. - /// For a joined representation of the entire key, use [`QueryKey::to_path`]; - /// for the single segment when the key has exactly one part, use - /// [`QueryKey::as_single`]. #[must_use] pub fn first_segment(&self) -> &str { match self.0.first() { @@ -83,10 +59,7 @@ impl QueryKey { } } - /// Returns the full key as a double-colon-separated path string. - /// - /// Useful for diagnostics and DevTools display. Uses `"::"` as the - /// separator so segments containing forward slashes stay unambiguous. + /// Joins with `"::"` so segments containing forward slashes stay unambiguous. pub fn to_path(&self) -> String { const SEP: &str = "::"; let len = self.0.iter().map(|s| s.len()).sum::<usize>() @@ -101,26 +74,16 @@ impl QueryKey { path } - /// Returns `true` if this key starts with the given `prefix`. - /// - /// If `prefix` is empty (zero segments), returns `true` — an empty - /// prefix matches every valid key. If `self` is also empty, returns - /// `false`, since zero-length keys are unsupported. + /// An empty `prefix` matches every valid key; an empty `self` never matches. pub fn starts_with(&self, prefix: &QueryKey) -> bool { if prefix.0.is_empty() { - // An empty prefix matches every valid (non-empty) key. return !self.0.is_empty(); } self.0.starts_with(&prefix.0) } - /// Create a new key by appending an extra segment. - /// - /// This is **O(n)** in key length because it copies all existing segments - /// into a new `Arc<[Arc<str>]>`. For hot paths that build keys incrementally, - /// prefer constructing the full key in one [`QueryKey::new`] call rather than - /// chaining `.join()` calls (e.g., prefer `QueryKey::new(["a", "b", "c"])` - /// over `QueryKey::from("a").join("b").join("c")`). + /// O(n) copy of all segments; prefer building the full key in one + /// [`new`](Self::new) call over chaining `join`s. pub fn join(&self, extra: &str) -> QueryKey { let mut parts: Vec<Arc<str>> = self.0.to_vec(); parts.push(Arc::from(extra)); @@ -136,8 +99,6 @@ impl Deref for QueryKey { } } -// ── From impls ────────────────────────────────────────────────────────── - impl From<&str> for QueryKey { fn from(value: &str) -> Self { Self::from_single(value) @@ -168,8 +129,6 @@ impl From<Vec<String>> for QueryKey { } } -// ── Serde ─────────────────────────────────────────────────────────────── - impl Serialize for QueryKey { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { let mut seq = serializer.serialize_seq(Some(self.0.len()))?; @@ -190,8 +149,7 @@ impl<'de> Deserialize<'de> for QueryKey { } match KeyRepr::deserialize(deserializer)? { - // An empty array must surface as an Err, never reach new()'s - // assert: deserializing untrusted blobs cannot panic. + // Deserializing untrusted input cannot panic, so empty must Err here. KeyRepr::Array(parts) if !parts.is_empty() => Ok(Self::new(parts)), KeyRepr::Array(_) => Err(serde::de::Error::custom( "QueryKey must contain at least one segment", @@ -282,14 +240,12 @@ mod tests { #[test] fn starts_with_empty_prefix_matches_valid_key() { let key = QueryKey::from(["users", "42"]); - // Bypass QueryKey::new to create an empty key for testing starts_with. let empty = QueryKey(Arc::from([] as [Arc<str>; 0])); assert!(key.starts_with(&empty)); } #[test] fn starts_with_empty_prefix_does_not_match_empty_key() { - // Bypass QueryKey::new to create an empty key for testing starts_with. let empty = QueryKey(Arc::from([] as [Arc<str>; 0])); assert!(!empty.starts_with(&empty)); } diff --git a/crates/gpui-query/src/core/key_filter.rs b/crates/gpui-query/src/core/key_filter.rs index 5756320..75c9516 100644 --- a/crates/gpui-query/src/core/key_filter.rs +++ b/crates/gpui-query/src/core/key_filter.rs @@ -1,19 +1,14 @@ use super::QueryKey; -/// A filter for matching query keys, used by bulk operations like -/// `invalidate_queries`. +/// Key matcher for bulk operations like `invalidate_queries`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueryKeyFilter<'a> { - /// Match only the key that is exactly equal. Exact(&'a QueryKey), - /// Match all keys that start with the given prefix. Prefix(&'a QueryKey), - /// Match every key. All, } impl<'a> QueryKeyFilter<'a> { - /// Returns `true` if the given key matches this filter. pub fn matches(&self, key: &QueryKey) -> bool { match self { Self::Exact(k) => key == *k, diff --git a/crates/gpui-query/src/core/mod.rs b/crates/gpui-query/src/core/mod.rs index d40472a..52dac69 100644 --- a/crates/gpui-query/src/core/mod.rs +++ b/crates/gpui-query/src/core/mod.rs @@ -1,35 +1,7 @@ -//! Layer 0: Transport-agnostic query lifecycle primitives. +//! Layer 0: transport-agnostic query lifecycle primitives, serde-only. //! -//! `QueryResource` owns the cache/request state for one resource. Callers start -//! work with `begin_request`, then complete it with the returned `RequestId`. -//! Completion methods reject stale request ids, so cancelled or replaced async -//! work cannot overwrite newer state. -//! -//! # Request lifecycle -//! -//! The typical lifecycle for a single query fetch is: -//! -//! 1. **Begin**: Call [`QueryResource::begin_request`] with a [`RequestSequencer`]. -//! This returns a [`QueryBeginResult`] indicating whether a fetch is needed, -//! the cache was hit, or the request was ignored. -//! -//! 2. **Fetch**: If the result is [`Started`](QueryBeginResult::Started) or -//! [`StaleCacheHit`](QueryBeginResult::StaleCacheHit), start an async fetch -//! using the returned [`RequestId`]. -//! -//! 3. **Accept**: When the fetch completes, call -//! [`QueryResource::accept_current_request`] with the `RequestId`. If the -//! request is still active (not replaced or cancelled), this returns a -//! [`RequestGuard`] — a single-use capability token. -//! -//! 4. **Complete**: Pass the [`RequestGuard`] (by value) to -//! [`QueryResource::complete_success`] or [`QueryResource::complete_failure`]. -//! The guard is consumed, preventing accidental double-completion. -//! -//! Alternatively, use the convenience methods [`QueryResource::complete_current_success`] -//! or [`QueryResource::complete_current_failure`] which combine steps 3 and 4. -//! -//! This module depends only on `serde` — zero framework coupling. +//! Fetch protocol: `begin_request` → `accept_current_request` (returns a +//! single-use `RequestGuard`) → `complete_success`/`complete_failure`. mod error; mod fetched; @@ -63,13 +35,12 @@ pub use select::{MappedQueryResource, SelectTransform}; pub use signal::QuerySignal; pub use status::QueryStatus; -// `gpui::Task<T>` is `Debug` but not `Clone`/`PartialEq`/`Eq`, and several -// resource structs derive those. `CurrentTask` is a newtype that restores the -// derives: `Clone` yields an empty handle (the original task keeps running), -// all instances compare equal, and dropping the inner `Task` cancels it -// (gpui semantics), so `set` replaces and aborts the previous task. #[cfg(feature = "client")] mod current_task { + //! `gpui::Task<T>` is Debug but not Clone/PartialEq/Eq, and several + //! resource structs derive those. This newtype restores the derives: + //! Clone yields an empty handle, all instances compare equal, and Drop + //! aborts the task (gpui semantics), so `set` replaces and aborts. use gpui::Task; #[derive(Debug, Default)] diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 84f4f9e..51d4734 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -4,22 +4,16 @@ use serde::{Deserialize, Serialize}; use super::{QueryError, QueryKey, QuerySignal, RetryPolicy}; -/// Status of a mutation operation. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum MutationStatus { - /// No mutation has been started yet. #[default] Idle, - /// Mutation is in progress. Loading, - /// Mutation completed successfully. Success, - /// Mutation failed. Failure, } impl MutationStatus { - /// Human-readable label. pub fn label(self) -> &'static str { match self { Self::Idle => "Idle", @@ -29,31 +23,24 @@ impl MutationStatus { } } - /// Whether the mutation is currently loading. pub fn is_loading(self) -> bool { matches!(self, Self::Loading) } - /// Whether the mutation is idle. pub fn is_idle(self) -> bool { matches!(self, Self::Idle) } - /// Whether the mutation succeeded. pub fn is_success(self) -> bool { matches!(self, Self::Success) } - /// Whether the mutation failed. pub fn is_failure(self) -> bool { matches!(self, Self::Failure) } } -/// A mutation resource that tracks the state of a single mutation. -/// -/// `V` is the variables (input) type, `T` is the success output type, -/// and `E` is the error type. +/// `V` is the variables (input) type, `T` the success output, `E` the error. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MutationResource<V, T, E = QueryError> { key: Option<QueryKey>, @@ -64,26 +51,20 @@ pub struct MutationResource<V, T, E = QueryError> { retry_count: u32, cancelled_count: u64, retry_policy: RetryPolicy, - /// Wall-clock ms of the most recent terminal completion (success/failure); - /// `None` until the mutation first completes. Read by `MutationBucket`'s GC - /// so recency is measured from completion time, not insertion time. - /// `#[serde(skip)]` — runtime state, not persisted. + /// Terminal-completion time (not insertion time) drives `MutationBucket` + /// GC recency; runtime state, not persisted. #[serde(skip)] last_updated_at_ms: Option<u64>, #[serde(skip)] signal: Option<QuerySignal>, - /// In-flight background mutation task. Stored so that a replacement - /// mutation or entity drop (component unmount) aborts the prior in-flight - /// task instead of leaving it detached. `#[cfg(feature = "client")]` - /// because `gpui::Task` is only available with the client feature. + /// Stored so a replacement mutation or entity drop aborts the prior + /// in-flight task instead of leaving it detached. #[cfg(feature = "client")] #[serde(skip)] pub(crate) current_task: crate::core::current_task::CurrentTask, } -/// Current wall-clock ms since the Unix epoch, clamped to 0 if the system -/// clock is before the epoch (mirrors the client/hook `current_time_ms`). Used -/// to stamp `MutationResource::last_updated_at_ms` on terminal completion. +/// Clamps to 0 when the system clock is before the epoch. fn completion_now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -92,7 +73,6 @@ fn completion_now_ms() -> u64 { } impl<V, T, E> MutationResource<V, T, E> { - /// Create a new mutation resource with the given retry policy. pub fn new(retry_policy: RetryPolicy) -> Self { Self { key: None, @@ -110,102 +90,74 @@ impl<V, T, E> MutationResource<V, T, E> { } } - /// Current status. pub fn status(&self) -> MutationStatus { self.status } - /// Wall-clock ms of the most recent terminal completion, or `None` if the - /// mutation has never completed. Used by `MutationBucket` GC to measure - /// recency from completion time rather than insertion time. - // Only the `client` layer reads this accessor; core-only builds (e.g. - // wasm32 core) have no caller yet, so silence dead_code there. + // Read only by the client layer; core-only builds (e.g. wasm32) have no caller. #[cfg_attr(not(feature = "client"), allow(dead_code))] pub(crate) fn last_updated_at_ms(&self) -> Option<u64> { self.last_updated_at_ms } - /// Most recent successful data. pub fn data(&self) -> Option<&T> { self.data.as_ref() } - /// Most recent error. pub fn error(&self) -> Option<&E> { self.error.as_ref() } - /// Variables for the current or most recent mutation. pub fn variables(&self) -> Option<&V> { self.variables.as_ref() } - /// Current retry count. pub fn retry_count(&self) -> u32 { self.retry_count } - /// Number of times this mutation has been cancelled. pub fn cancelled_count(&self) -> u64 { self.cancelled_count } - /// The retry policy. pub fn retry_policy(&self) -> &RetryPolicy { &self.retry_policy } - /// Set the retry policy. - /// - /// Mirrors `QueryResource::set_retry_policy` / - /// `InfiniteQueryResource::set_retry_policy` for API consistency. pub fn set_retry_policy(&mut self, policy: RetryPolicy) { self.retry_policy = policy; } - /// Whether the mutation is currently loading. pub fn is_loading(&self) -> bool { self.status.is_loading() } - /// Whether the mutation is idle. pub fn is_idle(&self) -> bool { self.status.is_idle() } - /// Whether the mutation succeeded. pub fn is_success(&self) -> bool { self.status.is_success() } - /// Whether the mutation failed. pub fn is_failure(&self) -> bool { self.status.is_failure() } - /// Optional query key for this mutation. pub fn key(&self) -> Option<&QueryKey> { self.key.as_ref() } - /// Associate a query key with this mutation. - /// - /// Forward-compatibility hook: the hook layer does not currently set a key - /// on mutations, so `key` remains `None` in production. Kept for callers - /// that want to tag a mutation with a key for diagnostics/invalidation. + /// The hook layer never sets a key; kept for callers tagging mutations + /// for diagnostics or invalidation. pub fn with_key(mut self, key: QueryKey) -> Self { self.key = Some(key); self } - /// Start a mutation with the given variables. - /// - /// Transitions to `Loading`, stores variables, clears error, creates signal. - /// Cancels any previous in-flight signal so a prior fetcher observes cancellation. - /// Resets `retry_count` so each mutation invocation starts fresh, matching - /// `QueryResource`'s behavior where the hook layer resets retries on success. + /// Cancels any in-flight signal and resets `retry_count`, so each + /// invocation starts fresh. pub fn begin(&mut self, variables: V) { - // Cancel old signal before replacing, matching QueryResource/InfiniteQueryResource pattern. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } @@ -217,7 +169,6 @@ impl<V, T, E> MutationResource<V, T, E> { self.signal = Some(QuerySignal::new()); } - /// Complete successfully. pub fn complete_success(&mut self, data: T) { self.status = MutationStatus::Success; self.data = Some(data); @@ -226,11 +177,8 @@ impl<V, T, E> MutationResource<V, T, E> { self.signal = None; } - /// Complete with failure. - /// - /// Clears `data` so consumers do not see stale success data alongside - /// a `Failure` status. Increments `retry_count` with saturating add to - /// prevent wraparound. + /// Clears `data` so consumers never see stale success data beside a + /// `Failure` status. pub fn complete_failure(&mut self, error: E) { self.status = MutationStatus::Failure; self.data = None; @@ -240,15 +188,11 @@ impl<V, T, E> MutationResource<V, T, E> { self.signal = None; } - /// Whether another retry is allowed. pub fn should_retry(&self) -> bool { self.retry_policy.should_retry(self.retry_count) } - /// Retry by transitioning back to Loading. - /// - /// Only valid from `Failure` when retries remain. - /// A fresh cancellation signal is created. + /// Only from `Failure` with retries remaining; creates a fresh signal. pub fn retry(&mut self) -> bool { if self.status != MutationStatus::Failure || !self.should_retry() { return false; @@ -259,7 +203,6 @@ impl<V, T, E> MutationResource<V, T, E> { true } - /// Reset to idle, clearing everything. pub fn reset(&mut self) { if let Some(signal) = self.signal.as_ref() { signal.cancel(); @@ -270,37 +213,25 @@ impl<V, T, E> MutationResource<V, T, E> { self.variables = None; self.retry_count = 0; self.cancelled_count = 0; - // Clear the completion timestamp so GC does not measure recency from - // a pre-reset completion. self.last_updated_at_ms = None; self.signal = None; } - /// The cancellation signal. pub fn signal(&self) -> Option<&QuerySignal> { self.signal.as_ref() } - /// Increment the retry counter. - /// - /// Used by the mutation retry loop to track how many attempts have been made - /// without transitioning through a terminal `Failure` state. + /// Tracks attempts without transitioning through a terminal `Failure`. pub fn increment_retry(&mut self) { self.retry_count = self.retry_count.saturating_add(1); } - /// Prepare for a retry by refreshing the signal without transitioning - /// through `Failure`. - /// - /// Avoids a transient `Failure` status that would cause observers to see a - /// brief Failure flash between retry attempts. The mutation stays in - /// `Loading` state, the old signal is cancelled, and a fresh signal is - /// created for the next attempt. + /// Stays in `Loading` so observers never see a transient `Failure` flash + /// between retry attempts. pub fn prepare_retry(&mut self) { if self.status != MutationStatus::Loading { return; } - // Cancel the old signal before creating a new one. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } @@ -308,22 +239,12 @@ impl<V, T, E> MutationResource<V, T, E> { self.signal = Some(QuerySignal::new()); } - /// Reset the retry counter to zero. - /// - /// Called on terminal failure so that `retry_count` is clean for the - /// next mutation invocation. pub fn reset_retry_count(&mut self) { self.retry_count = 0; } - /// Cancel the mutation. - /// - /// Only has effect when the mutation is in `Loading` state. Returns without - /// side effects if the mutation is already `Idle`, `Success`, or `Failure`, - /// matching the `QueryResource::cancel` behavior where a no-op cancel is silent. - /// - /// When effective, increments `cancelled_count` for diagnostics and sets - /// status to `Failure`. + /// No-op unless `Loading`; when effective, sets `Failure` and increments + /// `cancelled_count`. pub fn cancel(&mut self, error: E) { if self.status != MutationStatus::Loading { return; @@ -340,9 +261,6 @@ impl<V, T, E> MutationResource<V, T, E> { #[cfg(feature = "client")] impl<V, T, E> MutationResource<V, T, E> { - /// Store a new background mutation task, cancelling any previously stored - /// task. Called from the hook spawn sites so a replacement mutation or - /// entity drop aborts the prior in-flight task. pub(crate) fn set_current_task(&mut self, task: gpui::Task<()>) { self.current_task.set(task); } @@ -401,7 +319,7 @@ mod tests { let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(1)); m.begin("vars".to_string()); m.complete_failure(QueryError::response("fail")); - assert!(!m.should_retry()); // retry_count=1, max=1 + assert!(!m.should_retry()); assert!(!m.retry()); } @@ -432,10 +350,8 @@ mod tests { m.begin("first".to_string()); let old_signal = m.signal().unwrap().clone(); assert!(!old_signal.is_cancelled()); - // Starting a new mutation should cancel the old signal. m.begin("second".to_string()); assert!(old_signal.is_cancelled()); - // New signal should not be cancelled. assert!(!m.signal().unwrap().is_cancelled()); } @@ -445,7 +361,6 @@ mod tests { m.begin("vars".to_string()); m.complete_success(42); assert_eq!(m.data(), Some(&42)); - // Succeed then fail: data should be cleared. m.begin("vars2".to_string()); m.complete_failure(QueryError::response("fail")); assert!(m.is_failure()); @@ -514,7 +429,6 @@ mod tests { m.begin("vars".to_string()); m.complete_failure(QueryError::response("fail")); assert_eq!(m.retry_count(), 1); - // Starting a new invocation resets retry_count. m.begin("vars2".to_string()); assert_eq!(m.retry_count(), 0, "begin() should reset retry_count"); } @@ -522,7 +436,6 @@ mod tests { #[test] fn begin_resets_retry_count_allows_fresh_retries() { let mut m: MutationResource<String, i32> = MutationResource::new(RetryPolicy::new(1)); - // First invocation: fail, exhaust retries. m.begin("vars".to_string()); m.complete_failure(QueryError::response("fail")); assert_eq!(m.retry_count(), 1); @@ -530,7 +443,6 @@ mod tests { !m.should_retry(), "retries exhausted after first invocation" ); - // Second invocation: begin resets retry_count, so retries are fresh. m.begin("vars2".to_string()); assert_eq!(m.retry_count(), 0); assert!( diff --git a/crates/gpui-query/src/core/network_mode.rs b/crates/gpui-query/src/core/network_mode.rs index 517a5aa..40f6d96 100644 --- a/crates/gpui-query/src/core/network_mode.rs +++ b/crates/gpui-query/src/core/network_mode.rs @@ -1,24 +1,17 @@ -//! Network mode configuration (forward compatibility). +//! Network mode configuration. use serde::{Deserialize, Serialize}; -/// Controls fetch behavior based on network connectivity. -/// -/// Defined for forward compatibility; the actual network detection is not -/// implemented yet. +/// Forward compatibility only; network detection is not implemented yet. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum NetworkMode { - /// Only fetch when online (default). #[default] Online, - /// Always try to fetch, even when offline. Always, - /// Use cached data first, fetch when online. OfflineFirst, } impl NetworkMode { - /// Human-readable label. pub fn label(self) -> &'static str { match self { Self::Online => "Online", diff --git a/crates/gpui-query/src/core/policy.rs b/crates/gpui-query/src/core/policy.rs index de90032..66ece85 100644 --- a/crates/gpui-query/src/core/policy.rs +++ b/crates/gpui-query/src/core/policy.rs @@ -4,69 +4,40 @@ use serde::{Deserialize, Serialize}; use super::{QueryStatus, RequestId}; -/// How cached data is treated when a query is accessed. -/// -/// - [`NoCache`](CachePolicy::NoCache): Always fetch fresh data. -/// - [`Ttl`](CachePolicy::Ttl): Use cached data if fresh (within TTL). -/// - [`StaleWhileRevalidate`](CachePolicy::StaleWhileRevalidate): Return stale -/// data immediately while fetching fresh data in the background. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum CachePolicy { - /// Never cache — always fetch fresh data. NoCache, - /// Cache with a time-to-live. Data is considered fresh within the TTL. - /// - /// `ttl_ms` should be greater than zero; a value of 0 behaves like - /// [`NoCache`](Self::NoCache) because data is only "fresh" at the instant - /// it is stored. Not validated in release builds (a `debug_assert` fires - /// in debug builds). + /// `ttl_ms = 0` behaves like `NoCache` (data is only "fresh" at the + /// instant it is stored); only `debug_assert`ed, not validated in release. Ttl { ttl_ms: u64 }, - /// Return stale data immediately while revalidating in the background. - /// - /// Data within `ttl_ms` is served as a fresh cache hit (no refetch). Data - /// between `ttl_ms` and `ttl_ms + stale_ms` is served as stale data **and** - /// a background revalidation is triggered. After `ttl_ms + stale_ms`, data - /// is expired and a normal fetch is performed (no stale data served). - /// - /// Both fields should be greater than zero: `ttl_ms = 0` behaves like - /// [`NoCache`](Self::NoCache), and `stale_ms = 0` degenerates to pure TTL - /// behavior (empty stale window). Not validated in release builds - /// (`debug_assert`s fire in debug builds). + /// Fresh within `ttl_ms`; between `ttl_ms` and `ttl_ms + stale_ms` the + /// stale data is served and a background revalidation is triggered; past + /// that, a normal fetch runs. Zero values degenerate as in [`Ttl`](Self::Ttl); + /// only `debug_assert`ed, not validated in release. StaleWhileRevalidate { ttl_ms: u64, stale_ms: u64 }, } impl Default for CachePolicy { fn default() -> Self { - Self::Ttl { ttl_ms: 60_000 } // 1 minute default + Self::Ttl { ttl_ms: 60_000 } } } impl CachePolicy { - /// Human-readable label. - /// - /// Sub-second values are shown with millisecond precision (e.g. "500ms") - /// rather than truncating to "0s" via integer division. Allocates a - /// `String`; `format!("{policy}")` writes the same text without the heap - /// allocation. + /// Allocates a `String`; `format!("{policy}")` writes the same text + /// without the heap allocation. pub fn label(self) -> String { self.to_string() } - /// Whether this policy can short-circuit (return cached data without fetching). - /// - /// Returns `true` for `Ttl` and `StaleWhileRevalidate` since both can serve - /// cached data when it is fresh (within the TTL window). The actual freshness - /// check is done separately in [`is_fresh`](Self::is_fresh). pub fn can_short_circuit(self) -> bool { matches!(self, Self::Ttl { .. } | Self::StaleWhileRevalidate { .. }) } - /// Whether this policy allows serving stale data while revalidating. pub fn can_serve_stale(self) -> bool { matches!(self, Self::StaleWhileRevalidate { .. }) } - /// The TTL in milliseconds, if applicable. pub fn ttl_ms(self) -> Option<u64> { match self { Self::NoCache => None, @@ -74,9 +45,6 @@ impl CachePolicy { } } - /// The stale-while-revalidate window in milliseconds beyond TTL. - /// - /// Returns `None` for policies that are not `StaleWhileRevalidate`. pub fn stale_ms(self) -> Option<u64> { match self { Self::StaleWhileRevalidate { stale_ms, .. } => Some(stale_ms), @@ -84,14 +52,8 @@ impl CachePolicy { } } - /// Total valid window (TTL + stale) in milliseconds. - /// - /// This is the maximum age at which data can still be served under this policy. - /// For `Ttl`, this equals `ttl_ms`. For `StaleWhileRevalidate`, it equals - /// `ttl_ms + stale_ms`. Returns `None` for `NoCache`. - /// - /// On overflow (extremely large `ttl_ms + stale_ms`), saturates to `u64::MAX`, - /// effectively treating the data as indefinitely valid. + /// TTL + stale, saturating on overflow: the maximum age at which data can + /// still be served under this policy. pub fn total_valid_ms(self) -> Option<u64> { match self { Self::NoCache => None, @@ -116,18 +78,10 @@ impl CachePolicy { } } - /// Whether the data is fresh (within the TTL window). - /// - /// Returns `false` if the policy has no TTL or the data age exceeds TTL. pub fn is_fresh(self, age_ms: u64) -> bool { self.ttl_ms().map(|ttl| age_ms <= ttl).unwrap_or(false) } - /// Whether the data is stale but still within the stale-while-revalidate window. - /// - /// Data is "stale-but-serveable" when: - /// - The policy is `StaleWhileRevalidate` - /// - Data age is past TTL but within `ttl_ms + stale_ms` pub fn is_stale_but_serveable(self, age_ms: u64) -> bool { match self { Self::StaleWhileRevalidate { ttl_ms, stale_ms } => { @@ -138,13 +92,10 @@ impl CachePolicy { } } - /// Whether the data is expired (past the total valid window). - /// - /// Returns `true` if the data age exceeds the total valid window for this policy. pub fn is_expired(self, age_ms: u64) -> bool { self.total_valid_ms() .map(|total| age_ms > total) - .unwrap_or(true) // NoCache always considers data expired + .unwrap_or(true) } } @@ -166,8 +117,8 @@ impl std::fmt::Display for CachePolicy { } } -/// Write a duration: seconds for `>= 1000ms`, milliseconds otherwise, so -/// sub-second values do not collapse to "0s" through integer division. +/// Seconds for `>= 1000ms`, milliseconds otherwise, so sub-second values do +/// not collapse to "0s" through integer division. fn write_duration(f: &mut std::fmt::Formatter<'_>, ms: u64) -> std::fmt::Result { if ms >= 1_000 { write!(f, "{}s", ms / 1_000) @@ -176,22 +127,14 @@ fn write_duration(f: &mut std::fmt::Formatter<'_>, ms: u64) -> std::fmt::Result } } -/// How concurrent requests are handled. -/// -/// - [`LatestWins`](RequestPolicy::LatestWins): New requests cancel in-flight ones. -/// - [`IgnoreWhileLoading`](RequestPolicy::IgnoreWhileLoading): New requests are -/// ignored if one is already in progress. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RequestPolicy { - /// New requests replace in-flight ones (default). #[default] LatestWins, - /// Ignore new requests while one is already loading. IgnoreWhileLoading, } impl RequestPolicy { - /// Human-readable label. pub fn label(self) -> &'static str { match self { Self::LatestWins => "Latest wins", @@ -200,40 +143,29 @@ impl RequestPolicy { } } -/// Whether the fetch is a normal request or forced (ignoring cache). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum QueryFetchMode { - /// Normal fetch — respects cache policy. #[default] Normal, - /// Force fetch — ignores cache freshness. Force, } -/// The result of calling `begin_request` on a query resource. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[must_use] pub enum QueryBeginResult { - /// A new request was started. Started { request_id: RequestId, status: QueryStatus, replaced_request_id: Option<RequestId>, }, - /// Cache is fresh — no fetch needed. CacheHit, - /// Stale data was served and a background revalidation was started. - /// - /// The caller should: - /// 1. Return the existing stale data to the consumer immediately. - /// 2. Use the `request_id` to perform a background fetch. - /// 3. Complete the request normally via `complete_success`/`complete_failure`. + /// Serve the stale data immediately, run a background fetch with + /// `request_id`, then complete normally. StaleCacheHit { request_id: RequestId, status: QueryStatus, replaced_request_id: Option<RequestId>, }, - /// A request is already loading and the policy is `IgnoreWhileLoading`. IgnoredWhileLoading { active_request_id: RequestId }, } diff --git a/crates/gpui-query/src/core/refetch.rs b/crates/gpui-query/src/core/refetch.rs index 5880320..96d23d4 100644 --- a/crates/gpui-query/src/core/refetch.rs +++ b/crates/gpui-query/src/core/refetch.rs @@ -1,22 +1,15 @@ use serde::{Deserialize, Serialize}; -/// Trigger configuration for automatic refetching. -/// -/// The triggers are parsed and stored, but the event system integration -/// (window focus, reconnect) is not implemented yet. +/// Parsed and stored, but focus/reconnect event integration is not implemented yet. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RefetchTrigger { - /// Always refetch when the trigger fires. #[default] Always, - /// Refetch only if the data is stale (past TTL). IfStale, - /// Never refetch on this trigger. Never, } impl RefetchTrigger { - /// Human-readable label. pub fn label(self) -> &'static str { match self { Self::Always => "Always", diff --git a/crates/gpui-query/src/core/request.rs b/crates/gpui-query/src/core/request.rs index 1d49e6b..8df1c26 100644 --- a/crates/gpui-query/src/core/request.rs +++ b/crates/gpui-query/src/core/request.rs @@ -1,29 +1,12 @@ -//! Request lifecycle primitives for the query system. +//! Request lifecycle primitives: ids, sequencer, guard, timestamps. //! -//! - [`RequestId`] — a unique, ordered identifier for each in-flight request. -//! - [`RequestSequencer`] — a monotonic generator of `RequestId` values, scoped -//! per resource to guarantee uniqueness even after sequence overflow. -//! - [`RequestGuard`] — a single-use capability token that enforces the two-phase -//! completion protocol (accept → complete). -//! - [`QueryTimestamp`] — a millisecond-precision timestamp used for cache -//! freshness and staleness calculations. -//! -//! The two-phase protocol: accept a [`RequestId`] via -//! [`QueryResource::accept_current_request`](super::QueryResource::accept_current_request) -//! to get a [`RequestGuard`], then pass the guard (by value) to a `complete_*` -//! method. Convenience methods like `complete_current_success` combine both -//! phases into one call. -//! -//! [`QueryResource`]: super::QueryResource +//! Two-phase completion: `accept_current_request` returns a single-use +//! [`RequestGuard`] that a `complete_*` method consumes by value. use serde::{Deserialize, Serialize}; use std::num::NonZero; -/// A unique identifier for an in-flight request. -/// -/// Combines a scope id (per-resource) with a monotonically increasing sequence. -/// Two `RequestId` values are equal only when both scope and sequence match. -/// Ordering is lexicographic: scope first, then sequence. +/// Scoped id: equality and ordering are lexicographic (scope, then sequence). /// /// # Example /// @@ -44,28 +27,18 @@ pub struct RequestId { } impl RequestId { - /// Create a request id with explicit scope and sequence. - /// - /// The scope must be non-zero; passing a zero scope would violate the - /// `NonZero<u64>` niche invariant, so it is taken as `NonZero<u64>` directly. pub fn scoped(scope_id: NonZero<u64>, sequence: u64) -> Self { Self { scope_id, sequence } } - /// The sequence number within this scope. pub fn value(self) -> u64 { self.sequence } - /// The scope identifier. - /// - /// Returns the scope as `NonZero<u64>`. Use `.get()` if a plain `u64` is needed. pub fn scope_id(self) -> NonZero<u64> { self.scope_id } - /// Human-readable label for diagnostics. - /// /// Allocates a `String`; `format!("{id}")` writes the same text without /// the heap allocation. pub fn label(self) -> String { @@ -81,16 +54,10 @@ impl std::fmt::Display for RequestId { /// Monotonic request id generator scoped to a single resource. /// -/// Each `RequestSequencer` produces a stream of [`RequestId`] values that are -/// unique within the resource's lifetime. The sequence counter increments -/// from 1; when it would overflow `u64::MAX`, the scope advances via -/// [`advance_scope`](Self::advance_scope), which increments `scope_id` and -/// resets the sequence to 1. -/// -/// If `scope_id` itself overflows, it wraps to 1 and the sequence resets, so -/// a fresh `RequestId(1, 1)` could theoretically collide with a very old one -/// still held by a long-running future. Reaching `u64::MAX` requests per scope -/// is out of reach in practice. +/// The sequence increments from 1; at `u64::MAX` the scope advances and the +/// sequence resets. If the scope itself overflows it wraps to 1, so a fresh +/// id could theoretically collide with a very old one still held by a +/// long-running future. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RequestSequencer { pub(crate) scope_id: NonZero<u64>, @@ -104,7 +71,6 @@ impl Default for RequestSequencer { } impl RequestSequencer { - /// Create a new sequencer starting at scope 1, sequence 1. pub fn new() -> Self { Self { scope_id: NonZero::new(1).unwrap(), @@ -112,10 +78,6 @@ impl RequestSequencer { } } - /// Generate the next request id. - /// - /// When the sequence counter reaches `u64::MAX`, the scope advances - /// before the next call can produce a duplicate. pub fn next_request(&mut self) -> RequestId { let request_id = RequestId::scoped(self.scope_id, self.next_request_id); if self.next_request_id == u64::MAX { @@ -126,39 +88,31 @@ impl RequestSequencer { request_id } - /// Advance to a new scope when the sequence overflows. pub fn advance_scope(&mut self) { self.scope_id = NonZero::new(self.scope_id.get().checked_add(1).unwrap_or(1)) .unwrap_or(NonZero::<u64>::MIN); self.next_request_id = 1; } - /// Whether the given request id belongs to the current scope. pub fn is_current_scope(&self, request_id: RequestId) -> bool { request_id.scope_id == self.scope_id } } -/// A timestamp for query operations, in milliseconds since UNIX epoch. -/// -/// Used for cache freshness checks (TTL, stale-while-revalidate) and for -/// recording when data was last updated. Obtain the current time via -/// `QueryTimestamp::from_millis(...)` using your application's clock. +/// Milliseconds since the UNIX epoch, driven by the application's clock +/// via [`QueryTimestamp::from_millis`]; core has no clock of its own. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct QueryTimestamp(u64); impl QueryTimestamp { - /// Create a timestamp from milliseconds. pub fn from_millis(value: u64) -> Self { Self(value) } - /// The timestamp in milliseconds. pub fn as_millis(self) -> u64 { self.0 } - /// Compute elapsed time since an earlier timestamp. pub(super) fn elapsed_since(self, earlier: Self) -> Option<u64> { self.0.checked_sub(earlier.0) } @@ -170,15 +124,8 @@ impl From<u64> for QueryTimestamp { } } -/// A single-use capability token proving the holder owns the current request. -/// -/// Created by [`QueryResource::accept_current_request`], consumed by one of the -/// `complete_*` methods. The guard is **moved** (not copied) into the -/// completion method, which enforces the two-phase protocol at the type level: -/// once a guard is used, it cannot be used again. -/// -/// [`QueryResource`]: super::QueryResource -/// [`QueryResource::accept_current_request`]: super::QueryResource::accept_current_request +/// Single-use token moved into a `complete_*` call, enforcing +/// accept-then-complete at the type level. #[derive(Debug, PartialEq, Eq)] #[must_use] pub struct RequestGuard { @@ -190,14 +137,10 @@ impl RequestGuard { Self { request_id } } - /// The request id this guard protects (borrowed). pub fn request_id(&self) -> RequestId { self.request_id } - /// Consume the guard and return the request id. - /// - /// Useful when you want to extract the id and discard the guard. pub fn into_request_id(self) -> RequestId { self.request_id } diff --git a/crates/gpui-query/src/core/resource.rs b/crates/gpui-query/src/core/resource.rs index 17dd083..e569766 100644 --- a/crates/gpui-query/src/core/resource.rs +++ b/crates/gpui-query/src/core/resource.rs @@ -10,17 +10,9 @@ mod cache; mod completion; mod lifecycle; -/// Core state machine for a single query resource. -/// -/// `QueryResource` owns the cache/request state for one resource. It tracks -/// data, error, loading status, retry count, and a cooperative cancellation -/// signal. Callers interact with it through lifecycle methods: -/// -/// 1. [`begin_request`](QueryResource::begin_request) — start a fetch -/// 2. [`accept_current_request`](QueryResource::accept_current_request) — validate the request is still active -/// 3. [`complete_success`](QueryResource::complete_success) / [`complete_failure`](QueryResource::complete_failure) — complete the request -/// -/// This type is framework-free — it depends only on `serde`. +/// Framework-free state machine for one query: data, error, status, retries, +/// and a cooperative cancellation signal. Lifecycle: `begin_request` → +/// `accept_current_request` → `complete_*`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QueryResource<T, E = QueryError> { key: QueryKey, @@ -38,10 +30,8 @@ pub struct QueryResource<T, E = QueryError> { retry_count: u32, retry_policy: RetryPolicy, previous_data: Option<T>, - /// Per-resource sequencer used by [`begin_request_with_id`](Self::begin_request_with_id) - /// when no external id is supplied, so callers without a `QueryClient` - /// still get monotonic, collision-free ids. `#[serde(skip)]` — runtime - /// state, not persisted. + /// Runtime state, not persisted; supplies monotonic ids when no external + /// sequencer is provided. #[serde(skip)] transient_sequencer: RequestSequencer, #[serde(skip)] @@ -49,7 +39,6 @@ pub struct QueryResource<T, E = QueryError> { } impl<T, E> QueryResource<T, E> { - /// Create a new query resource with the given key and policies. pub fn new( key: impl Into<QueryKey>, cache_policy: CachePolicy, diff --git a/crates/gpui-query/src/core/resource/accessors.rs b/crates/gpui-query/src/core/resource/accessors.rs index cdf99d1..a695aae 100644 --- a/crates/gpui-query/src/core/resource/accessors.rs +++ b/crates/gpui-query/src/core/resource/accessors.rs @@ -8,134 +8,104 @@ use crate::core::{ use super::QueryResource; impl<T, E> QueryResource<T, E> { - /// Whether the resource is currently loading. pub fn is_loading(&self) -> bool { self.status.is_loading() } - /// Whether the resource is pending (no data yet). pub fn is_pending(&self) -> bool { self.status.is_pending() } - /// The cache key. pub fn key(&self) -> &QueryKey { &self.key } - /// Current status. pub fn status(&self) -> QueryStatus { self.status } - /// Current data, if loaded. pub fn data(&self) -> Option<&T> { self.data.as_ref() } - /// Current error, if any. pub fn error(&self) -> Option<&E> { self.error.as_ref() } - /// Active request id, if a request is in flight. pub fn active_request_id(&self) -> Option<RequestId> { self.active_request_id } - /// The cache policy. pub fn cache_policy(&self) -> CachePolicy { self.cache_policy } - /// The request policy. pub fn request_policy(&self) -> RequestPolicy { self.request_policy } - /// When the current request started (ms since UNIX epoch). pub fn started_at_ms(&self) -> Option<u64> { self.started_at.map(QueryTimestamp::as_millis) } - /// When data was last updated (ms since UNIX epoch). pub fn last_updated_at_ms(&self) -> Option<u64> { self.last_updated_at.map(QueryTimestamp::as_millis) } - /// Total cache hits. pub fn cache_hits(&self) -> u64 { self.cache_hits } - /// Total cancelled requests. pub fn cancelled_count(&self) -> u64 { self.cancelled_count } - /// Total ignored (stale) results. pub fn ignored_results(&self) -> u64 { self.ignored_results } - /// Whether data exists. pub fn has_data(&self) -> bool { self.data.is_some() } - /// The cancellation signal, if a request is in flight. pub fn signal(&self) -> Option<&QuerySignal> { self.signal.as_ref() } - /// Mutable reference to the cancellation signal. #[cfg(test)] pub(crate) fn signal_mut(&mut self) -> Option<&mut QuerySignal> { self.signal.as_mut() } - /// Previous data (saved during optimistic updates for rollback). + /// Saved during optimistic updates for [`rollback_to_previous`](Self::rollback_to_previous). pub fn previous_data(&self) -> Option<&T> { self.previous_data.as_ref() } - /// Current retry count. pub fn retry_count(&self) -> u32 { self.retry_count } - /// The retry policy. pub fn retry_policy(&self) -> &RetryPolicy { &self.retry_policy } - /// Increment the retry counter. pub fn increment_retry(&mut self) { self.retry_count = self.retry_count.saturating_add(1); } - /// Set the retry policy. pub fn set_retry_policy(&mut self, policy: RetryPolicy) { self.retry_policy = policy; } - /// Reset the retry counter to zero. pub fn reset_retry_count(&mut self) { self.retry_count = 0; } - /// Set the cache policy. - /// - /// This allows policy updates on existing resources when `use_query` is - /// called with the same key but different policies (e.g., a different TTL). pub fn set_cache_policy(&mut self, policy: CachePolicy) { self.cache_policy = policy; } - /// Set the request policy. - /// - /// This allows policy updates on existing resources when `use_query` is - /// called with the same key but different request behavior. pub fn set_request_policy(&mut self, policy: RequestPolicy) { self.request_policy = policy; } diff --git a/crates/gpui-query/src/core/resource/cache.rs b/crates/gpui-query/src/core/resource/cache.rs index dd22701..2d2d9c2 100644 --- a/crates/gpui-query/src/core/resource/cache.rs +++ b/crates/gpui-query/src/core/resource/cache.rs @@ -5,23 +5,12 @@ use crate::core::{QueryStatus, QueryTimestamp}; use super::QueryResource; impl<T, E> QueryResource<T, E> { - /// Cache age in milliseconds. pub fn cache_age_ms(&self, now_ms: u64) -> Option<u64> { QueryTimestamp::from(now_ms).elapsed_since(self.last_updated_at?) } - /// Whether the cache is fresh (within TTL). - /// - /// For all policies with a TTL, this checks that data exists and the age - /// is within the TTL window. The stale-while-revalidate window is NOT - /// considered fresh — it is stale-but-serveable (see [`is_stale_but_serveable`]). - /// - /// Data at exactly TTL milliseconds old is considered fresh (`age <= ttl_ms`), - /// unlike HTTP `Cache-Control: max-age` where the boundary is exclusive. - /// The inclusive boundary keeps the fresh/stale partition total: every age - /// is either fresh or stale, with no gap. - /// - /// [`is_stale_but_serveable`]: Self::is_stale_but_serveable + /// The TTL boundary is inclusive (`age <= ttl`), unlike HTTP `max-age`; + /// the stale-while-revalidate window is not fresh, it is stale-but-serveable. pub fn is_cache_fresh(&self, now_ms: u64) -> bool { self.has_data() && self @@ -32,12 +21,6 @@ impl<T, E> QueryResource<T, E> { .unwrap_or(false) } - /// Whether the cache is stale but still within the stale-while-revalidate window. - /// - /// Returns `true` when: - /// - The policy is `StaleWhileRevalidate` - /// - Data exists - /// - Data age is past TTL but within `ttl_ms + stale_ms` pub fn is_stale_but_serveable(&self, now_ms: u64) -> bool { self.has_data() && self @@ -46,11 +29,6 @@ impl<T, E> QueryResource<T, E> { .unwrap_or(false) } - /// Whether the cache is fully expired (past the total valid window). - /// - /// For `StaleWhileRevalidate`, this means past `ttl_ms + stale_ms`. - /// For `Ttl`, this means past `ttl_ms`. - /// For `NoCache`, always returns `true` (no data is ever valid). pub fn is_cache_expired(&self, now_ms: u64) -> bool { if !self.has_data() { return true; @@ -60,54 +38,31 @@ impl<T, E> QueryResource<T, E> { .unwrap_or(true) } - /// Whether the cache can short-circuit (fresh data, no fetch needed). - /// - /// Only returns `true` when the policy supports short-circuiting AND the - /// data is within the TTL window (fresh, not stale). pub fn should_short_circuit_cache(&self, now_ms: u64) -> bool { self.cache_policy.can_short_circuit() && self.is_cache_fresh(now_ms) } - /// Whether the resource should serve stale data while triggering a background refetch. - /// - /// This is the core stale-while-revalidate check: data is past its TTL but - /// still within the stale window. The caller should: - /// 1. Return existing data to the consumer immediately. - /// 2. Start a background fetch to revalidate. + /// When `true`, the caller serves the stale data immediately and starts a + /// background fetch to revalidate. pub fn should_serve_stale_and_revalidate(&self, now_ms: u64) -> bool { self.cache_policy.can_serve_stale() && self.is_stale_but_serveable(now_ms) } - /// Record a cache hit. - /// - /// Increments the hit counter and transitions status to [`Success`](QueryStatus::Success) - /// **only if the resource is not in a terminal failure state** (`Failure` or `Cancelled`). - /// This prevents a surprising `Failure -> Success` transition without a new fetch - /// having occurred. The error is only cleared when transitioning to `Success`. - /// - /// A cache hit on data that was previously fetched successfully will still set - /// `Success` as expected. + /// Skips the `Success` transition when in `Failure`/`Cancelled`: a hit on + /// old data must not clear an error the consumer is already handling. pub(crate) fn record_cache_hit(&mut self) { self.cache_hits = self.cache_hits.saturating_add(1); - // Failure/Cancelled are terminal: a hit on old data must not silently - // clear an error the consumer is already handling. if !matches!(self.status, QueryStatus::Failure | QueryStatus::Cancelled) { self.status = QueryStatus::Success; self.error = None; } } - /// Record a stale cache hit (data served from the stale window). - /// - /// Same behavior as [`record_cache_hit`](Self::record_cache_hit); the caller - /// is expected to also trigger a background revalidation. pub(crate) fn record_stale_cache_hit(&mut self) { self.record_cache_hit(); } - /// Invalidate the cache (clear last-updated timestamp). - /// - /// Data is retained but the resource is considered stale. + /// Data is retained; only the last-updated timestamp is cleared. pub fn invalidate(&mut self) { self.last_updated_at = None; } diff --git a/crates/gpui-query/src/core/resource/completion.rs b/crates/gpui-query/src/core/resource/completion.rs index 6c6f22b..4326e87 100644 --- a/crates/gpui-query/src/core/resource/completion.rs +++ b/crates/gpui-query/src/core/resource/completion.rs @@ -5,10 +5,6 @@ use crate::core::{CachePolicy, QueryStatus, QueryTimestamp, RequestGuard, Reques use super::QueryResource; impl<T, E> QueryResource<T, E> { - /// Complete the current request with success by request id. - /// - /// Convenience method that accepts + completes in one call. - /// Returns `true` if the request was accepted. pub fn complete_current_success( &mut self, request_id: RequestId, @@ -22,7 +18,6 @@ impl<T, E> QueryResource<T, E> { true } - /// Complete the current request with failure by request id. pub fn complete_current_failure( &mut self, request_id: RequestId, @@ -36,7 +31,6 @@ impl<T, E> QueryResource<T, E> { true } - /// Complete the current request with optional success by request id. pub fn complete_current_optional_success( &mut self, request_id: RequestId, @@ -50,7 +44,6 @@ impl<T, E> QueryResource<T, E> { true } - /// Complete the current request with failure but retain data by request id. pub fn complete_current_failure_with_data( &mut self, request_id: RequestId, @@ -65,36 +58,23 @@ impl<T, E> QueryResource<T, E> { true } - /// Complete with success, consuming the guard (two-phase protocol). - /// - /// The guard is moved, preventing double-completion at the type level. - /// Validates that no new request was started after the guard was issued. pub fn complete_success(&mut self, guard: RequestGuard, data: T, now_ms: u64) { self.validate_guard(&guard); self.apply_success(data, now_ms); } - /// Complete with failure, consuming the guard (two-phase protocol). - /// - /// The guard is moved, preventing double-completion at the type level. - /// Validates that no new request was started after the guard was issued. pub fn complete_failure(&mut self, guard: RequestGuard, error: impl Into<E>, now_ms: u64) { self.validate_guard(&guard); self.apply_failure(error, now_ms); } - /// Complete with optional success, consuming the guard. - /// - /// If `data` is `None`, the status is set to [`QueryStatus::Idle`] rather than - /// [`QueryStatus::Success`] to maintain the invariant that Success implies data exists. + /// `None` data completes to [`QueryStatus::Idle`], not `Success`, keeping + /// the invariant that `Success` implies data exists. pub fn complete_success_optional(&mut self, guard: RequestGuard, data: Option<T>, now_ms: u64) { self.validate_guard(&guard); self.apply_success_optional(data, now_ms); } - /// Complete with failure but retain data, consuming the guard. - /// - /// Validates that no new request was started after the guard was issued. pub fn complete_failure_with_data( &mut self, guard: RequestGuard, @@ -124,9 +104,6 @@ impl<T, E> QueryResource<T, E> { pub(crate) fn apply_success_optional(&mut self, data: Option<T>, now_ms: u64) { self.previous_data = self.data.take(); - // When data is None, use Idle instead of Success to maintain the - // invariant that Success always implies data is available. Callers - // that check status() == Success and then unwrap data() will not panic. if data.is_some() { self.status = QueryStatus::Success; } else { @@ -146,13 +123,8 @@ impl<T, E> QueryResource<T, E> { self.last_updated_at = Some(QueryTimestamp::from(now_ms)); } - /// Validate that the guard is still valid for the current resource state. - /// - /// `accept_current_request` clears `active_request_id`, so we cannot compare - /// directly. However, if `active_request_id` is `Some`, it means a *new* - /// request was started after the guard was issued but before completion. - /// This indicates the caller interleaved `begin_request` between accept and - /// complete, which would overwrite the newer request's state with stale data. + /// `accept_current_request` clears `active_request_id`, so a `Some` here + /// means a `begin_request` was interleaved between accept and complete. fn validate_guard(&self, guard: &RequestGuard) { debug_assert!( self.active_request_id.is_none(), @@ -165,11 +137,8 @@ impl<T, E> QueryResource<T, E> { } impl<T, E> QueryResource<T, E> { - /// Whether data should be evicted after observers consume it. - /// - /// Returns `true` when `CachePolicy::NoCache` is set, meaning stored data - /// will never be used for cache hits and should be cleared after delivery - /// to avoid holding it in memory indefinitely. + /// `NoCache` data can never produce a cache hit, so clear it after + /// observers consume it instead of holding it in memory. pub fn should_clear_data_on_complete(&self) -> bool { matches!(self.cache_policy, CachePolicy::NoCache) } diff --git a/crates/gpui-query/src/core/resource/lifecycle.rs b/crates/gpui-query/src/core/resource/lifecycle.rs index 575f044..22765cc 100644 --- a/crates/gpui-query/src/core/resource/lifecycle.rs +++ b/crates/gpui-query/src/core/resource/lifecycle.rs @@ -7,21 +7,14 @@ use crate::core::{ use super::QueryResource; -/// Source of the [`RequestId`] for the shared `begin_request_inner` helper: -/// a caller-supplied sequencer, or an optional pre-generated id with a -/// per-resource fallback. enum MaybeRequestId<'a> { FromSequencer(&'a mut RequestSequencer), Provided(Option<RequestId>), } impl<T, E> QueryResource<T, E> { - /// Begin a new request on this resource. - /// - /// Respects the cache policy (may return `CacheHit`) and request policy - /// (`IgnoreWhileLoading` or `LatestWins`). When replacing an existing - /// request, the old signal is **cancelled** so the in-flight fetcher - /// can observe it and abort early. + /// May short-circuit to `CacheHit` per the cache policy; replacing an + /// in-flight request cancels its signal so the old fetcher can abort early. pub fn begin_request( &mut self, sequencer: &mut RequestSequencer, @@ -31,17 +24,9 @@ impl<T, E> QueryResource<T, E> { self.begin_request_inner(now_ms, fetch_mode, MaybeRequestId::FromSequencer(sequencer)) } - /// Like [`begin_request`](Self::begin_request) but accepts an optional - /// pre-generated `RequestId` instead of using a `RequestSequencer`. - /// - /// When `maybe_request_id` is `Some`, uses that ID directly (useful when - /// the bucket's co-located sequencer has already generated the ID). - /// When `None`, falls back to the resource's own stored sequencer so the - /// generated ids stay monotonic and collision-free across calls. - /// - /// This is the preferred entry point for the hook layer: it lets the - /// bucket's persistent sequencer provide globally unique, monotonically - /// increasing RequestIds. + /// `Some(id)` is used as-is (bucket-scoped ids from the hook layer); + /// `None` falls back to the resource's own sequencer so ids stay + /// monotonic and collision-free. pub fn begin_request_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -55,16 +40,13 @@ impl<T, E> QueryResource<T, E> { ) } - /// Shared implementation behind [`begin_request`](Self::begin_request) and - /// [`begin_request_with_id`](Self::begin_request_with_id). fn begin_request_inner( &mut self, now_ms: u64, fetch_mode: QueryFetchMode, mut id_source: MaybeRequestId, ) -> QueryBeginResult { - // Resolve the next id lazily so early-return guards never consume a - // sequence number. + // Lazy: early-return guards must not consume a sequence number. macro_rules! next_id { () => {{ match &mut id_source { @@ -76,21 +58,15 @@ impl<T, E> QueryResource<T, E> { }}; } - // 1. Fresh cache hit — no fetch needed at all. if fetch_mode == QueryFetchMode::Normal && self.should_short_circuit_cache(now_ms) { self.record_cache_hit(); return QueryBeginResult::CacheHit; } - // 2. Stale-while-revalidate: serve stale data immediately, trigger - // background refetch. This is checked before the IgnoreWhileLoading - // guard so we always revalidate stale data even if another request - // is in flight (the new request replaces it via LatestWins below). + // Checked before the IgnoreWhileLoading guard: stale data is always revalidated. if fetch_mode == QueryFetchMode::Normal && self.should_serve_stale_and_revalidate(now_ms) { self.record_stale_cache_hit(); - // If IgnoreWhileLoading and a request is already active, skip the - // background refetch — an in-flight request will refresh the data. if self.request_policy == RequestPolicy::IgnoreWhileLoading && let Some(active_request_id) = self.active_request_id { @@ -115,14 +91,12 @@ impl<T, E> QueryResource<T, E> { }; } - // 3. IgnoreWhileLoading guard for normal (non-stale) requests. if self.request_policy == RequestPolicy::IgnoreWhileLoading && let Some(active_request_id) = self.active_request_id { return QueryBeginResult::IgnoredWhileLoading { active_request_id }; } - // 4. Normal fetch — start a new request. let replaced_request_id = self.active_request_id; if replaced_request_id.is_some() { self.cancelled_count = self.cancelled_count.saturating_add(1); @@ -137,13 +111,7 @@ impl<T, E> QueryResource<T, E> { } } - /// Internal: transition to a loading state. - /// - /// Cancels the old signal before creating a new one, so in-flight - /// fetchers for replaced requests can abort early. Under `LatestWins`, - /// a second call while already `LoadingEmpty` is intentional: it cancels - /// the old request and starts a new one. The old request's async task - /// holds a stale `RequestId` and will be rejected by + /// A stale fetcher holding an old `RequestId` is rejected later by /// `accept_current_request()`. pub(crate) fn begin_loading(&mut self, request_id: RequestId, now_ms: u64) -> QueryStatus { let status = if self.has_data() { @@ -156,7 +124,6 @@ impl<T, E> QueryResource<T, E> { self.started_at = Some(QueryTimestamp::from(now_ms)); self.error = None; - // Cancel the OLD signal before replacing it. if let Some(old_signal) = self.signal.as_ref() { old_signal.cancel(); } @@ -165,16 +132,11 @@ impl<T, E> QueryResource<T, E> { status } - /// Whether the given request id is the current active request. pub fn is_current_request(&self, request_id: RequestId) -> bool { self.active_request_id == Some(request_id) } - /// Accept a request for completion. - /// - /// Returns a [`RequestGuard`] if the request is still active, or `None` - /// if it was replaced or cancelled. The guard is a capability token for - /// the two-phase protocol (validate → complete). + /// `None` means the request was replaced or cancelled (counted as ignored). pub fn accept_current_request(&mut self, request_id: RequestId) -> Option<RequestGuard> { if self.is_current_request(request_id) { self.active_request_id = None; @@ -185,21 +147,9 @@ impl<T, E> QueryResource<T, E> { } } - /// Cancel the active request. - /// - /// Returns `false` if there is no active request. - /// The signal is cancelled so the in-flight fetcher can observe it. - /// - /// Data is preserved across cancellations. Current data (if any) is saved - /// to `previous_data` before being cleared, allowing recovery via - /// `rollback_to_previous()`. This matches TanStack Query behavior where - /// cancelling a refetch does not destroy existing data. - /// - /// When the resource was in `LoadingEmpty` status (no prior data existed), - /// both `data` and `previous_data` remain `None`. When the resource was in - /// `LoadingWithData` status (a refetch with existing data), the prior data - /// is saved to `previous_data` and `data` is set to `None`. Callers can use - /// `rollback_to_previous()` to recover the data if needed. + /// Current data moves to `previous_data` before clearing (recoverable via + /// `rollback_to_previous()`); cancelling a refetch does not destroy + /// existing data, matching TanStack Query. pub fn cancel(&mut self, error: E) -> bool { if self.active_request_id.is_none() { return false; @@ -210,8 +160,6 @@ impl<T, E> QueryResource<T, E> { self.error = Some(error); self.cancelled_count = self.cancelled_count.saturating_add(1); - // Save current data to previous_data before clearing so - // rollback_to_previous() can recover it. if self.data.is_some() { self.previous_data = self.data.take(); } @@ -227,16 +175,8 @@ impl<T, E> QueryResource<T, E> { self.ignored_results = self.ignored_results.saturating_add(1); } - /// Whether the current data was served from stale cache (i.e., a - /// stale-while-revalidate background refetch is in progress or failed). - /// - /// Returns `true` when the resource has data but the status indicates - /// the most recent fetch attempt failed or was cancelled. Consumers can - /// use this to distinguish "fresh success" from "stale data still being - /// displayed after a background refetch failure". - /// - /// Note: This is a heuristic check. A `true` result means data exists but - /// the last fetch did not succeed — the data may still be perfectly valid. + /// Heuristic: data exists but the most recent fetch attempt failed, was + /// cancelled, or is still revalidating in the background. pub fn is_data_stale(&self) -> bool { self.data.is_some() && matches!( @@ -245,18 +185,9 @@ impl<T, E> QueryResource<T, E> { ) } - /// Reset the resource back to idle, clearing state and diagnostic counters. - /// - /// **Preserves**: `cache_policy`, `request_policy`, `retry_policy`, and `key`. - /// These are considered configuration, not runtime state, and persist across - /// resets. Use `QueryResource::new()` to create a fully fresh resource with - /// default policies. - /// - /// Counters (`cache_hits`, `cancelled_count`, `ignored_results`, - /// `retry_count`) are always reset regardless of current state. If counter - /// preservation is needed, read them before calling `reset()`. + /// Preserves the policies and key (configuration, not runtime state); + /// counters are always reset, so read them first if needed. pub fn reset(&mut self) { - // Cancel signal before dropping if let Some(signal) = self.signal.as_ref() { signal.cancel(); } @@ -274,10 +205,8 @@ impl<T, E> QueryResource<T, E> { self.signal = None; } - /// Roll back to the previous data (optimistic update undo). - /// - /// Clears any stored error to maintain the invariant that `Success` - /// implies `error is None` (mirroring `apply_success`). + /// Clears any stored error so `Success` keeps implying `error is None`, + /// mirroring `apply_success`. pub fn rollback_to_previous(&mut self) -> bool { if let Some(prev) = self.previous_data.take() { self.data = Some(prev); @@ -288,18 +217,13 @@ impl<T, E> QueryResource<T, E> { false } - /// Apply an optimistic update. Current data is saved for rollback. pub fn set_data(&mut self, data: T) { self.previous_data = self.data.take(); self.data = Some(data); } - /// Clear data optimistically. Current data is saved for rollback. - /// - /// Transitions status to `Idle` to maintain the invariant that `Success` - /// implies data is available (mirroring `apply_success_optional`'s `None` - /// branch). Without this, a `Success` resource with `data = None` would - /// panic on `data.unwrap()`. + /// Status drops from `Success` to `Idle` so `Success` keeps implying data + /// is available. pub fn clear_data(&mut self) { self.previous_data = self.data.take(); if self.status == QueryStatus::Success { diff --git a/crates/gpui-query/src/core/retry.rs b/crates/gpui-query/src/core/retry.rs index 06a080c..cab604e 100644 --- a/crates/gpui-query/src/core/retry.rs +++ b/crates/gpui-query/src/core/retry.rs @@ -4,11 +4,7 @@ use serde::{Deserialize, Serialize}; /// Retry configuration for failed requests. /// -/// # Defaults -/// -/// 3 retries with exponential backoff, 1-second base delay, 30-second cap. -/// -/// # Builder pattern +/// # Examples /// /// ``` /// use gpui_query::RetryPolicy; @@ -20,18 +16,14 @@ use serde::{Deserialize, Serialize}; /// ``` #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RetryPolicy { - /// Maximum number of retry attempts. 0 = no retries. + /// 0 = no retries. pub max_retries: u32, - /// Base delay in milliseconds between retries. pub retry_delay_ms: u64, - /// Whether to use exponential backoff. pub exponential_backoff: bool, - /// Maximum delay cap for exponential backoff (in milliseconds). pub max_retry_delay_ms: u64, } impl RetryPolicy { - /// Create a policy that never retries. pub const fn no_retries() -> Self { Self { max_retries: 0, @@ -41,7 +33,6 @@ impl RetryPolicy { } } - /// Create a policy with the given maximum number of retries. pub const fn new(max_retries: u32) -> Self { Self { max_retries, @@ -51,40 +42,30 @@ impl RetryPolicy { } } - /// Set the base delay between retries (in milliseconds). pub fn with_delay(mut self, delay_ms: u64) -> Self { self.retry_delay_ms = delay_ms; self } - /// Enable exponential backoff for retries. pub fn with_exponential_backoff(mut self) -> Self { self.exponential_backoff = true; self } - /// Set the maximum delay cap for exponential backoff (in milliseconds). pub fn with_max_delay(mut self, max_ms: u64) -> Self { self.max_retry_delay_ms = max_ms; self } - /// Absolute ceiling for any single retry delay (1 hour in milliseconds). - /// This prevents effectively-infinite sleeps even when `max_retry_delay_ms` - /// is set to an unreasonably large value. + /// Absolute ceiling for any single retry delay (1 hour). const ABSOLUTE_MAX_DELAY_MS: u64 = 3_600_000; - /// Calculate the delay for the given attempt number (0-based). - /// - /// With exponential backoff the delay is `retry_delay_ms * 2^attempt`, - /// capped first by `max_retry_delay_ms` and then by a hard ceiling of - /// one hour. The shift amount is itself limited to 62 so the factor - /// never exceeds `2^62`, preventing intermediate overflow. + /// With exponential backoff: `retry_delay_ms * 2^attempt`, capped by + /// `max_retry_delay_ms` and then a 1-hour hard ceiling. pub fn delay_for_attempt(&self, attempt: u32) -> u64 { if !self.exponential_backoff { return self.retry_delay_ms; } - // Cap the shift so the factor stays well within u64 range. let shift = attempt.min(62); let factor = 1u64 << shift; let delay = self.retry_delay_ms.saturating_mul(factor); @@ -93,7 +74,6 @@ impl RetryPolicy { .min(Self::ABSOLUTE_MAX_DELAY_MS) } - /// Whether a retry should be attempted given the current retry count. pub fn should_retry(&self, current_retries: u32) -> bool { current_retries < self.max_retries } diff --git a/crates/gpui-query/src/core/select.rs b/crates/gpui-query/src/core/select.rs index 72e329b..9dd3bcf 100644 --- a/crates/gpui-query/src/core/select.rs +++ b/crates/gpui-query/src/core/select.rs @@ -1,18 +1,8 @@ -//! Select/transform support for projecting query data into derived views. +//! Select/transform support: [`SelectTransform`] projects cached `T` into a +//! derived `U` via [`MappedQueryResource`], without duplicating the cache entry. //! -//! This module provides [`SelectTransform`] and [`MappedQueryResource`], which -//! together implement the "select" pattern found in TanStack Query: a query -//! resource holds raw data of type `T`, and a `MappedQueryResource` applies a -//! `SelectTransform<T, U>` to project it into type `U` without duplicating the -//! underlying cache entry. -//! -//! # Hook integration -//! -//! The [`use_query_select`] hook wires a `SelectTransform` into the `use_query` -//! lifecycle. It returns a `MappedQueryResource` entity that is kept in sync -//! with the underlying `QueryResource` via an observer. Each time the source -//! resource changes (fetch completes, cache hit, etc.), the mapped resource -//! re-reads the source data and the transform is applied on access. +//! The `use_query_select` hook keeps a `MappedQueryResource` in sync with its +//! source `QueryResource` via an observer; the transform runs on access. //! //! # Example //! @@ -63,11 +53,8 @@ use std::sync::Arc; -/// A select transform that maps cached data of type `T` to output type `U`. -/// -/// Stored as `Arc<dyn Fn(&T) -> U>` to be `Clone + Send + Sync`. Use this -/// with [`MappedQueryResource`] to derive a projected view from cached query -/// data without storing a separate copy. +/// Stored as `Arc<dyn Fn(&T) -> U + Send + Sync>` so it is `Clone`; combine +/// with [`MappedQueryResource`] to derive views without a second data copy. /// /// # Example /// @@ -99,8 +86,7 @@ impl<T, U> std::fmt::Debug for SelectTransform<T, U> { impl<T, U> PartialEq for SelectTransform<T, U> { fn eq(&self, other: &Self) -> bool { - // Closures have no PartialEq; compare by shared pointer identity, - // the same approach QuerySignal uses. + // Closures have no PartialEq; compare by pointer identity like QuerySignal. Arc::ptr_eq(&self.transform, &other.transform) } } @@ -108,7 +94,6 @@ impl<T, U> PartialEq for SelectTransform<T, U> { impl<T, U> Eq for SelectTransform<T, U> {} impl<T, U> SelectTransform<T, U> { - /// Create a new select transform from a closure. pub fn new(transform: impl Fn(&T) -> U + Send + Sync + 'static) -> Self { Self { transform: Arc::new(transform), @@ -116,31 +101,13 @@ impl<T, U> SelectTransform<T, U> { } } - /// Apply the transform to data. pub fn apply(&self, data: &T) -> U { (self.transform)(data) } } -/// A mapped view over a `QueryResource` that applies a [`SelectTransform`]. -/// -/// This implements the "select" pattern: multiple consumers can derive -/// different views from the same underlying cached data, each with their own -/// `MappedQueryResource` holding a different transform function. The source -/// data is shared, so there is no duplication. -/// -/// # Type parameters -/// -/// - `T`: The source data type (the cached query result). -/// - `U`: The projected output type (the derived view). -/// - `E`: The error type (carried through for API consistency). -/// -/// # Storage -/// -/// Source data is held as `Option<Arc<T>>` so cloning a `MappedQueryResource` -/// (e.g. for derived views) is a cheap `Arc::clone` rather than a full copy -/// of `T`. `Arc<T>` is `Send + Sync` exactly when `T` is, so the existing -/// bounds are preserved. +/// Several consumers can derive different views from one shared cache entry; +/// the source is held as `Option<Arc<T>>` so cloning is a refcount bump. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MappedQueryResource<T, U, E> { source_data: Option<Arc<T>>, @@ -149,10 +116,6 @@ pub struct MappedQueryResource<T, U, E> { } impl<T, U, E> MappedQueryResource<T, U, E> { - /// Create a new mapped resource. - /// - /// Takes the source data as `Option<Arc<T>>`. For the common case of - /// constructing from a plain `T`, wrap it with `Some(Arc::new(t))`. pub fn new(source_data: Option<Arc<T>>, transform: SelectTransform<T, U>) -> Self { Self { source_data, @@ -161,12 +124,8 @@ impl<T, U, E> MappedQueryResource<T, U, E> { } } - /// Apply the transform to get the selected data. - /// - /// Re-applies the transform closure on every call; this type is a derived - /// view with no separate output cache. If the transform is expensive and - /// you need the result multiple times in a single render pass, cache it - /// in a local variable: + /// Re-applies the transform on every call; cache the result in a local + /// if you need it repeatedly within one render pass. /// /// ``` /// use gpui_query::core::{MappedQueryResource, SelectTransform}; @@ -177,47 +136,27 @@ impl<T, U, E> MappedQueryResource<T, U, E> { /// assert_eq!(data, Some(3)); /// // use `data` freely below /// ``` - /// - /// For lightweight transforms (field access, counting, simple projections) - /// the cost is negligible and no caching is needed. pub fn data(&self) -> Option<U> { - // Deref the Arc so the transform still receives `&T` as documented. self.source_data .as_ref() .map(|d| self.transform.apply(d.as_ref())) } - /// Whether source data exists. pub fn has_data(&self) -> bool { self.source_data.is_some() } - /// Read-only access to the source data. - /// - /// Returns `Option<&T>` by dereferencing the stored `Arc<T>`. Used by the - /// hook layer to detect when the source has changed before re-storing. pub fn source_data(&self) -> Option<&T> { self.source_data.as_ref().map(|arc| arc.as_ref()) } - /// Cheaply hand out the cached source `Arc<T>` as an owned value. - /// - /// Returns `Option<Arc<T>>` via a refcount bump — no `T` clone. The hook - /// layer uses this to compare the cached source against a fresh read - /// without cloning `T` on unchanged notifications. The returned `Arc<T>` - /// is owned, so the mapped borrow ends with this call and a subsequent - /// `entity.read_with` does not create a nested borrow. + /// Refcount bump, no `T` clone. The returned `Arc` is owned, so the + /// mapped borrow ends here and a later `entity.read_with` cannot nest. pub fn source_arc(&self) -> Option<Arc<T>> { self.source_data.clone() } - /// Update the source data from the underlying query resource. - /// - /// Call this when the source `QueryResource` changes (fetch completes, - /// cache invalidation, etc.) to keep the mapped view in sync. The - /// transform is not applied here — it is applied lazily when - /// [`data()`](Self::data) is called. Takes `Option<Arc<T>>` so callers - /// can hand over a cheap `Arc::clone` instead of cloning the full `T`. + /// The transform is not applied here; it runs lazily in [`data()`](Self::data). pub fn update_source(&mut self, data: Option<Arc<T>>) { self.source_data = data; } diff --git a/crates/gpui-query/src/core/signal.rs b/crates/gpui-query/src/core/signal.rs index 5f74b99..4f68567 100644 --- a/crates/gpui-query/src/core/signal.rs +++ b/crates/gpui-query/src/core/signal.rs @@ -1,16 +1,9 @@ //! Cooperative cancellation signal for in-flight query requests. -//! -//! [`QuerySignal`] uses a shared atomic flag so that all clones observe the -//! same cancellation state. The fetcher is expected to check `is_cancelled()` -//! periodically and abort early when possible. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -/// A cooperative cancellation signal for in-flight query requests. -/// -/// Clones share the same underlying flag, so cancelling any clone -/// cancels all of them. +/// Clones share one flag: cancelling any clone cancels all of them. #[derive(Debug, Clone)] pub struct QuerySignal { cancelled: Arc<AtomicBool>, @@ -25,20 +18,16 @@ impl PartialEq for QuerySignal { impl Eq for QuerySignal {} impl QuerySignal { - /// Create a new, non-cancelled signal. pub fn new() -> Self { Self { cancelled: Arc::new(AtomicBool::new(false)), } } - /// Signal cancellation. All clones sharing this flag will observe - /// `is_cancelled() == true` after this call. pub fn cancel(&self) { self.cancelled.store(true, Ordering::Release); } - /// Check whether cancellation has been signalled. pub fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Acquire) } diff --git a/crates/gpui-query/src/core/status.rs b/crates/gpui-query/src/core/status.rs index 5df16d1..ae6552d 100644 --- a/crates/gpui-query/src/core/status.rs +++ b/crates/gpui-query/src/core/status.rs @@ -4,28 +4,19 @@ use serde::{Deserialize, Serialize}; /// The status of a query resource. /// -/// A query transitions through these states: -/// `Idle` → `LoadingEmpty` → `Success` / `Failure` -/// `Success` → `LoadingWithData` → `Success` / `Failure` (refetch) +/// `Idle` → `LoadingEmpty` → `Success`/`Failure`; refetch: `Success` → `LoadingWithData` → terminal. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum QueryStatus { - /// No data has been fetched yet. Initial state. #[default] Idle, - /// Loading for the first time (no data available). LoadingEmpty, - /// Refetching with existing data available. LoadingWithData, - /// Data loaded successfully. Success, - /// The last fetch failed. Failure, - /// The request was cancelled. Cancelled, } impl QueryStatus { - /// Human-readable label for the status. pub fn label(self) -> &'static str { match self { Self::Idle => "Idle", @@ -37,14 +28,11 @@ impl QueryStatus { } } - /// Whether the resource is currently loading (first time or refetch). pub fn is_loading(self) -> bool { matches!(self, Self::LoadingEmpty | Self::LoadingWithData) } - /// Whether the resource is pending (no data yet and currently loading). - /// - /// Equivalent to TanStack Query's `isPending`. + /// TanStack Query's `isPending`: no data yet (`Idle` or first load). pub fn is_pending(self) -> bool { matches!(self, Self::Idle | Self::LoadingEmpty) } diff --git a/crates/gpui-query/src/lib.rs b/crates/gpui-query/src/lib.rs index 8f1ecc1..3bb2485 100644 --- a/crates/gpui-query/src/lib.rs +++ b/crates/gpui-query/src/lib.rs @@ -1,39 +1,10 @@ -//! gpui-query — Zero-boilerplate async state management for GPUI. +//! gpui-query: async state management for GPUI, inspired by TanStack Query. //! -//! Inspired by [TanStack Query v5](https://tanstack.com/query), redesigned for -//! GPUI's synchronous rendering model and Rust's ownership semantics. +//! Layers, strictly additive: `core` (serde-only state machine), `client` +//! (GPUI registry), `hook` (`use_query` & friends), `persist` (disk cache). //! -//! # v2 Improvements over v1 -//! -//! - **Options-first API** with sensible defaults — `use_query("users", fetcher, cx)` -//! - **Tuple return types** — hooks return `(Entity<QueryResource<T,E>>, Subscription)` for explicit control -//! - **Signal-always** — fetchers always receive `QuerySignal` for cooperative cancellation -//! - **Fixed signal lifecycle** — signals are cancelled on LatestWins replacement -//! - **Fixed retry loops** — single counter, signal checked between attempts -//! - **Efficient re-renders** — `cx.notify()` only on terminal state changes -//! - **`QueryError: Display + Error`** — ecosystem interop with `?` and `anyhow` -//! - **`AHashMap`** for trusted cache keys — ~2x faster lookups -//! - **Bounded `max_pages`** default — prevents unbounded memory growth -//! - **Actual mutation GC** — no more memory leaks -//! -//! # Layers -//! -//! - **`core`** — Serde-only state machine (`QueryResource`, `CachePolicy`, etc.) -//! - **`client`** — GPUI `QueryClient` registry with type-partitioned buckets -//! - **`hook`** — `use_query()` / `use_mutation()` / `use_infinite_query()` hooks -//! -//! # Quick Start -//! -//! Hooks are re-exported at the crate root for ergonomic imports: -//! -//! ```ignore -//! use gpui_query::{use_query, use_mutation, use_infinite_query, QueryClient}; -//! ``` +//! Quick start: `use gpui_query::{use_query, use_mutation, use_infinite_query, QueryClient};` -// T5: enable `#[doc(cfg(...))]` attribute gating on docs.rs builds so the -// rendered API docs annotate items with the feature gate that enables them -// (`core`, `client`, `hook`). `doc_cfg` is a nightly-only intradoc feature, -// so it is only turned on under the `docsrs` config attribute. #![cfg_attr(docsrs, feature(doc_cfg))] #[cfg(feature = "core")] @@ -48,12 +19,7 @@ pub mod client; #[cfg_attr(docsrs, doc(cfg(feature = "hook")))] pub mod hook; -// Convenience re-exports (star-export each enabled layer at crate root). -// -// `current_time_ms` is defined in both `client` and `hook` modules with -// identical implementations. Both glob re-exports are intentional so that -// users can import from either layer. Suppress the ambiguous_glob_reexports -// lint since the duplicate is harmless and both are public API. +// current_time_ms is defined identically in client and hook; the duplicate glob re-export is harmless. #[cfg(feature = "core")] #[cfg_attr(docsrs, doc(cfg(feature = "core")))] pub use core::*; @@ -67,7 +33,5 @@ pub use client::*; #[cfg_attr(docsrs, doc(cfg(feature = "hook")))] pub use hook::*; -// ── Tests ────────────────────────────────────────────────────────────── - #[cfg(test)] mod tests; diff --git a/crates/gpui-query/src/tests/core_cache/cache_ops.rs b/crates/gpui-query/src/tests/core_cache/cache_ops.rs index 3c3e8de..89845ad 100644 --- a/crates/gpui-query/src/tests/core_cache/cache_ops.rs +++ b/crates/gpui-query/src/tests/core_cache/cache_ops.rs @@ -1,13 +1,7 @@ -//! Cache invalidation and reset tests. - use crate::core::*; use crate::tests::core_cache::*; use crate::tests::test_support::*; -// ══════════════════════════════════════════════════════════════════════════ -// CACHE INVALIDATION -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn invalidate_clears_last_updated_but_retains_data() { let mut r = ttl_resource(); @@ -66,10 +60,6 @@ fn invalidate_then_refetch_refreshes_cache() { assert!(r.is_cache_fresh(completed_at + 200)); } -// ══════════════════════════════════════════════════════════════════════════ -// CACHE RESET -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn reset_clears_data_and_error() { let mut r = ttl_resource(); diff --git a/crates/gpui-query/src/tests/core_cache/cache_policy.rs b/crates/gpui-query/src/tests/core_cache/cache_policy.rs index b26fa8d..a378aa1 100644 --- a/crates/gpui-query/src/tests/core_cache/cache_policy.rs +++ b/crates/gpui-query/src/tests/core_cache/cache_policy.rs @@ -1,13 +1,7 @@ -//! TTL cache policy, StaleWhileRevalidate, and NoCache tests. - use crate::core::*; use crate::tests::core_cache::*; use crate::tests::test_support::test_sequencer; -// ══════════════════════════════════════════════════════════════════════════ -// TTL CACHE POLICY -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn ttl_cache_is_fresh_at_exact_boundary() { let mut r = ttl_resource(); @@ -84,15 +78,10 @@ fn ttl_no_short_circuit_without_data() { assert!(!r.should_short_circuit_cache(STORED_AT_MS)); } -// ══════════════════════════════════════════════════════════════════════════ -// STALE-WHILE-REVALIDATE -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn swr_fresh_within_ttl() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // stored_at=1000, TTL=1000. Fresh while age < TTL. assert!(r.is_cache_fresh(STORED_AT_MS + 500)); assert!(r.is_cache_fresh(AT_TTL_BOUNDARY)); } @@ -101,7 +90,6 @@ fn swr_fresh_within_ttl() { fn swr_stale_but_serveable_in_stale_window() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // stored_at=1000, TTL=1000, stale=2000. Past TTL but within stale window. assert!(!r.is_cache_fresh(ONE_MS_PAST_TTL), "past TTL, not fresh"); assert!(r.is_stale_but_serveable(ONE_MS_PAST_TTL)); assert!( @@ -114,7 +102,6 @@ fn swr_stale_but_serveable_in_stale_window() { fn swr_fully_expired_past_stale_window() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // stored_at=1000, total=3000. age at ONE_MS_PAST_SWR = 3001 > total => expired assert!(r.is_cache_expired(ONE_MS_PAST_SWR)); assert!(!r.is_cache_fresh(ONE_MS_PAST_SWR)); assert!(!r.is_stale_but_serveable(ONE_MS_PAST_SWR)); @@ -124,7 +111,6 @@ fn swr_fully_expired_past_stale_window() { fn swr_should_serve_stale_and_revalidate_in_stale_window() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // Between TTL boundary and stale boundary => should revalidate. assert!(r.should_serve_stale_and_revalidate(ONE_MS_PAST_TTL)); assert!(r.should_serve_stale_and_revalidate(STORED_AT_MS + 2_000)); } @@ -133,7 +119,6 @@ fn swr_should_serve_stale_and_revalidate_in_stale_window() { fn swr_should_not_serve_stale_within_ttl() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // Still fresh => no need for stale-serve. assert!(!r.should_serve_stale_and_revalidate(STORED_AT_MS + 500)); } @@ -143,7 +128,6 @@ fn swr_begin_request_stale_cache_hit_triggers_background_refetch() { let mut seq = test_sequencer(); seed_data(&mut r, "cached", STORED_AT_MS); - // One ms past TTL => stale but serveable, triggers background refetch. let result = r.begin_request(&mut seq, ONE_MS_PAST_TTL, QueryFetchMode::Normal); assert!(matches!(result, QueryBeginResult::StaleCacheHit { .. })); assert_eq!( @@ -160,7 +144,6 @@ fn swr_begin_request_expired_starts_normal_fetch() { let mut seq = test_sequencer(); seed_data(&mut r, "cached", STORED_AT_MS); - // stored_at=1000, total=3000. age at now=5000 is 4000 > total => expired let result = r.begin_request(&mut seq, STORED_AT_MS + 4_000, QueryFetchMode::Normal); assert!(matches!(result, QueryBeginResult::Started { .. })); assert_eq!(r.cache_hits(), 0, "no cache hit when expired"); @@ -170,17 +153,11 @@ fn swr_begin_request_expired_starts_normal_fetch() { fn swr_stale_boundary_exact() { let mut r = swr_resource(); seed_data(&mut r, "cached", STORED_AT_MS); - // stored_at=1000, total=3000. age at AT_SWR_BOUNDARY = 3000 == total => serveable (inclusive) assert!(r.is_stale_but_serveable(AT_SWR_BOUNDARY)); - // age at ONE_MS_PAST_SWR = 3001 > total => expired assert!(!r.is_stale_but_serveable(ONE_MS_PAST_SWR)); assert!(r.is_cache_expired(ONE_MS_PAST_SWR)); } -// ══════════════════════════════════════════════════════════════════════════ -// NO-CACHE POLICY -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn nocache_never_fresh() { let mut r = nocache_test_resource(); diff --git a/crates/gpui-query/src/tests/core_cache/data_retention.rs b/crates/gpui-query/src/tests/core_cache/data_retention.rs index 5280ff9..01c1c05 100644 --- a/crates/gpui-query/src/tests/core_cache/data_retention.rs +++ b/crates/gpui-query/src/tests/core_cache/data_retention.rs @@ -1,12 +1,6 @@ -//! Data retention tests: previous_data and rollback. - use crate::core::*; use crate::tests::core_cache::*; -// ══════════════════════════════════════════════════════════════════════════ -// DATA RETENTION: previous_data and rollback -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn previous_data_tracked_across_successive_successes() { let mut r = ttl_resource(); diff --git a/crates/gpui-query/src/tests/core_cache/mod.rs b/crates/gpui-query/src/tests/core_cache/mod.rs index c5ce6bd..e6471ec 100644 --- a/crates/gpui-query/src/tests/core_cache/mod.rs +++ b/crates/gpui-query/src/tests/core_cache/mod.rs @@ -1,14 +1,3 @@ -//! Core cache layer tests for gpui-query. -//! -//! Covers: -//! - TTL cache policy: freshness, boundary, expiry, renewal -//! - StaleWhileRevalidate: serving stale data, background refetch, full expiry -//! - NoCache: always stale/expired, no short-circuit -//! - Cache invalidation -//! - Cache reset -//! - Data retention: previous_data and rollback -//! - Cache interactions with different request policies - mod cache_ops; mod cache_policy; mod data_retention; @@ -17,29 +6,18 @@ mod request_interactions; use crate::core::*; use crate::tests::test_support::*; -// ── Named time constants ──────────────────────────────────────────────── -// -// Tests seed data at STORED_AT_MS and reason about boundary offsets from -// there, so the age arithmetic reads without magic numbers. - -/// The `stored_at` timestamp used by every seeded cache entry (ms). pub(crate) const STORED_AT_MS: u64 = 1_000; -/// TTL duration shared by both the default TTL resource and the SWR resource. pub(crate) const TTL_MS: u64 = 1_000; -/// Stale-window duration used by the SWR resource. pub(crate) const STALE_MS: u64 = 2_000; -/// Total validity window for the SWR resource (TTL + stale). pub(crate) const SWR_TOTAL_MS: u64 = TTL_MS + STALE_MS; -pub(crate) const AT_TTL_BOUNDARY: u64 = STORED_AT_MS + TTL_MS; // 2_000 -pub(crate) const ONE_MS_PAST_TTL: u64 = AT_TTL_BOUNDARY + 1; // 2_001 -pub(crate) const AT_SWR_BOUNDARY: u64 = STORED_AT_MS + SWR_TOTAL_MS; // 4_000 -pub(crate) const ONE_MS_PAST_SWR: u64 = AT_SWR_BOUNDARY + 1; // 4_001 - -// ── Helpers ────────────────────────────────────────────────────────────── +pub(crate) const AT_TTL_BOUNDARY: u64 = STORED_AT_MS + TTL_MS; +pub(crate) const ONE_MS_PAST_TTL: u64 = AT_TTL_BOUNDARY + 1; +pub(crate) const AT_SWR_BOUNDARY: u64 = STORED_AT_MS + SWR_TOTAL_MS; +pub(crate) const ONE_MS_PAST_SWR: u64 = AT_SWR_BOUNDARY + 1; pub(crate) fn ttl_resource() -> QueryResource<&'static str> { test_resource() diff --git a/crates/gpui-query/src/tests/core_cache/request_interactions.rs b/crates/gpui-query/src/tests/core_cache/request_interactions.rs index f3c35b6..982aa92 100644 --- a/crates/gpui-query/src/tests/core_cache/request_interactions.rs +++ b/crates/gpui-query/src/tests/core_cache/request_interactions.rs @@ -1,14 +1,8 @@ -//! Cache interactions with request policies and policy accessor tests. - use crate::core::*; use crate::tests::core_cache::*; use crate::tests::test_support::*; use std::num::NonZero; -// ══════════════════════════════════════════════════════════════════════════ -// CACHE INTERACTIONS WITH REQUEST POLICIES -// ══════════════════════════════════════════════════════════════════════════ - #[test] fn begin_request_short_circuits_fresh_ttl_cache() { let mut r = ttl_resource(); diff --git a/crates/gpui-query/src/tests/core_infinite_query/helpers.rs b/crates/gpui-query/src/tests/core_infinite_query/helpers.rs index 9356d90..4f05a20 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/helpers.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/helpers.rs @@ -16,9 +16,6 @@ pub fn make_bidirectional_resource() -> InfiniteQueryResource<Vec<&'static str>> ) } -/// Static page labels (`"page0"`, `"page1"`, ...) used by the default page -/// generator. Sized to comfortably cover the largest load count exercised by -/// the test suite (50 pages in `max_pages_50_allows_50_pages_and_evicts_on_51st`). const PAGE_LABELS: [&str; 64] = [ "page0", "page1", "page2", "page3", "page4", "page5", "page6", "page7", "page8", "page9", "page10", "page11", "page12", "page13", "page14", "page15", "page16", "page17", "page18", @@ -29,36 +26,13 @@ const PAGE_LABELS: [&str; 64] = [ "page55", "page56", "page57", "page58", "page59", "page60", "page61", "page62", "page63", ]; -/// Default page-content generator used by [`load_n_pages`]: `vec!["page{i}"]` -/// for page index `i`, with static labels (no allocation). -fn default_page(i: usize) -> Vec<&'static str> { - vec![PAGE_LABELS[i]] -} - -/// Convenience: load N pages via `begin_fetch_next` + `complete_page_success`. -/// Each page contains a single element `"page{i}"` produced by -/// [`default_page`]. Returns the resource with pages loaded. pub fn load_n_pages(n: usize) -> InfiniteQueryResource<Vec<&'static str>> { - load_n_pages_with(n, default_page) -} - -/// Load N pages with a caller-supplied page-content factory. -/// -/// `page_fn` is called once per page index with the page number (0-based) and -/// must return the page's data vector. This lets tests that need non-default -/// page content (e.g. typed data, multi-element pages) avoid per-page -/// allocation while keeping the common case as a one-line `load_n_pages(n)` -/// call. -pub fn load_n_pages_with( - n: usize, - page_fn: impl Fn(usize) -> Vec<&'static str>, -) -> InfiniteQueryResource<Vec<&'static str>> { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - for i in 0..n { + for (i, label) in PAGE_LABELS.iter().enumerate().take(n) { let has_more = i < n - 1; let id = r.begin_fetch_next(&mut seq, (i * 100) as u64).unwrap(); - r.complete_page_success(id, page_fn(i), has_more, true, ((i + 1) * 100) as u64); + r.complete_page_success(id, vec![*label], has_more, true, ((i + 1) * 100) as u64); } r } diff --git a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs index 6b24e90..c217699 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs @@ -1,10 +1,6 @@ -//! Tests for initial state, empty pages state, and FetchDirection modes. - use super::helpers::*; use crate::core::*; -// ── 1. Initial state ──────────────────────────────────────────────────── - #[test] fn new_resource_has_idle_state_with_empty_pages() { let r = make_resource(); @@ -30,18 +26,14 @@ fn new_resource_has_idle_state_with_empty_pages() { assert_eq!(r.max_pages(), Some(50)); assert_eq!(r.direction(), FetchDirection::ForwardOnly); - // Diagnostics assert_eq!(r.cache_hits(), 0); assert_eq!(r.cancelled_count(), 0); assert_eq!(r.ignored_results(), 0); assert_eq!(r.retry_count(), 0); - // Empty pages means data is not valid assert!(!r.is_page_data_valid()); } -// ── 9. Empty pages state ─────────────────────────────────────────────── - #[test] fn empty_pages_state_is_idle() { let r = make_resource(); @@ -52,8 +44,6 @@ fn empty_pages_state_is_idle() { assert_eq!(r.status(), QueryStatus::Idle); } -// ── 11. FetchDirection modes ──────────────────────────────────────────── - #[test] fn forward_only_defaults_has_next_true() { let r = make_resource(); @@ -88,10 +78,10 @@ fn bidirectional_allows_fetch_after_opt_in() { #[test] fn set_direction_changes_reset_behavior() { let mut r = make_resource(); - assert!(r.has_next_page()); // ForwardOnly default + assert!(r.has_next_page()); r.set_direction(FetchDirection::Bidirectional); r.reset(); - assert!(!r.has_next_page()); // Bidirectional reset default + assert!(!r.has_next_page()); assert!(!r.has_previous_page()); } diff --git a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs index b1b7789..44a4f68 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs @@ -1,10 +1,6 @@ -//! Tests for max_pages enforcement, edge cases, and evicted pages returns. - use super::helpers::*; use crate::core::*; -// ── 4. max_pages enforcement ──────────────────────────────────────────── - #[test] fn max_pages_evicts_oldest_page_on_append() { let mut r = make_resource(); @@ -16,7 +12,6 @@ fn max_pages_evicts_oldest_page_on_append() { let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); r.complete_page_success(id2, vec!["b"], true, true, 4_000); - // Third page exceeds max_pages=2, evicts oldest ("a") let id3 = r.begin_fetch_next(&mut seq, 5_000).unwrap(); r.complete_page_success(id3, vec!["c"], false, true, 6_000); @@ -36,7 +31,6 @@ fn max_pages_evicts_newest_page_on_prepend() { let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); r.complete_page_success(id2, vec!["b"], true, true, 4_000); - // Prepend a page: ["c", "a", "b"] enforced to 2 removes from back => ["c", "a"] r.set_has_previous_page(true); let id3 = r.begin_fetch_previous(&mut seq, 5_000).unwrap(); r.complete_page_success(id3, vec!["c"], false, false, 6_000); @@ -46,13 +40,10 @@ fn max_pages_evicts_newest_page_on_prepend() { assert_eq!(r.pages()[1].as_ref(), &vec!["a"]); } -// ── 5. max_pages edge cases ───────────────────────────────────────────── - #[test] fn max_pages_zero_treated_as_unbounded() { let mut r = load_n_pages(3); - // Some(0) is treated as unbounded — no eviction r.set_max_pages(Some(0)); assert_eq!(r.max_pages(), None); assert_eq!(r.page_count(), 3); @@ -69,7 +60,6 @@ fn max_pages_one_retains_only_latest_page() { let id2 = r.begin_fetch_next(&mut seq, 3_000).unwrap(); r.complete_page_success(id2, vec!["b"], true, true, 4_000); - // Only the last page is retained assert_eq!(r.page_count(), 1); assert_eq!(r.first_page(), Some(&vec!["b"])); assert_eq!(r.last_page(), Some(&vec!["b"])); @@ -87,7 +77,6 @@ fn max_pages_50_allows_50_pages_and_evicts_on_51st() { assert_eq!(r.max_pages(), Some(50)); let mut seq = RequestSequencer::new(); - // Static page labels "p0".."p50" (T9: avoids per-page format! allocation). const P_LABELS: [&str; 51] = [ "p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", @@ -95,13 +84,12 @@ fn max_pages_50_allows_50_pages_and_evicts_on_51st() { "p40", "p41", "p42", "p43", "p44", "p45", "p46", "p47", "p48", "p49", "p50", ]; - // Load 50 pages — all with has_more=true so has_next_page stays true for (i, label) in P_LABELS.iter().enumerate().take(50) { let id = r.begin_fetch_next(&mut seq, (i * 100) as u64).unwrap(); r.complete_page_success( id, vec![*label], - true, // always report more pages available + true, true, ((i + 1) * 100) as u64, ); @@ -109,7 +97,6 @@ fn max_pages_50_allows_50_pages_and_evicts_on_51st() { assert_eq!(r.page_count(), 50); assert_eq!(r.first_page(), Some(&vec!["p0"])); - // 51st page evicts p0 let id51 = r.begin_fetch_next(&mut seq, 5_000_000).unwrap(); r.complete_page_success(id51, vec!["p50"], false, true, 5_000_100); assert_eq!(r.page_count(), 50); diff --git a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs index af063f3..074e81c 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs @@ -1,12 +1,6 @@ -//! Tests for page append (fetch_next_page), prepend (fetch_previous_page), -//! signal cancellation on fetch replacement, loading status transitions, -//! and has_more propagation. - use super::helpers::*; use crate::core::*; -// ── 2. Page append (fetch_next_page) ──────────────────────────────────── - #[test] fn fetch_next_page_appends_page_and_updates_status() { let mut r = make_resource(); @@ -34,7 +28,6 @@ fn fetch_next_page_accumulates_multiple_pages() { assert_eq!(r.page_count(), 5); assert_eq!(r.first_page(), Some(&vec!["page0"])); assert_eq!(r.last_page(), Some(&vec!["page4"])); - // The last page was loaded with has_more = false assert!(!r.has_next_page()); } @@ -48,20 +41,16 @@ fn fetch_next_page_returns_none_when_no_next_page() { assert!(id.is_none()); } -// ── 3. Page prepend (fetch_previous_page) ─────────────────────────────── - #[test] fn fetch_previous_page_prepends_page() { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // Load initial page via next let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id1, vec!["page1"], true, true, 2_000); - // Enable previous and prepend r.set_has_previous_page(true); - assert!(!r.is_fetching_previous_page()); // not fetching yet + assert!(!r.is_fetching_previous_page()); let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); assert!(r.is_fetching_previous_page()); @@ -69,14 +58,13 @@ fn fetch_previous_page_prepends_page() { id2, vec!["page0"], false, - false, // is_next = false => prepend + false, 4_000, ); assert!(accepted); assert_eq!(r.page_count(), 2); assert_eq!(r.first_page(), Some(&vec!["page0"])); assert_eq!(r.last_page(), Some(&vec!["page1"])); - // has_more from completion updated has_previous_page assert!(!r.has_previous_page()); } @@ -84,13 +72,10 @@ fn fetch_previous_page_prepends_page() { fn fetch_previous_page_returns_none_when_no_previous_page() { let mut r = make_resource(); let mut seq = RequestSequencer::new(); - // ForwardOnly: has_previous_page defaults to false let id = r.begin_fetch_previous(&mut seq, 1_000); assert!(id.is_none()); } -// ── 7. Signal cancellation on page fetch replacement ──────────────────── - #[test] fn begin_fetch_next_cancels_previous_signal() { let mut r = make_resource(); @@ -100,10 +85,8 @@ fn begin_fetch_next_cancels_previous_signal() { let old_signal = r.signal().unwrap().clone(); assert!(!old_signal.is_cancelled()); - // Starting a new fetch cancels the old signal let _id2 = r.begin_fetch_next(&mut seq, 2_000).unwrap(); assert!(old_signal.is_cancelled()); - // The new signal is not cancelled assert!(!r.signal().unwrap().is_cancelled()); assert_ne!(r.signal().unwrap(), &old_signal); } @@ -117,7 +100,6 @@ fn begin_fetch_previous_cancels_previous_signal() { let old_signal = r.signal().unwrap().clone(); assert!(!old_signal.is_cancelled()); - // Switching direction cancels the old signal r.set_has_previous_page(true); let _id2 = r.begin_fetch_previous(&mut seq, 2_000).unwrap(); assert!(old_signal.is_cancelled()); @@ -127,7 +109,7 @@ fn begin_fetch_previous_cancels_previous_signal() { #[test] fn latest_wins_allows_replacement() { - let mut r = make_resource(); // LatestWins + let mut r = make_resource(); let mut seq = RequestSequencer::new(); let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); @@ -152,8 +134,6 @@ fn ignore_while_loading_prevents_replacement() { assert_eq!(r.cancelled_count(), 0); } -// ── 15. Loading status transitions ────────────────────────────────────── - #[test] fn loading_empty_when_no_pages_exist() { let mut r = make_resource(); @@ -169,7 +149,6 @@ fn loading_with_data_when_pages_exist() { let mut r = load_n_pages(1); let mut seq = RequestSequencer::new(); - // load_n_pages sets has_next_page=false for the last page, re-enable r.set_has_next_page(true); let _id = r.begin_fetch_next(&mut seq, 5_000).unwrap(); @@ -177,8 +156,6 @@ fn loading_with_data_when_pages_exist() { assert!(r.is_loading()); } -// ── 16. has_more propagation ──────────────────────────────────────────── - #[test] fn has_more_false_stops_further_fetches() { let mut r = make_resource(); @@ -188,7 +165,6 @@ fn has_more_false_stops_further_fetches() { r.complete_page_success(id, vec!["only"], false, true, 2_000); assert!(!r.has_next_page()); - // Attempting to fetch next should return None let id2 = r.begin_fetch_next(&mut seq, 3_000); assert!(id2.is_none()); } @@ -203,7 +179,6 @@ fn has_more_propagated_on_prepend() { r.set_has_previous_page(true); let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); - // has_more = false => has_previous_page set to false r.complete_page_success(id2, vec!["page0"], false, false, 4_000); assert!(!r.has_previous_page()); diff --git a/crates/gpui-query/src/tests/core_infinite_query/stale_and_completion.rs b/crates/gpui-query/src/tests/core_infinite_query/stale_and_completion.rs index 5cae44d..098aa2e 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/stale_and_completion.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/stale_and_completion.rs @@ -1,10 +1,6 @@ -//! Tests for stale request rejection and two-phase completion protocol. - use super::helpers::*; use crate::core::*; -// ── 6. Stale rejection for page fetches ───────────────────────────────── - #[test] fn stale_request_success_is_rejected() { let mut r = make_resource(); @@ -13,9 +9,7 @@ fn stale_request_success_is_rejected() { let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); let id2 = r.begin_fetch_next(&mut seq, 2_000).unwrap(); - // Completing the first (stale) request should fail assert!(!r.complete_page_success(id1, vec!["stale"], true, true, 3_000)); - // Completing the second (current) request should succeed assert!(r.complete_page_success(id2, vec!["fresh"], false, true, 3_000)); assert_eq!(r.page_count(), 1); assert_eq!(r.last_page(), Some(&vec!["fresh"])); @@ -51,7 +45,7 @@ fn stale_request_increments_ignored_results() { assert_eq!(r.ignored_results(), 1); assert!(r.complete_page_success(id2, vec!["fresh"], false, true, 3_000)); - assert_eq!(r.ignored_results(), 1); // no increment for accepted result + assert_eq!(r.ignored_results(), 1); } #[test] @@ -69,8 +63,6 @@ fn stale_failure_increments_ignored_results() { assert_eq!(r.ignored_results(), 1); } -// ── 13. Two-phase protocol ───────────────────────────────────────────── - #[test] fn accept_current_request_returns_guard_for_active_request() { let mut r = make_resource(); @@ -79,7 +71,7 @@ fn accept_current_request_returns_guard_for_active_request() { let id = r.begin_fetch_next(&mut seq, 1_000).unwrap(); let guard = r.accept_current_request(id); assert!(guard.is_some()); - assert!(r.active_request_id().is_none()); // cleared on accept + assert!(r.active_request_id().is_none()); } #[test] @@ -113,7 +105,6 @@ fn complete_failure_with_guard_preserves_pages() { let mut r = load_n_pages(1); let mut seq = RequestSequencer::new(); - // load_n_pages sets has_next_page=false for the last page, re-enable r.set_has_next_page(true); let id = r.begin_fetch_next(&mut seq, 3_000).unwrap(); diff --git a/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs b/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs index 2f4686c..045674e 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs @@ -1,11 +1,6 @@ -//! Tests for page data access, reset, is_page_data_valid, failure preserving -//! pages, invalidate, and serde roundtrip. - use super::helpers::*; use crate::core::*; -// ── 8. Page data access ──────────────────────────────────────────────── - #[test] fn pages_returns_vecdeque_in_order() { let r = load_n_pages(3); @@ -33,8 +28,6 @@ fn first_and_last_page_none_when_empty() { assert!(!r.has_data()); } -// ── 10. Reset clears all pages ───────────────────────────────────────── - #[test] fn reset_clears_all_pages_and_state() { let mut r = load_n_pages(3); @@ -55,7 +48,6 @@ fn reset_clears_all_pages_and_state() { assert!(!r.is_fetching_next_page()); assert!(!r.is_fetching_previous_page()); - // ForwardOnly defaults restored assert!(r.has_next_page()); assert!(!r.has_previous_page()); } @@ -91,8 +83,6 @@ fn reset_clears_diagnostics() { assert_eq!(r.cache_hits(), 0); } -// ── 12. is_page_data_valid across statuses ────────────────────────────── - #[test] fn is_page_data_valid_false_when_idle() { let r = make_resource(); @@ -110,13 +100,11 @@ fn is_page_data_valid_true_when_failure_with_existing_pages() { let mut r = load_n_pages(2); let mut seq = RequestSequencer::new(); - // load_n_pages sets has_next_page=false for the last page, re-enable r.set_has_next_page(true); let id = r.begin_fetch_next(&mut seq, 5_000).unwrap(); r.complete_page_failure(id, "network error".into()); - // Failure does not clear existing pages assert_eq!(r.page_count(), 2); assert!(r.is_page_data_valid()); assert_eq!(r.status(), QueryStatus::Failure); @@ -137,8 +125,6 @@ fn is_page_data_valid_false_when_failure_no_pages() { assert!(!r.is_page_data_valid()); } -// ── 14. Failure does not clear existing pages ─────────────────────────── - #[test] fn page_failure_preserves_existing_pages() { let mut r = load_n_pages(2); @@ -146,21 +132,17 @@ fn page_failure_preserves_existing_pages() { assert_eq!(r.page_count(), 2); - // load_n_pages sets has_next_page=false for the last page, re-enable r.set_has_next_page(true); let id = r.begin_fetch_next(&mut seq, 5_000).unwrap(); r.complete_page_failure(id, "timeout".into()); - // Pages remain intact assert_eq!(r.page_count(), 2); assert_eq!(r.first_page(), Some(&vec!["page0"])); assert_eq!(r.last_page(), Some(&vec!["page1"])); assert_eq!(r.status(), QueryStatus::Failure); } -// ── 17. Invalidate ───────────────────────────────────────────────────── - #[test] fn invalidate_clears_last_updated_but_preserves_pages() { let mut r = load_n_pages(2); @@ -172,22 +154,17 @@ fn invalidate_clears_last_updated_but_preserves_pages() { assert_eq!(r.page_count(), 2); } -// ── 18. Serde roundtrip ──────────────────────────────────────────────── - #[test] fn serde_roundtrip_preserves_state() { let r = load_n_pages(3); let json = serde_json::to_string(&r).unwrap(); - // Deserialize into an owned page type: serde cannot synthesize `&'static str` - // from parsed JSON, so the roundtrip target uses `Vec<String>`. let back: InfiniteQueryResource<Vec<String>> = serde_json::from_str(&json).unwrap(); assert_eq!(back.page_count(), 3); assert_eq!(back.status(), QueryStatus::Success); assert_eq!(back.first_page(), Some(&vec!["page0".to_string()])); assert_eq!(back.last_page(), Some(&vec!["page2".to_string()])); - // Signal is skipped by serde assert!(back.signal().is_none()); } @@ -195,7 +172,6 @@ fn serde_roundtrip_preserves_state() { fn serde_wire_format_uses_plain_array() { let r = load_n_pages(2); let json = serde_json::to_string(&r).unwrap(); - // VecDeque serializes as a plain array, not a VecDeque-specific format assert!(json.contains("\"pages\":[")); assert!(!json.contains("VecDeque")); } diff --git a/crates/gpui-query/src/tests/core_lifecycle/cancel_and_signals.rs b/crates/gpui-query/src/tests/core_lifecycle/cancel_and_signals.rs index c2f4ec1..e460be4 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/cancel_and_signals.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/cancel_and_signals.rs @@ -1,15 +1,6 @@ -//! Cancellation and signal lifecycle tests (sections 7-10). -//! -//! Covers: cancel from LoadingEmpty/LoadingWithData, cancel no-op, -//! signal creation, signal propagation, completion vs signal. - use crate::core::*; use crate::tests::core_lifecycle::transitions::*; -// ═══════════════════════════════════════════════════════════════════════ -// 7. Cancellation from LoadingEmpty -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn cancel_from_loading_empty() { let mut r = resource(); @@ -24,14 +15,9 @@ fn cancel_from_loading_empty() { assert_eq!(r.data(), None); assert_eq!(err_str(&r), Some("cancelled: user abort".to_string())); assert_eq!(r.cancelled_count(), 1); - // The stale rid should no longer be accepted assert!(r.accept_current_request(rid).is_none()); } -// ═══════════════════════════════════════════════════════════════════════ -// 8. Cancellation from LoadingWithData saves previous_data -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn cancel_from_loading_with_data_saves_previous_data() { let mut r = resource(); @@ -71,10 +57,6 @@ fn rollback_to_previous_restores_data_after_cancel() { assert_eq!(r.previous_data(), None); } -// ═══════════════════════════════════════════════════════════════════════ -// 9. Cancel without active request is a no-op -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn cancel_without_active_request_returns_false() { let mut r = resource(); @@ -92,17 +74,12 @@ fn cancel_after_completion_is_noop() { let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "done", 200)); - // No active request anymore assert!(!r.cancel(QueryError::cancelled("late"))); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"done")); assert_eq!(r.cancelled_count(), 0); } -// ═══════════════════════════════════════════════════════════════════════ -// 10. Cancel signal lifecycle: new signal created, old signal cancelled -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn begin_request_creates_fresh_signal() { let mut r = resource(); @@ -138,7 +115,6 @@ fn new_request_cancels_old_signal() { let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); let old_signal = r.signal().unwrap().clone(); - // Second request replaces the first (LatestWins) let _ = r.begin_request(&mut s, 200, QueryFetchMode::Normal); assert!( @@ -150,27 +126,6 @@ fn new_request_cancels_old_signal() { assert_ne!(old_signal, *new_signal, "signals must be distinct objects"); } -/// Design rationale: completing a request deliberately does NOT cancel the -/// signal. This is a conscious design choice with three motivations: -/// -/// 1. **Subscription hand-off**: Consumers that subscribed to the signal -/// during the loading phase may still need to read the signal's state -/// (e.g. to distinguish normal completion from cancellation). Cancelling -/// on completion would conflate the two cases. -/// -/// 2. **Refetch within the same signal**: If a refetch is triggered soon -/// after completion (e.g. stale-while-revalidate), reusing the same -/// signal avoids a cancel-then-recreate race window where subscribers -/// could miss the transition. -/// -/// 3. **What would break**: If completion cancelled the signal, any -/// subscriber that checked `is_cancelled()` to decide whether to -/// discard buffered data would incorrectly discard a successful result. -/// The signal's cancellation would be ambiguous -- it could mean -/// "aborted" or "finished successfully". -/// -/// Only explicit `cancel()` or `reset()` cancel the signal, because those -/// represent true interruptions where subscribers should stop work. #[test] fn completion_does_not_cancel_signal() { let mut r = resource(); diff --git a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs index 7ae4696..5a29bf7 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs @@ -1,13 +1,6 @@ -//! Invalidate, cancelled count, -//! two-phase protocol, is_current_request, and full lifecycle tests (sections 23-29). - use crate::core::*; use crate::tests::core_lifecycle::transitions::*; -// ═══════════════════════════════════════════════════════════════════════ -// 23. Invalidate: clears timestamp but keeps data and active request -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn invalidate_clears_timestamp_but_retains_data_and_active_request() { let mut r = resource(); @@ -28,18 +21,14 @@ fn invalidate_clears_timestamp_but_retains_data_and_active_request() { assert_eq!(r.last_updated_at_ms(), None, "invalidate clears timestamp"); } -// ═══════════════════════════════════════════════════════════════════════ -// 26. Multiple replacements: cancelled_count tracks all -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn cancelled_count_increments_on_each_replacement() { let mut r = resource(); let mut s = seq(); - let _ = begin(&mut r, &mut s, 100); // first - let _ = begin(&mut r, &mut s, 200); // replaces first - let _ = begin(&mut r, &mut s, 300); // replaces second + let _ = begin(&mut r, &mut s, 100); + let _ = begin(&mut r, &mut s, 200); + let _ = begin(&mut r, &mut s, 300); assert_eq!(r.cancelled_count(), 2, "two requests were replaced"); } @@ -55,10 +44,6 @@ fn cancelled_count_includes_explicit_cancel() { assert_eq!(r.cancelled_count(), 1); } -// ═══════════════════════════════════════════════════════════════════════ -// 27. Two-phase protocol: accept + complete via guard -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn accept_then_complete_success_via_guard() { let mut r = resource(); @@ -95,10 +80,6 @@ fn accept_then_complete_failure_via_guard() { assert_eq!(err_str(&r), Some("transport error: net error".to_string())); } -// ═══════════════════════════════════════════════════════════════════════ -// 28. is_current_request -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn is_current_request_matches_active() { let mut r = resource(); @@ -112,38 +93,27 @@ fn is_current_request_matches_active() { assert!(r.is_current_request(rid2), "rid2 is current"); } -// ═══════════════════════════════════════════════════════════════════════ -// 29. Full lifecycle: Idle -> LoadingEmpty -> Success -> LoadingWithData -// -> Success (updated) -> LoadingWithData -> Cancel -> Rollback -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn full_lifecycle_round_trip() { let mut r = resource(); let mut s = seq(); - // Phase 1: Idle -> LoadingEmpty assert_eq!(r.status(), QueryStatus::Idle); let (rid1, status1) = begin(&mut r, &mut s, 100); assert_eq!(status1, QueryStatus::LoadingEmpty); - // Phase 2: LoadingEmpty -> Success assert!(r.complete_current_success(rid1, "v1", 200)); assert_eq!(r.status(), QueryStatus::Success); - // Phase 3: Success -> LoadingWithData let (rid2, status2) = begin(&mut r, &mut s, 1_500); assert_eq!(status2, QueryStatus::LoadingWithData); assert_eq!(r.data(), Some(&"v1")); - // Phase 4: LoadingWithData -> Success (updated) assert!(r.complete_current_success(rid2, "v2", 1_600)); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"v2")); assert_eq!(r.previous_data(), Some(&"v1")); - // Phase 5: Success -> LoadingWithData -> Cancel - // Use t=3_000 which is beyond TTL (1_000ms from t=1_600) let (_rid3, _) = begin(&mut r, &mut s, 3_000); assert_eq!(r.status(), QueryStatus::LoadingWithData); assert!(r.cancel(QueryError::cancelled("manual"))); @@ -151,12 +121,10 @@ fn full_lifecycle_round_trip() { assert_eq!(r.data(), None); assert_eq!(r.previous_data(), Some(&"v2")); - // Phase 6: Rollback assert!(r.rollback_to_previous()); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"v2")); - // Phase 7: Full reset r.reset(); assert_eq!(r.status(), QueryStatus::Idle); assert_eq!(r.data(), None); diff --git a/crates/gpui-query/src/tests/core_lifecycle/mod.rs b/crates/gpui-query/src/tests/core_lifecycle/mod.rs index 46a99f9..dc7d991 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/mod.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/mod.rs @@ -1,8 +1,3 @@ -//! Comprehensive tests for the QueryResource lifecycle. -//! -//! Covers all state transitions, cancellation, stale request rejection, -//! reset, retry counter management, signal lifecycle, and request policies. - mod cancel_and_signals; mod data_and_lifecycle; mod policies_and_cache; diff --git a/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs b/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs index b8a2b6f..de1c2eb 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs @@ -1,17 +1,8 @@ -//! Request policies, stale data, cache behavior, and force fetch tests (sections 14-22). -//! -//! Covers: LatestWins, IgnoreWhileLoading, is_data_stale, optional success, -//! CacheHit, StaleWhileRevalidate, begin_request_with_id, force fetch. - use crate::core::*; use crate::tests::core_lifecycle::transitions::*; use crate::tests::test_support::*; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════ -// 14. Double begin_loading: LatestWins cancels old request -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn latest_wins_second_begin_replaces_active_request() { let mut r = resource(); @@ -20,7 +11,6 @@ fn latest_wins_second_begin_replaces_active_request() { let (rid1, _) = begin(&mut r, &mut s, 100); let (rid2, _) = begin(&mut r, &mut s, 200); - // rid1 is no longer active assert_ne!(r.active_request_id(), Some(rid1)); assert_eq!(r.active_request_id(), Some(rid2)); assert_eq!( @@ -29,16 +19,11 @@ fn latest_wins_second_begin_replaces_active_request() { "replaced request increments cancelled_count" ); - // Completing rid2 succeeds assert!(r.complete_current_success(rid2, "fresh", 300)); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"fresh")); } -// ═══════════════════════════════════════════════════════════════════════ -// 15. Double begin_loading: IgnoreWhileLoading ignores second -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn ignore_while_loading_rejects_second_request() { let mut r = test_resource_with_policies( @@ -71,10 +56,6 @@ fn ignore_while_loading_rejects_second_request() { ); } -// ═══════════════════════════════════════════════════════════════════════ -// 16. Stale data check (is_data_stale) -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn is_data_stale_returns_true_when_loading_with_data() { let mut r = resource(); @@ -118,10 +99,6 @@ fn is_data_stale_returns_false_on_success() { assert!(!r.is_data_stale()); } -// ═══════════════════════════════════════════════════════════════════════ -// 17. Optional success: None -> Idle (not Success) -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn optional_success_none_sets_idle_not_success() { let mut r = resource(); @@ -166,10 +143,6 @@ fn optional_success_none_clears_previous_data() { assert_eq!(r.previous_data(), Some(&"old")); } -// ═══════════════════════════════════════════════════════════════════════ -// 18. Cache short-circuit: CacheHit result -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn cache_hit_returns_no_fetch_when_fresh() { let mut r = resource(); @@ -178,7 +151,6 @@ fn cache_hit_returns_no_fetch_when_fresh() { let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "data", 200)); - // Within TTL (1000ms): should be a cache hit let result = r.begin_request(&mut s, 500, QueryFetchMode::Normal); assert_eq!(result, QueryBeginResult::CacheHit); @@ -187,10 +159,6 @@ fn cache_hit_returns_no_fetch_when_fresh() { assert_eq!(r.cache_hits(), 1); } -// ═══════════════════════════════════════════════════════════════════════ -// 19. Stale-while-revalidate: StaleCacheHit result -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn stale_while_revalidate_serves_stale_and_starts_background() { let mut r = test_resource_with_policies( @@ -206,7 +174,6 @@ fn stale_while_revalidate_serves_stale_and_starts_background() { let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "stale-data", 200)); - // At t=800: past TTL (500) but within stale window (500+1000=1500) let result = r.begin_request(&mut s, 800, QueryFetchMode::Normal); match result { @@ -230,10 +197,6 @@ fn stale_while_revalidate_serves_stale_and_starts_background() { assert_eq!(r.cache_hits(), 1); } -// ═══════════════════════════════════════════════════════════════════════ -// 20. begin_request_with_id variant -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn begin_request_with_id_uses_provided_id() { let mut r = resource(); @@ -258,17 +221,12 @@ fn begin_request_with_id_none_uses_transient_sequencer() { match result { QueryBeginResult::Started { request_id, .. } => { - // Transient sequencer starts at scope 1, sequence 1 assert_eq!(request_id, RequestId::scoped(NonZero::new(1).unwrap(), 1)); } _ => panic!("expected Started"), } } -// ═══════════════════════════════════════════════════════════════════════ -// 21. Force fetch mode bypasses cache -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn force_fetch_mode_bypasses_fresh_cache() { let mut r = resource(); @@ -277,7 +235,6 @@ fn force_fetch_mode_bypasses_fresh_cache() { let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "data", 200)); - // Even though cache is fresh (t=300 < TTL 1000), force fetch should proceed let result = r.begin_request(&mut s, 300, QueryFetchMode::Force); match result { @@ -286,10 +243,6 @@ fn force_fetch_mode_bypasses_fresh_cache() { } } -// ═══════════════════════════════════════════════════════════════════════ -// 22. Optimistic update: set_data / clear_data / rollback -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn set_data_saves_previous_for_rollback() { let mut r = resource(); diff --git a/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs b/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs index 50fa959..ab76c91 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs @@ -1,15 +1,7 @@ -//! Reset, retry counter, and stale request rejection tests (sections 11-13). -//! -//! Covers: stale request rejection, reset from every state, retry counter. - use crate::core::*; use crate::tests::core_lifecycle::transitions::*; use crate::tests::test_support::test_resource_with_policies; -// ═══════════════════════════════════════════════════════════════════════ -// 11. Stale request rejection: old results don't overwrite new -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn accept_rejects_stale_request_id() { let mut r = resource(); @@ -18,7 +10,6 @@ fn accept_rejects_stale_request_id() { let (rid1, _) = begin(&mut r, &mut s, 100); let (rid2, _) = begin(&mut r, &mut s, 200); - // rid1 is stale -- rid2 replaced it assert!(r.accept_current_request(rid1).is_none()); assert_eq!(r.ignored_results(), 1); assert_eq!(r.active_request_id(), Some(rid2)); @@ -32,7 +23,6 @@ fn stale_success_does_not_overwrite_newer_request() { let (rid1, _) = begin(&mut r, &mut s, 100); let (rid2, _) = begin(&mut r, &mut s, 200); - // Stale completion for rid1 should be rejected assert!(!r.complete_current_success(rid1, "stale", 300)); assert_eq!(r.status(), QueryStatus::LoadingEmpty); @@ -56,10 +46,6 @@ fn stale_failure_does_not_overwrite_newer_request() { assert!(r.error().is_none()); } -// ═══════════════════════════════════════════════════════════════════════ -// 12. Reset from every state -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn reset_from_idle() { let mut r = resource(); @@ -183,7 +169,6 @@ fn reset_clears_diagnostic_counters() { r.increment_retry(); r.increment_retry(); - // Replace request to bump cancelled_count let _ = begin(&mut r, &mut s, 1_500); r.reset(); @@ -194,10 +179,6 @@ fn reset_clears_diagnostic_counters() { assert_eq!(r.retry_count(), 0); } -// ═══════════════════════════════════════════════════════════════════════ -// 13. Retry counter increment and reset -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn retry_counter_increments_and_resets() { let mut r = resource(); diff --git a/crates/gpui-query/src/tests/core_lifecycle/transitions.rs b/crates/gpui-query/src/tests/core_lifecycle/transitions.rs index bea07be..3494c54 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/transitions.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/transitions.rs @@ -1,34 +1,18 @@ -//! Basic state transition tests (sections 1-6). -//! -//! Covers: Idle -> LoadingEmpty, LoadingEmpty -> Success/Failure, -//! refetch with cached data, LoadingWithData -> Success/Failure. - use crate::core::*; use crate::tests::test_support::*; -// ── Helpers ──────────────────────────────────────────────────────────── - -/// Create a default test resource with LatestWins policy. pub fn resource() -> QueryResource<&'static str> { test_resource() } -/// Create a fresh sequencer. pub fn seq() -> RequestSequencer { test_sequencer() } -/// Extract the error display string from a resource. pub fn err_str(r: &QueryResource<&'static str>) -> Option<String> { r.error().map(|e| e.to_string()) } -/// Begin a request, returning (request_id, status). -/// -/// Panics with a descriptive message if the result is not `Started` or -/// `StaleCacheHit`. This includes `CacheHit` (which means the cache was -/// still fresh at `now_ms` -- likely a TTL miscalculation in the test) -/// and `IgnoredWhileLoading` (which means a request was already active). pub fn begin( r: &mut QueryResource<&'static str>, seq: &mut RequestSequencer, @@ -68,10 +52,6 @@ pub fn begin( } } -// ═══════════════════════════════════════════════════════════════════════ -// 1. Idle -> LoadingEmpty -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn idle_to_loading_empty_transitions_correctly() { let mut r = resource(); @@ -92,10 +72,6 @@ fn idle_to_loading_empty_transitions_correctly() { assert_eq!(r.error(), None, "error should be cleared on begin_loading"); } -// ═══════════════════════════════════════════════════════════════════════ -// 2. LoadingEmpty -> Success -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn loading_empty_to_success_with_data() { let mut r = resource(); @@ -112,10 +88,6 @@ fn loading_empty_to_success_with_data() { assert!(r.error().is_none()); } -// ═══════════════════════════════════════════════════════════════════════ -// 3. LoadingEmpty -> Failure -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn loading_empty_to_failure() { let mut r = resource(); @@ -135,20 +107,14 @@ fn loading_empty_to_failure() { assert_eq!(r.last_updated_at_ms(), Some(200)); } -// ═══════════════════════════════════════════════════════════════════════ -// 4. Idle -> LoadingWithData (refetch with existing cached data) -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn success_to_loading_with_data_on_refetch() { let mut r = resource(); let mut s = seq(); - // Seed data via a successful fetch let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "cached", 200)); - // Refetch (beyond TTL so cache doesn't short-circuit) let (rid2, status) = begin(&mut r, &mut s, 1_500); assert_eq!(status, QueryStatus::LoadingWithData); @@ -163,20 +129,14 @@ fn success_to_loading_with_data_on_refetch() { assert_eq!(r.active_request_id(), Some(rid2)); } -// ═══════════════════════════════════════════════════════════════════════ -// 5. LoadingWithData -> Success (data updated) -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn loading_with_data_to_success_updates_data() { let mut r = resource(); let mut s = seq(); - // First fetch let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "old", 200)); - // Refetch let (rid2, _) = begin(&mut r, &mut s, 1_500); assert!(r.complete_current_success(rid2, "new", 1_600)); @@ -190,20 +150,14 @@ fn loading_with_data_to_success_updates_data() { assert_eq!(r.last_updated_at_ms(), Some(1_600)); } -// ═══════════════════════════════════════════════════════════════════════ -// 6. LoadingWithData -> Failure (data retained; only cancel clears data) -// ═══════════════════════════════════════════════════════════════════════ - #[test] fn loading_with_data_to_failure_retains_data() { let mut r = resource(); let mut s = seq(); - // First fetch succeeds let (rid, _) = begin(&mut r, &mut s, 100); assert!(r.complete_current_success(rid, "cached", 200)); - // Refetch fails let (rid2, _) = begin(&mut r, &mut s, 1_500); assert!(r.complete_current_failure(rid2, QueryError::transport("timeout"), 1_600)); @@ -219,8 +173,6 @@ fn loading_with_data_to_failure_retains_data() { Some(1_600), "failure updates last_updated_at" ); - // Cached data is still within TTL window relative to its original timestamp, - // but the status is Failure, not Success. assert!(r.is_data_stale(), "data with Failure status is stale"); } diff --git a/crates/gpui-query/src/tests/core_mutation/cancellation.rs b/crates/gpui-query/src/tests/core_mutation/cancellation.rs index 1739567..6f27f2b 100644 --- a/crates/gpui-query/src/tests/core_mutation/cancellation.rs +++ b/crates/gpui-query/src/tests/core_mutation/cancellation.rs @@ -1,9 +1,5 @@ -//! Cancellation, signal propagation, and cancelled_count tests. - use crate::core::*; -// -- Cancellation cancels signal and sets Failure -- - #[test] fn cancel_during_loading_sets_failure() { let mut m: MutationResource<&'static str, i32> = @@ -26,8 +22,6 @@ fn cancel_during_loading_sets_failure() { assert_eq!(m.cancelled_count(), 1); } -// -- Cancel is a no-op on Idle, Success, Failure -- - #[test] fn cancel_on_idle_is_noop() { let mut m: MutationResource<&'static str, i32> = @@ -63,8 +57,6 @@ fn cancel_on_failure_is_noop() { assert_eq!(m.cancelled_count(), 0); } -// -- cancelled_count increments across multiple cancellations -- - #[test] fn cancelled_count_increments_across_mutations() { let mut m: MutationResource<&'static str, i32> = diff --git a/crates/gpui-query/src/tests/core_mutation/lifecycle.rs b/crates/gpui-query/src/tests/core_mutation/lifecycle.rs index 5f59d36..05b8324 100644 --- a/crates/gpui-query/src/tests/core_mutation/lifecycle.rs +++ b/crates/gpui-query/src/tests/core_mutation/lifecycle.rs @@ -1,9 +1,5 @@ -//! Basic lifecycle, reset, latest-wins, data preservation, key, and misc tests. - use crate::core::*; -// -- New mutation is idle -- - #[test] fn new_mutation_is_idle() { let m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::no_retries()); @@ -21,17 +17,13 @@ fn new_mutation_is_idle() { assert!(m.key().is_none()); } -// -- Full lifecycle: Idle -> Loading -> Success -- - #[test] fn lifecycle_idle_to_loading_to_success() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::no_retries()); - // Idle assert_eq!(m.status(), MutationStatus::Idle); - // Begin -> Loading m.begin("update-user"); assert!(m.is_loading()); assert_eq!(m.variables(), Some(&"update-user")); @@ -39,18 +31,14 @@ fn lifecycle_idle_to_loading_to_success() { assert!(m.signal().is_some()); assert!(!m.signal().unwrap().is_cancelled()); - // Complete with data -> Success m.complete_success(42); assert!(m.is_success()); assert_eq!(m.data(), Some(&42)); assert!(m.error().is_none()); assert!(m.signal().is_none()); - // Variables persist through success assert_eq!(m.variables(), Some(&"update-user")); } -// -- Full lifecycle: Idle -> Loading -> Failure -- - #[test] fn lifecycle_idle_to_loading_to_failure() { let mut m: MutationResource<&'static str, i32> = @@ -69,13 +57,10 @@ fn lifecycle_idle_to_loading_to_failure() { assert_eq!(m.variables(), Some(&"delete-user")); } -// -- Reset returns to Idle, clears everything -- - #[test] fn reset_returns_to_idle_and_clears_all_state() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(3)); - // Build up some state: load -> fail -> retry -> cancel m.begin("vars"); m.complete_failure(QueryError::response("fail")); assert!(m.retry()); @@ -95,8 +80,6 @@ fn reset_returns_to_idle_and_clears_all_state() { assert!(m.signal().is_none()); } -// -- Reset cancels in-flight signal -- - #[test] fn reset_cancels_in_flight_signal() { let mut m: MutationResource<&'static str, i32> = @@ -114,8 +97,6 @@ fn reset_cancels_in_flight_signal() { assert!(m.signal().is_none()); } -// -- LatestWins: begin cancels previous in-flight signal -- - #[test] fn begin_cancels_previous_signal_on_replacement() { let mut m: MutationResource<&'static str, i32> = @@ -125,7 +106,6 @@ fn begin_cancels_previous_signal_on_replacement() { let first_signal = m.signal().unwrap().clone(); assert!(!first_signal.is_cancelled()); - // Second begin while first is still loading -> LatestWins m.begin("second"); assert!(first_signal.is_cancelled(), "old signal must be cancelled"); assert!( @@ -136,8 +116,6 @@ fn begin_cancels_previous_signal_on_replacement() { assert_eq!(m.retry_count(), 0, "begin resets retry_count"); } -// -- LatestWins: data from previous success is overwritten -- - #[test] fn begin_clears_error_from_previous_failure() { let mut m: MutationResource<&'static str, i32> = @@ -153,41 +131,32 @@ fn begin_clears_error_from_previous_failure() { assert!(m.error().is_none(), "begin clears previous error"); } -// -- Data preservation: success data persists until failure -- - #[test] fn success_data_preserved_until_next_failure() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(2)); - // First invocation succeeds m.begin("v1"); m.complete_success(100); assert_eq!(m.data(), Some(&100)); - // Second invocation fails -- data is cleared m.begin("v2"); m.complete_failure(QueryError::response("fail")); assert!(m.data().is_none(), "failure clears data"); - // Third invocation succeeds again -- new data m.begin("v3"); m.complete_success(200); assert_eq!(m.data(), Some(&200)); } -// -- begin resets retry_count for fresh invocation -- - #[test] fn begin_resets_retry_count_allowing_fresh_retries() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(1)); - // First invocation: exhaust retries m.begin("v1"); m.complete_failure(QueryError::response("fail 1")); assert_eq!(m.retry_count(), 1); assert!(!m.should_retry(), "retries exhausted"); - // Second invocation: begin resets retry_count m.begin("v2"); assert_eq!(m.retry_count(), 0, "begin must reset retry_count"); assert!(m.should_retry(), "should_retry is true after fresh begin"); @@ -197,8 +166,6 @@ fn begin_resets_retry_count_allowing_fresh_retries() { assert!(!m.should_retry(), "retries exhausted again"); } -// -- Mutation with key association -- - #[test] fn mutation_with_key_association() { let m: MutationResource<&'static str, i32> = @@ -214,8 +181,6 @@ fn mutation_without_key() { assert!(m.key().is_none()); } -// -- Status labels -- - #[test] fn status_labels() { assert_eq!(MutationStatus::Idle.label(), "Idle"); @@ -224,8 +189,6 @@ fn status_labels() { assert_eq!(MutationStatus::Failure.label(), "Failure"); } -// -- retry_policy accessor returns the configured policy -- - #[test] fn retry_policy_accessor() { let policy = RetryPolicy::new(5) @@ -236,27 +199,22 @@ fn retry_policy_accessor() { assert_eq!(m.retry_policy().max_retries, 5); } -// -- Multiple sequential mutations work correctly -- - #[test] fn multiple_sequential_mutations() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::no_retries()); - // First mutation m.begin("a"); m.complete_success(1); assert_eq!(m.data(), Some(&1)); assert_eq!(m.variables(), Some(&"a")); - // Second mutation m.begin("b"); assert_eq!(m.retry_count(), 0, "retry_count reset on new begin"); m.complete_success(2); assert_eq!(m.data(), Some(&2)); assert_eq!(m.variables(), Some(&"b")); - // Third mutation fails m.begin("c"); m.complete_failure(QueryError::response("err")); assert!(m.data().is_none()); diff --git a/crates/gpui-query/src/tests/core_mutation/mod.rs b/crates/gpui-query/src/tests/core_mutation/mod.rs index ca98942..5cbc3aa 100644 --- a/crates/gpui-query/src/tests/core_mutation/mod.rs +++ b/crates/gpui-query/src/tests/core_mutation/mod.rs @@ -1,5 +1,3 @@ -//! Comprehensive tests for `MutationResource` in gpui-query. - mod cancellation; mod lifecycle; mod retry; diff --git a/crates/gpui-query/src/tests/core_mutation/retry.rs b/crates/gpui-query/src/tests/core_mutation/retry.rs index 44881e0..cd138ff 100644 --- a/crates/gpui-query/src/tests/core_mutation/retry.rs +++ b/crates/gpui-query/src/tests/core_mutation/retry.rs @@ -1,9 +1,5 @@ -//! Retry behaviour, retry count tracking, prepare_retry, increment/reset retry helpers. - use crate::core::*; -// -- Retry from Failure transitions to Loading -- - #[test] fn retry_from_failure_goes_to_loading() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(2)); @@ -24,21 +20,17 @@ fn retry_from_failure_goes_to_loading() { assert_eq!(m.variables(), Some(&"vars"), "variables preserved"); } -// -- Retry count is respected (max reached) -- - #[test] fn retry_respects_max_retries() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(2)); m.begin("vars"); - // First failure -> retry_count = 1 m.complete_failure(QueryError::response("fail 1")); assert_eq!(m.retry_count(), 1); assert!(m.should_retry(), "1 < 2"); assert!(m.retry()); - // Second failure -> retry_count = 2 m.complete_failure(QueryError::response("fail 2")); assert_eq!(m.retry_count(), 2); assert!(!m.should_retry(), "2 is not < 2"); @@ -48,30 +40,22 @@ fn retry_respects_max_retries() { assert_eq!(m.error().unwrap().message(), "fail 2"); } -// -- Retry only works from Failure status -- - #[test] fn retry_only_from_failure_status() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(3)); - // Cannot retry from Idle assert!(!m.retry()); - // Cannot retry from Loading m.begin("vars"); assert!(!m.retry()); - // Can retry from Failure m.complete_failure(QueryError::response("fail")); assert!(m.retry()); - // After retry, back to Loading -- cannot retry again assert!(m.is_loading()); assert!(!m.retry()); } -// -- Retry creates a fresh (non-cancelled) signal -- - #[test] fn retry_creates_fresh_signal() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(2)); @@ -80,7 +64,6 @@ fn retry_creates_fresh_signal() { m.complete_failure(QueryError::response("fail")); - // old signal was cleared on failure assert!(m.signal().is_none()); let retried = m.retry(); @@ -91,13 +74,9 @@ fn retry_creates_fresh_signal() { !new_signal.is_cancelled(), "fresh signal after retry must not be cancelled" ); - // old_signal is independent -- it may or may not be cancelled depending on impl, - // but new_signal must definitely be fresh and uncancelled assert_ne!(old_signal, *new_signal, "signals must be different objects"); } -// -- prepare_retry stays in Loading with fresh signal -- - #[test] fn prepare_retry_stays_in_loading_with_fresh_signal() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(3)); @@ -108,7 +87,6 @@ fn prepare_retry_stays_in_loading_with_fresh_signal() { m.prepare_retry(); - // Must still be Loading -- no transient Failure flash assert!(m.is_loading(), "prepare_retry must not leave Loading"); assert!(m.error().is_none()); assert!( @@ -120,32 +98,25 @@ fn prepare_retry_stays_in_loading_with_fresh_signal() { assert!(!new_signal.is_cancelled(), "new signal must be fresh"); } -// -- prepare_retry is no-op when not in Loading -- - #[test] fn prepare_retry_noop_when_not_loading() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(3)); - // No-op on Idle m.prepare_retry(); assert!(m.is_idle()); - // No-op on Success m.begin("vars"); m.complete_success(42); m.prepare_retry(); assert!(m.is_success()); assert_eq!(m.data(), Some(&42)); - // No-op on Failure m.begin("vars"); m.complete_failure(QueryError::response("fail")); m.prepare_retry(); assert!(m.is_failure()); } -// -- increment_retry bumps counter without state transition -- - #[test] fn increment_retry_bumps_counter_without_state_change() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(5)); @@ -166,8 +137,6 @@ fn increment_retry_bumps_counter_without_state_change() { assert_eq!(m.retry_count(), 3); } -// -- reset_retry_count zeroes the counter -- - #[test] fn reset_retry_count_zeroes_counter() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(5)); @@ -180,16 +149,11 @@ fn reset_retry_count_zeroes_counter() { assert_eq!(m.retry_count(), 0); } -// -- Saturating retry_count via repeated complete_failure+retry cycles -- - #[test] fn retry_count_increments_saturating_on_complete_failure() { - // Use a very high retry limit so we can observe multiple complete_failure increments. let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(100)); m.begin("vars"); - // Each complete_failure increments retry_count by 1 (saturating_add). - // Retry clears the Failure state so we can fail again without a fresh begin. for i in 1..=10u32 { m.complete_failure(QueryError::response("fail")); assert_eq!( @@ -205,23 +169,14 @@ fn retry_count_increments_saturating_on_complete_failure() { assert_eq!(m.retry_count(), 10); } -// -- Retry is allowed after cancel sets Failure state -- - #[test] fn retry_allowed_after_cancel_sets_failure() { let mut m: MutationResource<&'static str, i32> = MutationResource::new(RetryPolicy::new(3)); m.begin("vars"); m.cancel(QueryError::cancelled("abort")); - // Even though retries remain, cancel is terminal for this invocation - // and retry only works from Failure when the mutation failed via complete_failure. - // cancel also sets Failure, so let's check should_retry logic: assert_eq!(m.retry_count(), 0, "cancel does not increment retry_count"); - // Actually cancel sets Failure and retry_count is still 0, so should_retry is true - // but retry() checks status == Failure AND should_retry. - // Since cancel sets Failure, retry should be possible if should_retry. assert!(m.should_retry(), "retry_count=0 < max_retries=3"); - // retry() should succeed since status is Failure and retries remain assert!(m.retry(), "retry from cancelled Failure should work"); assert!(m.is_loading()); } diff --git a/crates/gpui-query/src/tests/core_policy_types/mod.rs b/crates/gpui-query/src/tests/core_policy_types/mod.rs index 1b1e0de..9ad7107 100644 --- a/crates/gpui-query/src/tests/core_policy_types/mod.rs +++ b/crates/gpui-query/src/tests/core_policy_types/mod.rs @@ -1,10 +1,3 @@ -//! Tests for RetryPolicy, RefetchTrigger, QueryError, QueryStatus, -//! QueryTimestamp, and RequestId edge cases. -//! -//! Covers untested scenarios across all core policy/value types. -//! -//! Note: NetworkMode is tested inline in core/network_mode.rs. - mod policy_and_status_types; mod query_error; mod retry_policy; diff --git a/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs b/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs index 9f980ae..53c3c0b 100644 --- a/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs +++ b/crates/gpui-query/src/tests/core_policy_types/policy_and_status_types.rs @@ -1,14 +1,7 @@ -//! Tests for QueryStatus, QueryTimestamp, RequestId, MutationStatus, -//! CachePolicy, RequestPolicy, and QueryFetchMode. - use crate::core::*; use crate::tests::test_support::assert_serde_roundtrip; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════════ -// QueryStatus -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_status_default_is_idle() { assert_eq!(QueryStatus::default(), QueryStatus::Idle); @@ -56,10 +49,6 @@ fn query_status_serde_roundtrip() { ]); } -// ═══════════════════════════════════════════════════════════════════════════ -// QueryTimestamp -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_timestamp_from_millis() { let ts = QueryTimestamp::from_millis(1_000); @@ -103,10 +92,6 @@ fn query_timestamp_equality() { assert_ne!(a, c); } -// ═══════════════════════════════════════════════════════════════════════════ -// RequestId -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn request_id_hash_consistency() { use std::collections::HashSet; @@ -122,7 +107,7 @@ fn request_id_hash_consistency() { #[test] fn request_id_copy_semantics() { let a = RequestId::scoped(NonZero::new(5).unwrap(), 10); - let b = a; // Copy + let b = a; assert_eq!(a, b); } @@ -134,10 +119,6 @@ fn request_id_serde_roundtrip() { assert_eq!(back, id); } -// ═══════════════════════════════════════════════════════════════════════════ -// MutationStatus -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn mutation_status_default_is_idle() { assert_eq!(MutationStatus::default(), MutationStatus::Idle); @@ -161,10 +142,6 @@ fn mutation_status_serde_roundtrip() { ]); } -// ═══════════════════════════════════════════════════════════════════════════ -// CachePolicy edge cases -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn cache_policy_is_fresh_at_zero_age() { let policy = CachePolicy::Ttl { ttl_ms: 100 }; @@ -198,13 +175,10 @@ fn cache_policy_swr_is_stale_between_ttl_and_total() { ttl_ms: 100, stale_ms: 200, }; - // Within TTL: not stale assert!(!policy.is_stale_but_serveable(50)); assert!(!policy.is_stale_but_serveable(100)); - // Between TTL and total (100 < age <= 300): stale assert!(policy.is_stale_but_serveable(101)); assert!(policy.is_stale_but_serveable(300)); - // Past total: not stale (expired) assert!(!policy.is_stale_but_serveable(301)); } diff --git a/crates/gpui-query/src/tests/core_policy_types/query_error.rs b/crates/gpui-query/src/tests/core_policy_types/query_error.rs index dc2ffe3..1fd7781 100644 --- a/crates/gpui-query/src/tests/core_policy_types/query_error.rs +++ b/crates/gpui-query/src/tests/core_policy_types/query_error.rs @@ -1,12 +1,6 @@ -//! Tests for QueryError and QueryErrorKind edge cases. - use crate::core::*; use crate::tests::test_support::assert_serde_roundtrip; -// ═══════════════════════════════════════════════════════════════════════════ -// QueryError -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn query_error_kinds() { assert_eq!(QueryError::cancelled("x").kind(), QueryErrorKind::Cancelled); @@ -68,8 +62,6 @@ fn query_error_from_string_ref() { #[test] fn query_error_as_ref_str() { let err = QueryError::response("detail"); - // Disambiguate: QueryError impls both AsRef<str> and AsRef<Arc<str>>, - // so a bare `err.as_ref()` is ambiguous (E0283). let s: &str = err.as_ref(); assert_eq!(s, "detail"); } @@ -142,8 +134,6 @@ fn query_error_sanitized_home_path() { #[test] fn query_error_sanitized_users_path_uppercase() { - // Path matching is case-insensitive, so the macOS "/Users/" prefix must - // be redacted just like "/home/". let err = QueryError::unknown("error in /Users/admin/.env leaked"); let clean = err.sanitized(); assert!(!clean.message().contains("/Users/admin/.env")); @@ -161,7 +151,6 @@ fn query_error_sanitized_multiple_email_addresses() { #[test] fn query_error_sanitized_hex_key_16_chars() { - // Exactly 16 hex chars => redacted let err = QueryError::response("key a1b2c3d4e5f6a1b2 is invalid"); let clean = err.sanitized(); assert!(clean.message().contains("[REDACTED_HEX]")); @@ -170,7 +159,6 @@ fn query_error_sanitized_hex_key_16_chars() { #[test] fn query_error_sanitized_hex_key_15_chars_not_redacted() { - // 15 hex chars => NOT redacted (< 16 threshold) let err = QueryError::response("key a1b2c3d4e5f6a1b is short"); let clean = err.sanitized(); assert!(clean.message().contains("a1b2c3d4e5f6a1b")); diff --git a/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs b/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs index 5303d4c..bb4bd61 100644 --- a/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs +++ b/crates/gpui-query/src/tests/core_policy_types/retry_policy.rs @@ -1,12 +1,6 @@ -//! Tests for RetryPolicy and RefetchTrigger. - use crate::core::*; use crate::tests::test_support::assert_serde_roundtrip; -// ═══════════════════════════════════════════════════════════════════════════ -// RetryPolicy -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn retry_policy_default_is_3_with_exponential() { let policy = RetryPolicy::default(); @@ -50,7 +44,6 @@ fn retry_policy_builder_chain() { #[test] fn retry_policy_delay_for_attempt_linear_without_backoff() { let policy = RetryPolicy::new(3).with_delay(200); - // Without exponential backoff, delay is constant regardless of attempt assert_eq!(policy.delay_for_attempt(0), 200); assert_eq!(policy.delay_for_attempt(1), 200); assert_eq!(policy.delay_for_attempt(5), 200); @@ -64,14 +57,13 @@ fn retry_policy_delay_for_attempt_exponential() { .with_exponential_backoff() .with_max_delay(10_000); - assert_eq!(policy.delay_for_attempt(0), 100); // 100 * 2^0 = 100 - assert_eq!(policy.delay_for_attempt(1), 200); // 100 * 2^1 = 200 - assert_eq!(policy.delay_for_attempt(2), 400); // 100 * 2^2 = 400 - assert_eq!(policy.delay_for_attempt(3), 800); // 100 * 2^3 = 800 - assert_eq!(policy.delay_for_attempt(4), 1600); // 100 * 2^4 = 1600 - assert_eq!(policy.delay_for_attempt(5), 3200); // 100 * 2^5 = 3200 - assert_eq!(policy.delay_for_attempt(6), 6400); // 100 * 2^6 = 6400 - // 100 * 2^7 = 12800, capped by max_delay=10000 + assert_eq!(policy.delay_for_attempt(0), 100); + assert_eq!(policy.delay_for_attempt(1), 200); + assert_eq!(policy.delay_for_attempt(2), 400); + assert_eq!(policy.delay_for_attempt(3), 800); + assert_eq!(policy.delay_for_attempt(4), 1600); + assert_eq!(policy.delay_for_attempt(5), 3200); + assert_eq!(policy.delay_for_attempt(6), 6400); assert_eq!(policy.delay_for_attempt(7), 10_000); } @@ -81,7 +73,6 @@ fn retry_policy_delay_for_attempt_capped_by_absolute_max() { .with_delay(u64::MAX) .with_exponential_backoff() .with_max_delay(u64::MAX); - // delay * 2^62 overflows => u64::MAX, then capped by ABSOLUTE_MAX_DELAY_MS = 3_600_000 let delay = policy.delay_for_attempt(62); assert_eq!(delay, 3_600_000); } @@ -92,11 +83,8 @@ fn retry_policy_delay_for_attempt_shift_capped_at_62() { .with_delay(1) .with_exponential_backoff() .with_max_delay(u64::MAX); - // shift is capped at 62, so 1 << 62 = 4611686018427387904, - // but ABSOLUTE_MAX_DELAY_MS (3_600_000) still caps it. let delay = policy.delay_for_attempt(62); assert_eq!(delay, 3_600_000, "delay capped by absolute max"); - // Attempt 63 should produce the same (also capped) let delay_63 = policy.delay_for_attempt(63); assert_eq!(delay_63, 3_600_000, "delay still capped by absolute max"); } @@ -139,10 +127,6 @@ fn retry_policy_equality() { assert_ne!(a, c); } -// ═══════════════════════════════════════════════════════════════════════════ -// RefetchTrigger -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn refetch_trigger_default_is_always() { assert_eq!(RefetchTrigger::default(), RefetchTrigger::Always); diff --git a/crates/gpui-query/src/tests/core_request/mod.rs b/crates/gpui-query/src/tests/core_request/mod.rs index cdf8a44..e201742 100644 --- a/crates/gpui-query/src/tests/core_request/mod.rs +++ b/crates/gpui-query/src/tests/core_request/mod.rs @@ -1,13 +1,3 @@ -//! Comprehensive tests for REQUEST MANAGEMENT in gpui-query. -//! -//! Covers: -//! - RequestId: construction, fields, ordering, label, equality -//! - RequestSequencer: monotonicity, scope advancement, wrapping at u64::MAX -//! - RequestGuard: proof-of-ownership, scope-based rejection, into_request_id -//! - RequestPolicy: LatestWins cancels previous, IgnoreWhileLoading keeps previous -//! - QuerySignal: creation per request, cancellation propagation across clones -//! - begin_request_with_id variant - mod request_id_sequencer; mod request_lifecycle; mod request_policy; diff --git a/crates/gpui-query/src/tests/core_request/request_id_sequencer.rs b/crates/gpui-query/src/tests/core_request/request_id_sequencer.rs index ed1538b..555a8f9 100644 --- a/crates/gpui-query/src/tests/core_request/request_id_sequencer.rs +++ b/crates/gpui-query/src/tests/core_request/request_id_sequencer.rs @@ -1,16 +1,6 @@ -//! Tests for RequestId and RequestSequencer. -//! -//! Covers: -//! - RequestId: construction, fields, ordering, label, equality -//! - RequestSequencer: monotonicity, scope advancement, wrapping at u64::MAX - use crate::core::{RequestId, RequestSequencer}; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════════ -// RequestId basics -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn request_id_scoped_accesses_scope_and_value() { let id = RequestId::scoped(NonZero::new(3).unwrap(), 7); @@ -40,16 +30,10 @@ fn request_id_ordering_is_lexicographic() { let a = RequestId::scoped(NonZero::new(1).unwrap(), 100); let b = RequestId::scoped(NonZero::new(2).unwrap(), 1); let c = RequestId::scoped(NonZero::new(1).unwrap(), 200); - // scope is compared first assert!(a < b, "scope 1 < scope 2 regardless of sequence"); - // same scope, sequence compared assert!(a < c, "scope 1 seq 100 < scope 1 seq 200"); } -// ═══════════════════════════════════════════════════════════════════════════ -// RequestSequencer monotonicity -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn sequencer_starts_at_scope_1_seq_1() { let mut seq = RequestSequencer::new(); @@ -97,22 +81,16 @@ fn sequencer_is_current_scope_tracks_scope_changes() { assert!(seq.is_current_scope(id_in_new_scope)); } -// ═══════════════════════════════════════════════════════════════════════════ -// RequestSequencer wrapping (u64::MAX → scope advance) -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn sequencer_advances_scope_when_sequence_reaches_max() { let mut seq = RequestSequencer { scope_id: NonZero::new(5).unwrap(), next_request_id: u64::MAX, }; - // This call should produce scope 5, seq u64::MAX and then advance scope. let id = seq.next_request(); assert_eq!(id.scope_id(), NonZero::new(5).unwrap()); assert_eq!(id.value(), u64::MAX); - // After advancing, the next id should be in scope 6, seq 1. let next_id = seq.next_request(); assert_eq!( next_id.scope_id(), @@ -132,12 +110,10 @@ fn sequencer_scope_id_wraps_to_1_on_overflow() { scope_id: NonZero::new(u64::MAX).unwrap(), next_request_id: u64::MAX, }; - // First call returns (u64::MAX, u64::MAX) and then advances scope. let id = seq.next_request(); assert_eq!(id.scope_id(), NonZero::new(u64::MAX).unwrap()); assert_eq!(id.value(), u64::MAX); - // scope_id.checked_add(1) overflows -> wraps to 1 let next_id = seq.next_request(); assert_eq!( next_id.scope_id(), diff --git a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs index 16fa0e4..e60ca77 100644 --- a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs @@ -1,10 +1,3 @@ -//! Tests for RequestGuard, two-phase completion, and begin_request_with_id. -//! -//! Covers: -//! - RequestGuard: proof-of-ownership, scope-based rejection, into_request_id -//! - Two-phase completion via guard: complete_success, complete_failure, stale rejection -//! - begin_request_with_id variant - use crate::core::{ CachePolicy, QueryBeginResult, QueryFetchMode, QueryResource, QueryStatus, RequestId, RequestPolicy, @@ -14,10 +7,6 @@ use crate::tests::test_support::{ }; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════════ -// RequestGuard: proof-of-ownership (obtained via accept_current_request) -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn guard_holds_the_correct_request_id() { let mut resource: QueryResource<&str> = @@ -41,7 +30,6 @@ fn guard_into_request_id_consumes_guard() { let guard = resource.accept_current_request(rid).unwrap(); let extracted = guard.into_request_id(); assert_eq!(extracted, rid); - // guard is consumed — calling guard.request_id() here would not compile. } #[test] @@ -64,10 +52,8 @@ fn accept_current_request_rejects_stale_request_id() { test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut seq = test_sequencer(); - // Start request 1 let old_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); - // Start request 2 — replaces request 1 under LatestWins let result = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let _new_id = match result { QueryBeginResult::Started { @@ -81,7 +67,6 @@ fn accept_current_request_rejects_stale_request_id() { other => panic!("expected Started, got {:?}", other), }; - // Trying to accept the old request should fail let result = resource.accept_current_request(old_id); assert!( result.is_none(), @@ -90,10 +75,6 @@ fn accept_current_request_rejects_stale_request_id() { assert_eq!(resource.ignored_results(), 1); } -// ═══════════════════════════════════════════════════════════════════════════ -// Two-phase completion via guard -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn complete_success_consumes_guard_and_sets_data() { let mut resource: QueryResource<&str> = @@ -132,13 +113,10 @@ fn complete_convenience_method_rejects_stale_id() { test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut seq = test_sequencer(); - // Request 1 let old_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); - // Request 2 replaces request 1 let _ = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); - // Trying to complete old request should fail let completed = resource.complete_current_success(old_id, "stale data", TEST_NOW_MS); assert!(!completed, "stale request should not be completed"); assert!( @@ -147,10 +125,6 @@ fn complete_convenience_method_rejects_stale_id() { ); } -// ═══════════════════════════════════════════════════════════════════════════ -// begin_request_with_id variant -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn begin_request_with_id_uses_provided_id() { let mut resource: QueryResource<&str> = @@ -176,7 +150,6 @@ fn begin_request_with_id_none_falls_back_to_transient_sequencer() { QueryBeginResult::Started { request_id, .. } => request_id, other => panic!("expected Started, got {:?}", other), }; - // Transient sequencer starts at scope 1, seq 1 assert_eq!(rid.scope_id(), NonZero::new(1).unwrap()); assert_eq!(rid.value(), 1); } diff --git a/crates/gpui-query/src/tests/core_request/request_policy.rs b/crates/gpui-query/src/tests/core_request/request_policy.rs index 1a5f887..0d953d3 100644 --- a/crates/gpui-query/src/tests/core_request/request_policy.rs +++ b/crates/gpui-query/src/tests/core_request/request_policy.rs @@ -1,10 +1,3 @@ -//! Tests for RequestPolicy variants and QuerySignal lifecycle. -//! -//! Covers: -//! - RequestPolicy: LatestWins cancels previous, IgnoreWhileLoading keeps previous -//! - QuerySignal: creation per request, cancellation propagation across clones -//! - Scope-based rejection: request id from different scope is rejected - use crate::core::{ CachePolicy, QueryBeginResult, QueryFetchMode, QueryResource, QuerySignal, QueryStatus, RequestId, RequestPolicy, @@ -14,22 +7,16 @@ use crate::tests::test_support::{ }; use std::num::NonZero; -// ═══════════════════════════════════════════════════════════════════════════ -// RequestPolicy: LatestWins cancels previous -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn latest_wins_cancels_previous_request_and_signal() { let mut resource: QueryResource<&str> = test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut seq = test_sequencer(); - // Start request 1, capture its signal let old_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let old_signal = resource.signal().unwrap().clone(); assert!(!old_signal.is_cancelled()); - // Start request 2 — should cancel request 1's signal let result = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let new_id = match result { QueryBeginResult::Started { @@ -43,19 +30,15 @@ fn latest_wins_cancels_previous_request_and_signal() { other => panic!("expected Started, got {:?}", other), }; - // Old signal should be cancelled assert!( old_signal.is_cancelled(), "replaced request's signal should be cancelled" ); - // New signal should not be cancelled let new_signal = resource.signal().unwrap(); assert!(!new_signal.is_cancelled()); - // Old request id should not be accepted assert!(resource.accept_current_request(old_id).is_none()); - // New request id should be accepted assert!(resource.accept_current_request(new_id).is_some()); assert_eq!(resource.cancelled_count(), 1); @@ -70,14 +53,9 @@ fn latest_wins_increments_cancelled_count_for_each_replacement() { for _ in 0..5 { let _ = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); } - // First request: 0 cancellations; each subsequent: +1 assert_eq!(resource.cancelled_count(), 4); } -// ═══════════════════════════════════════════════════════════════════════════ -// RequestPolicy: IgnoreWhileLoading keeps previous -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn ignore_while_loading_rejects_new_request_when_loading() { let mut resource: QueryResource<&str> = test_resource_with_policies( @@ -87,11 +65,9 @@ fn ignore_while_loading_rejects_new_request_when_loading() { ); let mut seq = test_sequencer(); - // First request starts normally let first_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); assert_status(&resource, QueryStatus::LoadingEmpty); - // Second request should be ignored let result = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); match result { QueryBeginResult::IgnoredWhileLoading { active_request_id } => { @@ -100,7 +76,6 @@ fn ignore_while_loading_rejects_new_request_when_loading() { other => panic!("expected IgnoredWhileLoading, got {:?}", other), } - // Active request is still the first one assert_eq!(resource.active_request_id(), Some(first_id)); assert_eq!( resource.cancelled_count(), @@ -118,13 +93,11 @@ fn ignore_while_loading_allows_new_request_after_completion() { ); let mut seq = test_sequencer(); - // Start and complete first request let first_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let completed = resource.complete_current_success(first_id, "data", TEST_NOW_MS); assert!(completed); assert_status(&resource, QueryStatus::Success); - // Now a new request should be allowed let result = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); assert!( matches!(result, QueryBeginResult::Started { .. }), @@ -132,26 +105,18 @@ fn ignore_while_loading_allows_new_request_after_completion() { ); } -// ═══════════════════════════════════════════════════════════════════════════ -// Signal creation per request -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn each_request_gets_a_fresh_signal() { let mut resource: QueryResource<&str> = test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut seq = test_sequencer(); - // Request 1 let _ = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let signal_1 = resource.signal().unwrap().clone(); - // Complete request 1 — completion does not clear the signal; - // it remains until replaced by the next begin_loading call. let id_1 = RequestId::scoped(NonZero::new(1).unwrap(), 1); resource.complete_current_success(id_1, "result", TEST_NOW_MS); - // Request 2 — begin_loading cancels the old signal and creates a new one let _ = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); let signal_2 = resource.signal().unwrap().clone(); @@ -194,27 +159,20 @@ fn signal_is_not_cancelled_on_creation() { assert!(!default.is_cancelled()); } -// ═══════════════════════════════════════════════════════════════════════════ -// Scope-based rejection: request id from different scope is rejected -// ═══════════════════════════════════════════════════════════════════════════ - #[test] fn request_id_from_different_scope_is_rejected() { let mut resource: QueryResource<&str> = test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut seq = test_sequencer(); - // Start a request — scope 1 let active_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); - // Manually craft a request id in a different scope let fake_id = RequestId::scoped(NonZero::new(999).unwrap(), 1); let result = resource.accept_current_request(fake_id); assert!( result.is_none(), "request id from different scope should be rejected" ); - // The actual active request should still be accepted assert_eq!(resource.active_request_id(), Some(active_id)); assert_eq!(resource.ignored_results(), 1); } diff --git a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs index 4e2dafb..5779d60 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs @@ -1,24 +1,5 @@ -//! Tests for InfiniteQueryResource advanced scenarios. -//! -//! Covers: -//! - InfiniteQueryResource cross-direction replacement -//! - InfiniteQueryResource cache_policy and request_policy setters -//! - InfiniteQueryResource retry_policy and set_retry_policy -//! - InfiniteQueryResource started_at / last_updated_at timestamps -//! - InfiniteQueryResource cancelled_count tracking -//! - InfiniteQueryResource error accessor after failure -//! - InfiniteQueryResource key accessor -//! - InfiniteQueryResource complete_success_with_guard for prepend -//! - InfiniteQueryResource complete_failure_with_guard clears fetching flags -//! - InfiniteQueryResource is_current_request -//! - InfiniteQueryResource multiple pages loaded then failure on next -//! - InfiniteQueryResource is_loading -//! - InfiniteQueryResource active_request_id through lifecycle - use crate::core::*; -// ── InfiniteQueryResource: cross-direction replacement ─────────────────── - #[test] fn infinite_query_cross_direction_replaces_request() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -28,23 +9,19 @@ fn infinite_query_cross_direction_replaces_request() { ); let mut seq = RequestSequencer::new(); - // Start a next-page fetch let id_next = r.begin_fetch_next(&mut seq, 1_000).unwrap(); assert!(r.is_fetching_next_page()); assert!(!r.is_fetching_previous_page()); - // Now start a previous-page fetch (cross-direction replacement under LatestWins) r.set_has_previous_page(true); let id_prev = r.begin_fetch_previous(&mut seq, 2_000).unwrap(); assert!(!r.is_fetching_next_page(), "next flag should be cleared"); assert!(r.is_fetching_previous_page()); assert_eq!(r.cancelled_count(), 1, "previous request was cancelled"); - // Completing the old next request should fail (stale) assert!(!r.complete_page_success(id_next, vec!["stale_next".to_string()], true, true, 3_000)); assert_eq!(r.ignored_results(), 1); - // Completing the previous request should succeed assert!(r.complete_page_success(id_prev, vec!["page0".to_string()], false, false, 3_000)); assert_eq!(r.page_count(), 1); assert_eq!(r.first_page(), Some(&vec!["page0".to_string()])); @@ -72,8 +49,6 @@ fn infinite_query_cross_direction_previous_to_next() { assert!(r.complete_page_success(id_next, vec!["page1".to_string()], true, true, 3_000)); } -// ── InfiniteQueryResource: cache_policy and request_policy setters ─────── - #[test] fn infinite_query_set_cache_policy() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -100,8 +75,6 @@ fn infinite_query_set_request_policy() { assert_eq!(r.request_policy(), RequestPolicy::IgnoreWhileLoading); } -// ── InfiniteQueryResource: retry_policy ─────────────────────────────────── - #[test] fn infinite_query_default_retry_policy() { let r = InfiniteQueryResource::<Vec<String>>::new( @@ -126,8 +99,6 @@ fn infinite_query_set_retry_policy() { assert_eq!(r.retry_policy(), &policy); } -// ── InfiniteQueryResource: timestamps ───────────────────────────────────── - #[test] fn infinite_query_timestamps_on_lifecycle() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -153,8 +124,6 @@ fn infinite_query_timestamps_on_lifecycle() { assert_eq!(r.last_updated_at_ms(), Some(2_000)); } -// ── InfiniteQueryResource: cancelled_count tracking ─────────────────────── - #[test] fn infinite_query_cancelled_count_on_replacement() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -174,8 +143,6 @@ fn infinite_query_cancelled_count_on_replacement() { assert_eq!(r.cancelled_count(), 2); } -// ── InfiniteQueryResource: error accessor ───────────────────────────────── - #[test] fn infinite_query_error_after_failure() { let mut r: InfiniteQueryResource<Vec<String>, QueryError> = InfiniteQueryResource::new( @@ -215,8 +182,6 @@ fn infinite_query_error_cleared_on_success() { assert!(r.error().is_none()); } -// ── InfiniteQueryResource: key accessor ─────────────────────────────────── - #[test] fn infinite_query_key_accessor() { let r = InfiniteQueryResource::<Vec<String>>::new( @@ -227,8 +192,6 @@ fn infinite_query_key_accessor() { assert_eq!(r.key(), &QueryKey::from(["users", "42", "posts"])); } -// ── InfiniteQueryResource: complete_success_with_guard for prepend ────── - #[test] fn infinite_query_complete_success_with_guard_prepend() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -238,11 +201,9 @@ fn infinite_query_complete_success_with_guard_prepend() { ); let mut seq = RequestSequencer::new(); - // First, add a page via next let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); r.complete_page_success(id1, vec!["page1".to_string()], true, true, 2_000); - // Now prepend via two-phase protocol r.set_has_previous_page(true); let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); let guard = r.accept_current_request(id2).unwrap(); @@ -257,8 +218,6 @@ fn infinite_query_complete_success_with_guard_prepend() { ); } -// ── InfiniteQueryResource: complete_failure_with_guard clears fetching flags - #[test] fn infinite_query_complete_failure_with_guard_clears_flags() { let mut r: InfiniteQueryResource<Vec<String>, QueryError> = InfiniteQueryResource::new( @@ -280,8 +239,6 @@ fn infinite_query_complete_failure_with_guard_clears_flags() { assert_eq!(r.status(), QueryStatus::Failure); } -// ── InfiniteQueryResource: is_current_request ──────────────────────────── - #[test] fn infinite_query_is_current_request() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -300,8 +257,6 @@ fn infinite_query_is_current_request() { assert_eq!(r.active_request_id(), Some(id2)); } -// ── InfiniteQueryResource: multiple pages loaded then failure on next ──── - #[test] fn infinite_query_multiple_pages_then_failure_does_not_clear_pages() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -311,7 +266,6 @@ fn infinite_query_multiple_pages_then_failure_does_not_clear_pages() { ); let mut seq = RequestSequencer::new(); - // Load 3 pages let id1 = r.begin_fetch_next(&mut seq, 100).unwrap(); r.complete_page_success(id1, vec!["a".to_string()], true, true, 200); let id2 = r.begin_fetch_next(&mut seq, 300).unwrap(); @@ -321,7 +275,6 @@ fn infinite_query_multiple_pages_then_failure_does_not_clear_pages() { assert_eq!(r.page_count(), 3); - // Fail on the 4th page let id4 = r.begin_fetch_next(&mut seq, 700).unwrap(); r.complete_page_failure(id4, QueryError::transport("timeout")); @@ -332,8 +285,6 @@ fn infinite_query_multiple_pages_then_failure_does_not_clear_pages() { assert_eq!(r.last_page(), Some(&vec!["c".to_string()])); } -// ── InfiniteQueryResource: is_loading ───────────────────────────────────── - #[test] fn infinite_query_is_loading_reflects_status() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -352,8 +303,6 @@ fn infinite_query_is_loading_reflects_status() { assert!(!r.is_loading()); } -// ── InfiniteQueryResource: active_request_id through lifecycle ─────────── - #[test] fn infinite_query_active_request_id_lifecycle() { let mut r = InfiniteQueryResource::<Vec<String>>::new( @@ -368,7 +317,6 @@ fn infinite_query_active_request_id_lifecycle() { let id = r.begin_fetch_next(&mut seq, 1_000).unwrap(); assert_eq!(r.active_request_id(), Some(id)); - // Accept clears active_request_id let guard = r.accept_current_request(id).unwrap(); assert!(r.active_request_id().is_none()); diff --git a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs index 8a91c8a..ccb73ac 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs @@ -1,37 +1,19 @@ -//! Tests for QueryResource advanced scenarios. -//! -//! Covers: -//! - Error recovery: Failure -> Success, Failure -> Cancel, Cancel -> Success -//! - signal_mut accessor -//! - set_retry_policy and retry_policy interaction -//! - QueryResource serde roundtrip with data -//! - QueryResource mark_ignored_result -//! - set_request_policy and preservation across reset -//! - CachePolicy::NoCache should_clear_data_on_complete -//! - Error recovery full cycle through Failure back to Success -//! - begin_request_with_id respects IgnoreWhileLoading - use crate::core::*; use crate::tests::test_support::*; use std::num::NonZero; -// ── QueryResource: Error recovery paths ──────────────────────────────────── - #[test] fn failure_to_success_recovery_cycle() { - // Load -> Fail -> Retry -> Succeed let mut r: QueryResource<&str> = QueryResource::new("test", CachePolicy::NoCache, RequestPolicy::LatestWins); let mut s = test_sequencer(); - // First fetch succeeds let rid1 = match r.begin_request(&mut s, 100, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), }; r.complete_current_success(rid1, "v1", 200); - // Refetch fails let rid2 = match r.begin_request(&mut s, 1_500, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), @@ -40,7 +22,6 @@ fn failure_to_success_recovery_cycle() { assert_eq!(r.status(), QueryStatus::Failure); assert_eq!(r.data(), Some(&"v1"), "failure retains prior data"); - // Retry succeeds (NoCache ensures we get Started, not CacheHit) let rid3 = match r.begin_request(&mut s, 2_000, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), @@ -64,7 +45,6 @@ fn cancel_then_fresh_begin_succeeds() { r.cancel(QueryError::cancelled("abort")); assert_eq!(r.status(), QueryStatus::Cancelled); - // Begin a new request after cancellation let rid2 = match r.begin_request(&mut s, 200, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), @@ -94,7 +74,6 @@ fn failure_with_data_then_success_updates_data() { assert_eq!(r.status(), QueryStatus::Failure); assert_eq!(r.data(), Some(&"fallback")); - // Now succeed — should update data and clear error let rid2 = match r.begin_request(&mut s, 300, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), @@ -105,8 +84,6 @@ fn failure_with_data_then_success_updates_data() { assert!(r.error().is_none()); } -// ── QueryResource: signal_mut accessor ──────────────────────────────────── - #[test] fn signal_mut_returns_signal_when_active() { let mut r = test_resource(); @@ -124,12 +101,10 @@ fn signal_mut_returns_none_when_idle() { assert!(r.signal_mut().is_none()); } -// ── QueryResource: set_retry_policy ──────────────────────────────────────── - #[test] fn set_retry_policy_updates_policy() { let mut r = test_resource(); - assert_eq!(r.retry_policy().max_retries, 0); // default is no_retries + assert_eq!(r.retry_policy().max_retries, 0); let new_policy = RetryPolicy::new(5) .with_delay(200) @@ -153,8 +128,6 @@ fn retry_policy_preserved_across_reset() { assert_eq!(r.retry_count(), 0, "count cleared after reset"); } -// ── QueryResource: serde roundtrip ──────────────────────────────────────── - #[test] fn serde_roundtrip_with_data_and_error_state() { let mut r: QueryResource<String, QueryError> = QueryResource::new( @@ -179,8 +152,6 @@ fn serde_roundtrip_with_data_and_error_state() { assert!(back.signal().is_none(), "signal is #[serde(skip)]"); } -// ── QueryResource: mark_ignored_result ──────────────────────────────────── - #[test] fn mark_ignored_result_increments_counter() { let mut r = test_resource(); @@ -194,8 +165,6 @@ fn mark_ignored_result_increments_counter() { assert_eq!(r.ignored_results(), 3); } -// ── QueryResource: set_request_policy ────────────────────────────────────── - #[test] fn set_request_policy_changes_policy() { let mut r = test_resource(); @@ -213,27 +182,21 @@ fn set_request_policy_preserved_across_reset() { assert_eq!(r.request_policy(), RequestPolicy::IgnoreWhileLoading); } -// ── QueryResource: nocache should_clear_data_on_complete ────────────────── - #[test] fn ttl_resource_should_not_clear_data_on_complete() { let r = test_resource(); assert!(!r.should_clear_data_on_complete()); } -// ── QueryResource: full recovery from cancelled state ───────────────────── - #[test] fn cancelled_to_loading_to_success_recovery() { let mut r = test_resource(); let mut s = test_sequencer(); - // Cancel a request let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); r.cancel(QueryError::cancelled("abort")); assert_eq!(r.status(), QueryStatus::Cancelled); - // Begin a new request let rid2 = match r.begin_request(&mut s, 200, QueryFetchMode::Normal) { QueryBeginResult::Started { request_id, .. } => request_id, _ => panic!("expected Started"), @@ -241,14 +204,11 @@ fn cancelled_to_loading_to_success_recovery() { assert_eq!(r.status(), QueryStatus::LoadingEmpty); assert!(r.error().is_none(), "begin clears error"); - // Complete successfully r.complete_current_success(rid2, "recovered", 300); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"recovered")); } -// ── QueryResource: begin_request_with_id respects IgnoreWhileLoading ───── - #[test] fn begin_request_with_id_respects_ignore_while_loading() { let mut r: QueryResource<&str> = QueryResource::new( @@ -260,7 +220,6 @@ fn begin_request_with_id_respects_ignore_while_loading() { let _ = r.begin_request_with_id(Some(custom_id), 100, QueryFetchMode::Normal); assert_eq!(r.active_request_id(), Some(custom_id)); - // Second request should be ignored let result = r.begin_request_with_id( Some(RequestId::scoped(NonZero::new(10).unwrap(), 2)), 200, diff --git a/crates/gpui-query/src/tests/core_select/mod.rs b/crates/gpui-query/src/tests/core_select/mod.rs index f04ccbf..9923491 100644 --- a/crates/gpui-query/src/tests/core_select/mod.rs +++ b/crates/gpui-query/src/tests/core_select/mod.rs @@ -1,19 +1,7 @@ -//! Tests for SelectTransform and MappedQueryResource. -//! -//! Covers: -//! - SelectTransform creation, clone, apply -//! - MappedQueryResource new, data, has_data, update_source -//! - Transform composition (chained transforms) -//! - Empty source data (None) -//! - Different output types (identity, count, projection) -//! - Clone semantics - use std::sync::Arc; use crate::core::{MappedQueryResource, SelectTransform}; -// ── SelectTransform ───────────────────────────────────────────────────── - #[test] fn select_transform_apply_identity() { let transform = SelectTransform::new(|x: &i32| *x); @@ -52,18 +40,14 @@ fn select_transform_clone_shares_transform() { #[test] fn select_transform_different_types() { - // String -> usize (length) let len_transform = SelectTransform::new(|s: &String| s.len()); assert_eq!(len_transform.apply(&"hello".to_string()), 5); - // Vec<i32> -> bool (is empty) let empty_check = SelectTransform::new(|v: &Vec<i32>| v.is_empty()); assert!(empty_check.apply(&vec![])); assert!(!empty_check.apply(&vec![1])); } -// ── MappedQueryResource ───────────────────────────────────────────────── - #[test] fn mapped_resource_new_with_data() { let transform = SelectTransform::new(|v: &Vec<i32>| v.len()); @@ -118,9 +102,6 @@ fn mapped_resource_update_source_replaces_previous() { #[test] fn mapped_resource_data_applies_transform_lazily() { - // Behavioral test: verifies that data() returns the correct transformed value - // reflecting the latest source data, regardless of whether the implementation - // evaluates lazily (re-applies on each call) or eagerly (caches on update). let transform = SelectTransform::new(|v: &Vec<i32>| v.len()); @@ -128,10 +109,8 @@ fn mapped_resource_data_applies_transform_lazily() { MappedQueryResource::new(Some(Arc::new(vec![1, 2])), transform); assert_eq!(mapped.data(), Some(2)); - // Repeated data() calls must still return the correct value. assert_eq!(mapped.data(), Some(2)); - // After updating the source, data() must reflect the new source. mapped.update_source(Some(Arc::new(vec![1, 2, 3]))); assert_eq!(mapped.data(), Some(3)); } @@ -145,12 +124,10 @@ fn mapped_resource_clone_is_independent() { let mut cloned = mapped.clone(); assert_eq!(cloned.data(), Some(3)); - // Updating the original does not affect the clone mapped.update_source(Some(Arc::new(vec![1]))); assert_eq!(mapped.data(), Some(1)); assert_eq!(cloned.data(), Some(3), "clone should be independent"); - // Updating the clone does not affect the original cloned.update_source(Some(Arc::new(vec![4, 5, 6, 7]))); assert_eq!(cloned.data(), Some(4)); assert_eq!(mapped.data(), Some(1)); From bb2cf5c3b61740b43a4e149a2c8f0cacd450cc41 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 18:37:06 +0200 Subject: [PATCH 036/111] fix: restore retrypolicy defaults as one-line doc, drop name-restating lines --- crates/gpui-query/src/core/retry.rs | 2 +- crates/gpui-query/src/core/status.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/gpui-query/src/core/retry.rs b/crates/gpui-query/src/core/retry.rs index cab604e..7e1ca82 100644 --- a/crates/gpui-query/src/core/retry.rs +++ b/crates/gpui-query/src/core/retry.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -/// Retry configuration for failed requests. +/// Defaults: 3 retries, exponential backoff, 1s base delay, 30s cap. /// /// # Examples /// diff --git a/crates/gpui-query/src/core/status.rs b/crates/gpui-query/src/core/status.rs index ae6552d..aeafb51 100644 --- a/crates/gpui-query/src/core/status.rs +++ b/crates/gpui-query/src/core/status.rs @@ -2,8 +2,6 @@ use serde::{Deserialize, Serialize}; -/// The status of a query resource. -/// /// `Idle` → `LoadingEmpty` → `Success`/`Failure`; refetch: `Success` → `LoadingWithData` → terminal. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum QueryStatus { From 42df0549bc518b1cdc0f0a09a3c99aee9d06e7e2 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 18:59:24 +0200 Subject: [PATCH 037/111] refactor: purge client comments to one-line-or-examples and strip test comments --- .../src/client/bucket/erased_ops.rs | 12 +- crates/gpui-query/src/client/bucket/mod.rs | 6 +- crates/gpui-query/src/client/bucket/ops.rs | 3 - crates/gpui-query/src/client/bucket/shared.rs | 44 ++--- crates/gpui-query/src/client/bucket/types.rs | 24 +-- crates/gpui-query/src/client/devtools.rs | 45 +---- crates/gpui-query/src/client/erased.rs | 48 ++--- .../gpui-query/src/client/infinite_bucket.rs | 16 +- .../src/client/infinite_mutation_ops.rs | 38 +--- crates/gpui-query/src/client/lifecycle.rs | 100 +++------- crates/gpui-query/src/client/mod.rs | 108 +++-------- .../gpui-query/src/client/mutation_bucket.rs | 47 ++--- .../gpui-query/src/client/mutation_signal.rs | 13 +- crates/gpui-query/src/client/observer.rs | 30 +-- .../gpui-query/src/client/prepared_fetch.rs | 32 +--- crates/gpui-query/src/client/time.rs | 12 +- .../src/tests/coverage_gaps/concurrency.rs | 18 -- .../src/tests/coverage_gaps/gap_tests.rs | 26 --- .../src/tests/coverage_gaps/gc_eviction.rs | 42 ----- .../gpui-query/src/tests/coverage_gaps/mod.rs | 16 -- .../src/tests/coverage_gaps/property_based.rs | 69 ++----- .../tests/coverage_gaps/state_transitions.rs | 46 +---- .../tests/integration_client/client_basics.rs | 30 --- .../tests/integration_client/data_access.rs | 40 ---- .../invalidation_reset_gc.rs | 42 ----- .../src/tests/integration_client/mod.rs | 15 -- .../integration_client/mutations_lifecycle.rs | 33 ---- crates/gpui-query/src/tests/test_support.rs | 174 ------------------ 28 files changed, 161 insertions(+), 968 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/erased_ops.rs b/crates/gpui-query/src/client/bucket/erased_ops.rs index 2535d6f..36e156d 100644 --- a/crates/gpui-query/src/client/bucket/erased_ops.rs +++ b/crates/gpui-query/src/client/bucket/erased_ops.rs @@ -1,5 +1,3 @@ -//! `ErasedBucket` trait implementation for `QueryBucket`. - use gpui::App; use crate::client::devtools::QueryDiagnostic; @@ -29,8 +27,7 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.inner.for_each_matching_entry(filter, cx, |entity, cx| { - // invalidate() only clears last_updated_at; skip the update (which - // notifies observers even on a no-op) when it is already None. + // invalidate() only clears last_updated_at; skip the no-op update, which still notifies observers. let needs_invalidate = entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); if needs_invalidate { @@ -79,8 +76,8 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB self.inner.entries.contains_key(key) } - /// For each `Success` entry whose `T` has a registered serializer, push - /// `(key, PersistedEntry)` into `out`; everything else is skipped. + /// Only `Success` entries whose `T` has a registered serializer are + /// pushed; everything else is skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -110,8 +107,7 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB let Some(data) = resource.data() else { continue; }; - // Downcast failure is unreachable by construction (see - // `SerializerRegistry::register`); skip rather than persist junk. + // Downcast failure is unreachable by construction; skip rather than persist junk. let Some(value) = serialize_fn(data as &dyn std::any::Any) else { continue; }; diff --git a/crates/gpui-query/src/client/bucket/mod.rs b/crates/gpui-query/src/client/bucket/mod.rs index 036cf8a..92bebf9 100644 --- a/crates/gpui-query/src/client/bucket/mod.rs +++ b/crates/gpui-query/src/client/bucket/mod.rs @@ -1,9 +1,7 @@ -//! Type-partitioned buckets for query resources. -//! //! `ResourceBucket` in `shared` holds the machinery shared by //! [`QueryBucket`] and [`InfiniteQueryBucket`](crate::client::InfiniteQueryBucket): -//! weak-entity entries with co-located request sequencers, capacity-bounded -//! eviction, GC, bulk key-filter operations, and diagnostics. +//! weak-entity entries with co-located sequencers, capacity-bounded eviction, +//! GC, bulk key-filter operations, and diagnostics. mod erased_ops; mod ops; diff --git a/crates/gpui-query/src/client/bucket/ops.rs b/crates/gpui-query/src/client/bucket/ops.rs index 13cddec..73e6741 100644 --- a/crates/gpui-query/src/client/bucket/ops.rs +++ b/crates/gpui-query/src/client/bucket/ops.rs @@ -8,7 +8,6 @@ use crate::core::{ use super::shared::ResourceBucket; -/// Type-partitioned storage for query resources of a specific `(T, E)` type pair. pub struct QueryBucket<T, E> { pub(crate) inner: ResourceBucket<QueryResource<T, E>>, } @@ -30,8 +29,6 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> QueryBu self.inner.get_or_create(key, cache_policy, request_policy, cx) } - /// Get-or-create that also mints the next `RequestId` from the entry's - /// sequencer in the same lookup. pub(crate) fn get_or_create_with_request_id( &mut self, key: QueryKey, diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 8d9354e..7f6e8ac 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -1,9 +1,7 @@ -//! Shared bucket machinery. -//! //! `ResourceBucket<R>` holds everything `QueryBucket` and //! `InfiniteQueryBucket` do identically (get-or-create, eviction, GC, bulk -//! matching, diagnostics); the two public bucket types are thin facades that -//! only add their erased-trait impls and persistence specifics. +//! matching, diagnostics); the public bucket types only add erased-trait +//! impls and persistence specifics. use ahash::AHashMap; use gpui::{App, AppContext as _, Entity}; @@ -16,12 +14,12 @@ use crate::core::{ use super::types::{BucketEntry, DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; -/// Run GC every this many resource operations so it fires in production +/// Runs GC every this many resource operations, so it fires in production /// without anyone calling `gc()` by hand. pub(crate) const GC_INTERVAL: usize = 64; -/// The resource surface `ResourceBucket` needs; implemented for both query -/// resource kinds. Prefixed names keep the delegating impls unambiguous. +/// The resource surface `ResourceBucket` needs for both query kinds; +/// prefixed names keep the delegating impls unambiguous. pub(crate) trait BucketResource { fn new_resource(key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy) -> Self; @@ -117,8 +115,6 @@ impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { } } -/// Key-partitioned storage for one resource type, shared by the query and -/// infinite-query buckets. pub(crate) struct ResourceBucket<R> { pub(crate) entries: AHashMap<QueryKey, BucketEntry<R>>, pub(crate) max_entries: usize, @@ -132,7 +128,6 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } - /// Get an existing entity or create a new one. pub(crate) fn get_or_create( &mut self, key: QueryKey, @@ -144,8 +139,8 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { .0 } - /// Get-or-create that also mints the next `RequestId` from the entry's - /// sequencer in the same lookup. + /// Mints the next `RequestId` from the entry's sequencer in the same + /// lookup. pub(crate) fn get_or_create_with_request_id( &mut self, key: QueryKey, @@ -158,9 +153,9 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { (entity, request_id.expect("impl inserts the entry before returning")) } - /// Live entries get their policies refreshed in place when they differ. - /// A dead weak reference is overwritten in place (length unchanged, no - /// eviction); a vacant insert at capacity evicts the oldest entry first. + /// Live entries refresh differing policies in place; a dead weak + /// reference is overwritten in place (length unchanged, no eviction), + /// while a vacant insert at capacity evicts the oldest entry first. fn get_or_create_impl( &mut self, key: QueryKey, @@ -211,9 +206,7 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { (entity, request_id) } - /// Evict the least-recently-updated entry to make room for a new one. - /// - /// The scan reads only the mirrors plus weak-ref liveness, then confirms + /// Scans only the mirrors plus weak-ref liveness, then confirms /// `!is_loading()` on the winner with one entity read (the mirror can be /// stale if a fetch began after the last refresh). Each retry marks the /// stale mirror and re-picks, so the candidate set strictly shrinks. @@ -271,8 +264,8 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { .collect() } - /// Collect matching entities up front (one pass), then run `action` on - /// each outside the map borrow. GPUI defers observer effects to the + /// Collects matching entities up front, then runs `action` on each + /// outside the map borrow. GPUI defers observer effects to the /// outermost update, so no action can re-enter this bucket mid-loop. pub(crate) fn for_each_matching_entry( &mut self, @@ -292,12 +285,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } - /// Evict dead weak references plus terminal entries past their age - /// window. Loading resources always survive; `Success` survives while its - /// cache policy can still serve it and until - /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`; `Idle`/`Failure`/`Cancelled` - /// survive `gc_time_ms`. Entries without a completion timestamp count as - /// fully aged. + /// Loading always survives; `Success` survives while its cache policy + /// can still serve it and until `SUCCESS_GC_MULTIPLIER * gc_time_ms`; + /// `Idle`/`Failure`/`Cancelled` survive `gc_time_ms`. Entries without a + /// completion timestamp count as fully aged. pub(crate) fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); @@ -308,7 +299,6 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { }; let resource = entity.read(cx); - // GC walks every entry, so it is the canonical mirror refresh point. entry.last_updated_ms = resource.resource_last_updated(); entry.loading = resource.resource_is_loading(); diff --git a/crates/gpui-query/src/client/bucket/types.rs b/crates/gpui-query/src/client/bucket/types.rs index 3ddc8fc..3418b67 100644 --- a/crates/gpui-query/src/client/bucket/types.rs +++ b/crates/gpui-query/src/client/bucket/types.rs @@ -1,28 +1,22 @@ -//! Core types and constants for the resource buckets. - use gpui::WeakEntity; use crate::core::RequestSequencer; -/// Floor for `gc_time_ms`. A value of 0 (or anything below this) would evict -/// every `Idle`/`Failure` resource on every GC pass, since `age >= 0` always -/// holds for unsigned ages. +/// A `gc_time_ms` below this floor would evict every `Idle`/`Failure` +/// resource on every GC pass (unsigned ages always satisfy `age >= 0`). pub(crate) const MIN_GC_TIME_MS: u64 = 1_000; -/// Entries per bucket before the oldest one is evicted. Bounds memory when a -/// component registers unbounded unique keys. +/// Entries per bucket before the oldest one is evicted; bounds memory when +/// components register unbounded unique keys. pub(crate) const DEFAULT_MAX_ENTRIES: usize = 10_000; -/// `Success` resources survive this many times `gc_time_ms` before GC may -/// evict them, so valuable data outlives transient failures. +/// Successful data outlives transient failures by this multiple of +/// `gc_time_ms`. pub(crate) const SUCCESS_GC_MULTIPLIER: u32 = 2; -/// Weak entity handle co-located with the key's request sequencer. -/// -/// `last_updated_ms` / `loading` mirror the entity's -/// `last_updated_at_ms()` / `is_loading()` and are refreshed whenever the -/// bucket already reads the entity, so `evict_oldest` can scan cheap fields -/// and do a single confirming entity read on its winner. +/// `last_updated_ms` / `loading` mirror the entity, refreshed wherever the +/// bucket already reads it, so `evict_oldest` scans cheap fields and +/// confirms its winner with a single entity read. pub(crate) struct BucketEntry<R> { pub entity: WeakEntity<R>, pub sequencer: RequestSequencer, diff --git a/crates/gpui-query/src/client/devtools.rs b/crates/gpui-query/src/client/devtools.rs index 339d5a2..be7eba0 100644 --- a/crates/gpui-query/src/client/devtools.rs +++ b/crates/gpui-query/src/client/devtools.rs @@ -1,79 +1,47 @@ -//! Diagnostic types for query and mutation DevTools. - #[cfg(feature = "persist")] use std::any::TypeId; use crate::core::{MutationStatus, QueryStatus}; -/// Diagnostic information about a single query resource. #[derive(Clone, Debug)] pub struct QueryDiagnostic { - /// Full key path (e.g., "users::42::posts"). + /// Key path, e.g. "users::42::posts". pub key: String, - /// Current status. pub status: QueryStatus, - /// Cache policy label. pub cache_policy: String, - /// Cache age in milliseconds, if available. pub cache_age_ms: Option<u64>, - /// Number of cache hits. pub cache_hits: u64, - /// Number of retries. pub retry_count: u32, } -/// Diagnostic information about a single mutation resource. #[derive(Clone, Debug)] pub struct MutationDiagnostic { - /// Optional key associated with this mutation. pub key: Option<String>, - /// Current status. pub status: MutationStatus, - /// Number of retries. pub retry_count: u32, } -/// Aggregate diagnostic for the entire QueryClient. #[derive(Clone, Debug, Default)] pub struct ClientDiagnostic { - /// Total number of tracked query resources. pub query_count: usize, - /// Total number of tracked mutation resources. pub mutation_count: usize, - /// Per-query diagnostics. pub queries: Vec<QueryDiagnostic>, - /// Per-mutation diagnostics. pub mutations: Vec<MutationDiagnostic>, } -// Dehydration types, gated behind `persist` alongside the -// dehydrate/hydrate/persist/restore methods and the `QueryPersister` trait. - -/// A single entry in a dehydrated query cache snapshot, identified by its -/// key and the `TypeId` of its `(T, E)` type pair. `kind` distinguishes -/// queries, infinite queries, and mutations so consumers can deserialize -/// appropriately. #[cfg(feature = "persist")] #[derive(Clone, Debug)] pub struct DehydratedEntry { - /// Full key path (e.g., "users::42::posts"). + /// Key path, e.g. "users::42::posts". pub key: String, - /// `TypeId` of the resource's `(T, E)` type pair; used to match entries - /// to concrete types during hydration. + /// Matches entries to concrete types during hydration. pub type_id: TypeId, - /// Whether this entry is a query, an infinite query, or a mutation. + /// "query", "infinite", or "mutation". pub kind: &'static str, } -/// A portable snapshot of all cached query state, produced by -/// [`QueryClient::dehydrate`](super::QueryClient::dehydrate) and consumed by -/// [`QueryClient::hydrate`](super::QueryClient::hydrate). Persist it to disk -/// or send it over a network for state restoration. -/// -/// Because `QueryClient` uses type-erased buckets, `DehydratedState` stores -/// `TypeId` values but cannot deserialize typed data itself: callers that -/// know the concrete types should iterate `entries` and use -/// `QueryClient::set_query_data` for each matching entry. +/// Type-erased: callers that know the concrete types iterate `entries` and +/// call [`set_query_data`](super::QueryClient::set_query_data) per entry. /// /// # Example /// @@ -97,6 +65,5 @@ pub struct DehydratedEntry { #[cfg(feature = "persist")] #[derive(Clone, Debug, Default)] pub struct DehydratedState { - /// All dehydrated cache entries. pub entries: Vec<DehydratedEntry>, } diff --git a/crates/gpui-query/src/client/erased.rs b/crates/gpui-query/src/client/erased.rs index 649bd75..9c5304f 100644 --- a/crates/gpui-query/src/client/erased.rs +++ b/crates/gpui-query/src/client/erased.rs @@ -1,9 +1,5 @@ -//! Type-erased bucket traits and persistence adapter. -//! -//! These traits let `QueryClient` store heterogeneous buckets in -//! `AHashMap<TypeId, Box<dyn Erased*>>` maps, dispatching to concrete types -//! only when the caller provides generic parameters. The persistence-only -//! surface is gated behind the `persist` feature. +//! Type-erased bucket traits: let `QueryClient` store heterogeneous buckets +//! in `AHashMap<TypeId, Box<dyn Erased*>>` maps. use crate::client::devtools::{MutationDiagnostic, QueryDiagnostic}; #[cfg(feature = "persist")] @@ -12,7 +8,6 @@ use crate::core::QueryKeyFilter; #[cfg(feature = "persist")] use crate::core::{MutationStatus, QueryStatus}; -/// Type-erased bucket trait for storage in a homogeneous map. pub(crate) trait ErasedBucket { fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; @@ -22,17 +17,14 @@ pub(crate) trait ErasedBucket { fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); fn remove_matching(&mut self, filter: &QueryKeyFilter); fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - /// Push each live entry's diagnostic into the caller-supplied Vec so - /// `QueryClient::diagnostics` can pre-size one destination instead of - /// allocating per bucket. + /// Push diagnostics into the caller's Vec so `QueryClient::diagnostics` + /// pre-sizes one destination instead of allocating per bucket. fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); /// Key/status pairs without the per-entry allocations of full /// diagnostics; used by `dehydrate`. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - /// Push each `Success` entry's `(key, entry)` pair into `out`, serializing - /// via the caller-supplied registry. Entries whose `T` has no registered - /// serializer are skipped. + /// Entries whose `T` has no registered serializer are skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -41,13 +33,11 @@ pub(crate) trait ErasedBucket { now_ms: u64, out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, ); - /// Whether the bucket currently holds `key`. Used to prune the - /// persistence metadata map of keys whose entries were evicted. + /// Prunes the persisted-meta map of keys whose entries were evicted. #[cfg(feature = "persist")] fn contains_key(&self, key: &crate::core::QueryKey) -> bool; } -/// Type-erased infinite query bucket trait. pub(crate) trait ErasedInfiniteBucket { fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; @@ -57,15 +47,9 @@ pub(crate) trait ErasedInfiniteBucket { fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); fn remove_matching(&mut self, filter: &QueryKeyFilter); fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - /// Push each live entry's diagnostic into `out`. See - /// [`ErasedBucket::collect_diagnostics_into`]. fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); - /// Key/status pairs without full diagnostics; used by `dehydrate`. See - /// [`ErasedBucket::collect_key_status_into`]. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - /// Value-carrying persistence variant. See - /// [`ErasedBucket::collect_persistable_into`]. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -74,35 +58,26 @@ pub(crate) trait ErasedInfiniteBucket { now_ms: u64, out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, ); - /// Whether the bucket currently holds `key`. See - /// [`ErasedBucket::contains_key`]. #[cfg(feature = "persist")] fn contains_key(&self, key: &crate::core::QueryKey) -> bool; } -/// Type-erased mutation bucket trait. pub(crate) trait ErasedMutationBucket { fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &gpui::App); fn count(&self) -> usize; - /// Push each live entry's `MutationDiagnostic` into `out`. See - /// [`ErasedBucket::collect_diagnostics_into`] for the rationale. fn collect_diagnostics_into(&self, cx: &gpui::App, out: &mut Vec<MutationDiagnostic>); - /// Key/status pairs without full diagnostics (`key` is `None` for keyless - /// mutations); used by `dehydrate`. + /// `key` is `None` for keyless mutations. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(Option<String>, MutationStatus)>); } -/// Legacy synchronous persistence adapter trait for the metadata-only -/// `dehydrate`/`hydrate`/`persist`/`restore` methods. The richer async -/// value-carrying surface is [`Persister`](crate::client::Persister) plus +/// Legacy metadata-only persistence: entries serialize as JSON strings, +/// avoiding generic bounds. The async value-carrying surface is +/// [`Persister`](crate::client::Persister) plus /// [`persist_with`](crate::client::QueryClient::persist_with). /// -/// Entries are serialized as JSON strings to avoid generic bounds on the -/// persister; implementations can target any backend. -/// /// # Example /// /// ``` @@ -118,9 +93,8 @@ pub(crate) trait ErasedMutationBucket { /// ``` #[cfg(feature = "persist")] pub trait QueryPersister: Send + Sync { - /// Load persisted entries from storage. fn load(&self) -> Vec<crate::client::devtools::DehydratedEntry>; - /// Save entries to storage, replacing any previously stored data. + /// Replaces any previously stored data. fn save(&self, entries: Vec<crate::client::devtools::DehydratedEntry>); } diff --git a/crates/gpui-query/src/client/infinite_bucket.rs b/crates/gpui-query/src/client/infinite_bucket.rs index 6a4029e..ce8483f 100644 --- a/crates/gpui-query/src/client/infinite_bucket.rs +++ b/crates/gpui-query/src/client/infinite_bucket.rs @@ -1,9 +1,6 @@ -//! Type-partitioned bucket for infinite query resources. -//! //! Shares its machinery with [`QueryBucket`] through //! [`ResourceBucket`](super::bucket::shared::ResourceBucket); only the erased -//! trait impl and the first-page persistence path are specific to infinite -//! queries. +//! trait impl and the first-page persistence path are infinite-specific. use gpui::{App, Entity}; @@ -16,7 +13,6 @@ use super::bucket::shared::ResourceBucket; use super::devtools::QueryDiagnostic; use super::ErasedInfiniteBucket; -/// Type-partitioned storage for infinite query resources of a specific `(T, E)` type pair. pub struct InfiniteQueryBucket<T, E> { entries: ResourceBucket<InfiniteQueryResource<T, E>>, } @@ -72,8 +68,6 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.entries.for_each_matching_entry(filter, cx, |entity, cx| { - // Skip the notify when last_updated_at is already None (invalidate - // only clears that one field). let needs_invalidate = entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); if needs_invalidate { @@ -92,9 +86,8 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI self.entries.entries.retain(|k, _| !filter.matches(k)); } - /// Gate on the authoritative `is_loading()` read; see - /// `QueryBucket::cancel_matching`. Also bumps `ignored_results` so - /// cancelled infinite fetches match the regular query path. + /// Bumps `ignored_results` so cancelled infinite fetches match the + /// regular query path. See `QueryBucket::cancel_matching`. fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.entries.for_each_matching_entry(filter, cx, |entity, cx| { if entity.read_with(cx, |r, _| r.is_loading()) { @@ -123,8 +116,6 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI } /// Persists the first page only; the full page vector is opaque here. - /// Entries without a registered serializer, or not in `Success`, are - /// skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, @@ -138,7 +129,6 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI ) { use crate::core::QueryStatus; - // Serializers are registered by `T` alone, not the `(T, E)` pair. let type_id = std::any::TypeId::of::<T>(); let Some(serialize_fn) = serializers.get(type_id) else { return; diff --git a/crates/gpui-query/src/client/infinite_mutation_ops.rs b/crates/gpui-query/src/client/infinite_mutation_ops.rs index ee7b286..4fe7fdc 100644 --- a/crates/gpui-query/src/client/infinite_mutation_ops.rs +++ b/crates/gpui-query/src/client/infinite_mutation_ops.rs @@ -13,9 +13,6 @@ use crate::core::{ use super::QueryClient; impl QueryClient { - // ── Infinite query operations ─────────────────────────────────────── - - /// Get or create an infinite query resource for the given key and type pair. pub fn infinite_resource<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &mut self, key: impl Into<QueryKey>, @@ -29,7 +26,6 @@ impl QueryClient { ) } - /// Get or create an infinite query resource with explicit policies. pub fn infinite_resource_with_policies< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -52,7 +48,6 @@ impl QueryClient { entity } - /// Get a specific infinite query entity by key. pub fn infinite_query<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, key: &QueryKey, @@ -64,9 +59,6 @@ impl QueryClient { .and_then(|b| b.get(key)) } - /// Use the infinite query bucket's co-located sequencer to generate a - /// `RequestId` for a key; the infinite-query counterpart of - /// [`next_request_id_for_key`](Self::next_request_id_for_key). pub fn next_request_id_for_infinite_key< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -80,7 +72,6 @@ impl QueryClient { typed.sequencer_mut(key).map(|seq| seq.next_request()) } - /// Get all infinite query entities of a given type pair. pub fn all_infinite_queries< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -95,9 +86,6 @@ impl QueryClient { .unwrap_or_default() } - // ── Mutation operations ───────────────────────────────────────────── - - /// Register a mutation entity. pub fn register_mutation< V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, @@ -113,14 +101,12 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(MutationBucket::<V, T, E>::new())); - // One clock read shared by insert and the opportunistic GC below. let now_ms = crate::client::time::current_time_ms(); let typed = Self::mutation_bucket_or_recreate::<V, T, E>(bucket); typed.insert(entity, now_ms, cx); self.maybe_opportunistic_gc(cx); } - /// Get all mutation entities of a given type triple. pub fn all_mutations< V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, @@ -136,8 +122,6 @@ impl QueryClient { .unwrap_or_default() } - // ── Bulk operations ───────────────────────────────────────────────── - fn for_each_query_bucket_mut<F>(&mut self, mut f: F) where F: FnMut(EitherBucket<'_>), @@ -150,7 +134,7 @@ impl QueryClient { } } - /// Invalidate queries matching the filter (data is kept but marked stale). + /// Data is kept but marked stale. pub fn invalidate_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.invalidate_matching(filter, cx), @@ -158,7 +142,7 @@ impl QueryClient { }); } - /// Reset queries matching the filter (data and status cleared). + /// Data and status are cleared. pub fn reset_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.reset_matching(filter, cx), @@ -166,7 +150,7 @@ impl QueryClient { }); } - /// Remove queries matching the filter from the cache entirely. + /// Entries are removed from the cache entirely. pub fn remove_queries(&mut self, filter: &QueryKeyFilter) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.remove_matching(filter), @@ -174,12 +158,9 @@ impl QueryClient { }); } - /// Cancel in-flight requests matching the filter, cancelling their - /// signals with a [`QueryError::cancelled`](crate::core::QueryError::cancelled) - /// error. Essential for cleanup when navigating away from a page. - /// - /// The bulk counterpart of `QueryResource::cancel()`, equivalent to - /// TanStack Query's `queryClient.cancelQueries()`. + /// Cancels matching in-flight requests with a + /// [`QueryError::cancelled`](crate::core::QueryError::cancelled) error; + /// TanStack `queryClient.cancelQueries()`. pub fn cancel_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_query_bucket_mut(|b| match b { EitherBucket::Query(b) => b.cancel_matching(filter, cx), @@ -187,12 +168,6 @@ impl QueryClient { }); } - // ── Erased-bucket recovery helpers ────────────────────────────────── - - // Downcast counterparts of `bucket_or_recreate` for the infinite and - // mutation maps: recreate in place on the (unreachable) mismatch instead - // of panicking. - fn infinite_bucket_or_recreate< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -243,7 +218,6 @@ impl QueryClient { } } -/// One side of a query bucket iteration. enum EitherBucket<'a> { Query(&'a mut dyn crate::client::erased::ErasedBucket), Infinite(&'a mut dyn crate::client::erased::ErasedInfiniteBucket), diff --git a/crates/gpui-query/src/client/lifecycle.rs b/crates/gpui-query/src/client/lifecycle.rs index f7f7f8e..7678c6d 100644 --- a/crates/gpui-query/src/client/lifecycle.rs +++ b/crates/gpui-query/src/client/lifecycle.rs @@ -17,23 +17,15 @@ use super::QueryClient; use crate::client::erased::QueryPersister; impl QueryClient { - // ── Garbage collection ────────────────────────────────────────────── - - /// Run garbage collection on all buckets. - /// - /// Calls `current_time_ms()` internally; if you already have a cached - /// time value, use [`gc_with_time`](Self::gc_with_time) to avoid the - /// syscall. + /// Calls `current_time_ms()` internally; use + /// [`gc_with_time`](Self::gc_with_time) if you already hold a time value. pub fn gc(&mut self, cx: &App) { let now_ms = current_time_ms(); self.gc_with_time(now_ms, cx); } - /// Run garbage collection with a pre-computed time value (milliseconds - /// since the UNIX epoch), amortizing `SystemTime::now()` across calls. - /// - /// Also stamps `last_gc_ms` so a manual GC debounces the next - /// opportunistic sweep. + /// Stamps `last_gc_ms`, so a manual GC debounces the next opportunistic + /// sweep. pub fn gc_with_time(&mut self, now_ms: u64, cx: &App) { self.last_gc_ms = now_ms; for bucket in self.buckets.values_mut() { @@ -45,8 +37,7 @@ impl QueryClient { for bucket in self.mutation_buckets.values_mut() { bucket.gc(now_ms, self.gc_time_ms, cx); } - // Metadata for keys whose entries were evicted can never be collected - // again; drop it so churning keys cannot grow the map without bound. + // Evicted keys' meta can never be collected again; drop it so churning keys can't grow the map. #[cfg(feature = "persist")] if let Some(meta) = self.persisted_meta.as_mut() { meta.retain(|key, _| { @@ -56,18 +47,10 @@ impl QueryClient { } } - // ── Diagnostics ───────────────────────────────────────────────────── - - /// Get diagnostics for all queries and mutations. - /// - /// Returns aggregate counts plus per-resource details, collected by - /// iterating bucket entries, upgrading weak references, and reading - /// entity state. Dead entries (collected entities) are skipped, so the - /// counts are an upper bound on the returned vectors. + /// Collected entities are skipped, so the aggregate counts are an upper + /// bound on the returned vectors. pub fn diagnostics(&self, cx: &App) -> ClientDiagnostic { let now_ms = current_time_ms(); - // Pre-size from the bucket counts (entries.len()) so the per-bucket - // pushes never reallocate. let mut query_count = 0; let mut mutation_count = 0; for bucket in self.buckets.values() { @@ -100,17 +83,9 @@ impl QueryClient { } } - // ── Serialization / hydration ─────────────────────────────────────── - - /// Serialize cached query state into a portable format: keys, status, - /// and type information for every live `Success` resource (other - /// statuses are skipped). The resulting [`DehydratedState`] can be - /// persisted or restored via [`hydrate`](Self::hydrate). - /// - /// Full data serialization needs type-specific code at the call site: - /// use [`get_query_data`](Self::get_query_data) to extract typed data - /// and serialize it externally. `DehydratedState` carries the metadata - /// (keys, type IDs) needed for typed restoration. + /// Only live `Success` resources are included. Full data serialization + /// is type-specific: extract via + /// [`get_query_data`](Self::get_query_data) and serialize externally. #[cfg(feature = "persist")] pub fn dehydrate(&self, cx: &App) -> DehydratedState { let cap = self.buckets.values().map(|b| b.count()).sum::<usize>() @@ -144,8 +119,7 @@ impl QueryClient { } } - // Two scratch buffers reused across buckets; drained per bucket so - // they never grow and the keys move into `entries` without cloning. + // Scratch buffers drained per bucket: keys move into `entries` without cloning. let mut q_pairs: Vec<(String, QueryStatus)> = Vec::new(); let mut m_pairs: Vec<(Option<String>, MutationStatus)> = Vec::new(); @@ -183,45 +157,28 @@ impl QueryClient { DehydratedState { entries } } - /// Restore query state from a previously dehydrated snapshot. - /// - /// Full hydration requires type-specific deserialization: - /// `DehydratedState` stores `type_id` keys, but downcasting needs the - /// concrete types at the call site. Callers should iterate - /// `state.entries` and call `set_query_data::<T, E>()` for each entry - /// whose types they know. This hook point mirrors TanStack Query's - /// `queryClient.hydrate()`. + /// Hook point only: `DehydratedState` stores erased `type_id`s, so + /// callers restore typed data themselves via + /// `set_query_data::<T, E>()` for entries whose types they know. #[cfg(feature = "persist")] pub fn hydrate(&mut self, _state: DehydratedState, _cx: &mut App) {} - // ── Persistence ───────────────────────────────────────────────────── - - /// Persist the dehydrated state via the provided persister. Can be - /// called periodically (e.g. during GC) or on app shutdown. #[cfg(feature = "persist")] pub fn persist(&self, persister: &dyn QueryPersister, cx: &App) { let state = self.dehydrate(cx); persister.save(state.entries); } - /// Load entries from a persister. Type information is erased in the - /// persister, so callers iterate and restore typed data themselves via - /// `set_query_data`. An associated function: it reads no client state, - /// so it needs no borrow on the client. + /// Types are erased in the persister, so callers restore via + /// `set_query_data` themselves. Associated fn: reads no client state. #[cfg(feature = "persist")] pub fn restore(persister: &dyn QueryPersister) -> Vec<DehydratedEntry> { persister.load() } - // ── Imperative fetch ──────────────────────────────────────────────── - - /// Prepare an imperative fetch for a query key, creating the resource if - /// needed, and begin a forced request. Returns a [`PreparedFetch`] with - /// the entity, request ID, and signal; the caller runs the fetcher and - /// completes the request via `complete_success` / `complete_failure`. - /// - /// The equivalent of TanStack Query's `queryClient.fetchQuery()`. - /// Unlike `use_query`, this does not subscribe or create an observer. + /// Imperative fetch (TanStack `fetchQuery`): no observer is attached; + /// the caller runs the fetcher and completes the request via + /// `complete_success` / `complete_failure`. /// /// # Example /// @@ -260,7 +217,6 @@ impl QueryClient { cx, ); - // Begin the request and pull the signal from the same update. let (request_id, signal) = entity.update(cx, |resource, _| { let _ = resource.begin_request_with_id( Some(request_id), @@ -280,17 +236,10 @@ impl QueryClient { }) } - // ── Prefetch ──────────────────────────────────────────────────────── - - /// Prepare a prefetch for a key that will be needed soon: creates (or - /// reuses) the resource and begins a request if the cache is stale or - /// empty. No observer is attached; a later `use_query` with the same key - /// finds the prefetched data. - /// - /// The equivalent of TanStack Query's `queryClient.prefetchQuery()`. - /// Returns `None` on a fresh cache hit (use - /// [`get_query_data`](Self::get_query_data) to read it) or when the - /// request policy ignored the start. + /// Returns `None` on a fresh cache hit (read it via + /// [`get_query_data`](Self::get_query_data)) or when the request policy + /// ignored the start. No observer is attached; a later `use_query` with + /// the same key finds the prefetched data. pub fn prepare_prefetch_query< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -305,8 +254,7 @@ impl QueryClient { let (entity, request_id) = self.resource_with_request_id::<T, E>(key, cache_policy, request_policy, cx); - // Normal mode respects the cache policy; only Started and - // StaleCacheHit mean a fetch is actually wanted. + // Only Started and StaleCacheHit mean a fetch is actually wanted. let (request_id, signal) = entity.update(cx, |resource, _| { let started = matches!( resource.begin_request_with_id( diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index 0cb1fbf..298ddf1 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -49,11 +49,8 @@ use crate::client::bucket::types::MIN_GC_TIME_MS; use crate::client::erased::{ErasedBucket, ErasedInfiniteBucket, ErasedMutationBucket}; use crate::core::{CachePolicy, QueryKey, QueryResource, RequestPolicy}; -/// Global registry for query and mutation resources. -/// -/// Implements [`Global`] so it can be set once with -/// `cx.set_global(QueryClient::default())` and accessed from any component -/// via `cx.global::<QueryClient>()`. +/// Implements [`Global`]: set once with `cx.set_global(QueryClient::default())`, +/// read from any component via `cx.global::<QueryClient>()`. pub struct QueryClient { pub(crate) buckets: AHashMap<TypeId, Box<dyn ErasedBucket>>, pub(crate) infinite_buckets: AHashMap<TypeId, Box<dyn ErasedInfiniteBucket>>, @@ -61,34 +58,27 @@ pub struct QueryClient { pub(crate) default_cache_policy: CachePolicy, pub(crate) default_request_policy: RequestPolicy, pub(crate) gc_time_ms: u64, - /// Typed-serializer registry for the value-carrying persistence path - /// (`persist` feature), populated by `register_serializer::<T, E>`. +/// Populated by `register_serializer::<T, E>` (value-carrying persistence path). #[cfg(feature = "persist")] pub(crate) serializers: Option<crate::client::persist::SerializerRegistry>, - /// Typed-deserializer registry for [`hydrate`] (`persist` feature), - /// populated by `register_deserializer::<T, E>`. + /// Populated by `register_deserializer::<T, E>`, consumed by [`hydrate`]. #[cfg(feature = "persist")] pub(crate) deserializers: Option<crate::client::persist::DeserializerRegistry>, - /// Opaque per-key metadata captured from `Fetched::meta` at fetch - /// completion, surfaced into `PersistedEntry::meta` at collect time so - /// HTTP `CacheMeta` and similar round-trip through a cold start. Pruned - /// of evicted keys by GC. + /// Per-key metadata from `Fetched::meta`, surfaced into + /// `PersistedEntry::meta`; pruned of evicted keys by GC. #[cfg(feature = "persist")] pub(crate) persisted_meta: Option<std::collections::HashMap<crate::core::QueryKey, serde_json::Value>>, - /// Operation counter driving opportunistic GC every `GC_INTERVAL` ops. op_count: u64, - /// Wall-clock ms of the last GC sweep; GC runs at most once per - /// `MIN_GC_TIME_MS`. `0` means "not yet seeded" (avoids a syscall at - /// construction; the first reach seeds it and skips that sweep). + /// Wall-clock ms of the last sweep; `0` means "not yet seeded" (the first + /// reach seeds it and skips that sweep). last_gc_ms: u64, } impl Global for QueryClient {} impl Default for QueryClient { - /// `gc_time_ms` defaults to 300_000 (5 minutes), matching - /// [`with_policies`](Self::with_policies). + /// `gc_time_ms` defaults to 300_000 (5 minutes). fn default() -> Self { Self { buckets: AHashMap::new(), @@ -110,12 +100,10 @@ impl Default for QueryClient { } impl QueryClient { - /// Create a new client with default policies. pub fn new() -> Self { Self::default() } - /// Create with custom default policies. pub fn with_policies( default_cache_policy: CachePolicy, default_request_policy: RequestPolicy, @@ -123,13 +111,11 @@ impl QueryClient { Self { default_cache_policy, default_request_policy, - gc_time_ms: 300_000, // 5 minutes + gc_time_ms: 300_000, ..Default::default() } } - /// Set the garbage collection time (in milliseconds). - /// /// Values below 1000ms are clamped to 1000ms during GC to prevent /// aggressive eviction of all Idle/Failure resources on every GC pass. /// A value of 0 disables GC entirely. @@ -138,10 +124,8 @@ impl QueryClient { self } - /// Record opaque metadata (e.g. a serialized HTTP `CacheMeta`) for `key`, - /// captured from a fetcher's `Fetched::meta` at completion and surfaced - /// into `PersistedEntry::meta` so it round-trips through persistence. - /// `persist` feature only. + /// Captured from a fetcher's `Fetched::meta` at completion, surfaced into + /// `PersistedEntry::meta`. `persist` feature only. #[cfg(feature = "persist")] pub(crate) fn record_meta(&mut self, key: crate::core::QueryKey, meta: serde_json::Value) { self.persisted_meta @@ -149,10 +133,8 @@ impl QueryClient { .insert(key, meta); } - /// GC trigger for resource-creating ops: runs GC every `GC_INTERVAL` - /// operations, at most once per `MIN_GC_TIME_MS`, so the GC subsystem - /// fires in production without hooks calling `gc()` explicitly. - /// `gc_time_ms` of 0 disables GC entirely. + /// Runs GC every `GC_INTERVAL` operations, at most once per + /// `MIN_GC_TIME_MS`; `gc_time_ms` of 0 disables GC entirely. fn maybe_opportunistic_gc(&mut self, cx: &App) { if self.gc_time_ms == 0 { return; @@ -173,9 +155,6 @@ impl QueryClient { self.gc_with_time(now_ms, cx); } - // ── Query operations ──────────────────────────────────────────────── - - /// Get or create a query resource for the given key and type pair. pub fn resource<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &mut self, key: impl Into<QueryKey>, @@ -189,10 +168,6 @@ impl QueryClient { ) } - /// Get or create a query resource with explicit policies. - /// - /// A bucket downcast mismatch (impossible while `TypeId` keys are - /// sound) replaces the bucket instead of panicking. pub fn resource_with_policies< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -215,9 +190,8 @@ impl QueryClient { entity } - /// Private [`resource_with_policies`](Self::resource_with_policies) - /// variant that mints the request id in the same bucket lookup, skipping - /// the second TypeId+key hash of a follow-up `next_request_id_for_key`. + /// Mints the request id in the same bucket lookup, skipping a second + /// TypeId+key hash of a follow-up `next_request_id_for_key`. fn resource_with_request_id<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &mut self, key: impl Into<QueryKey>, @@ -237,7 +211,6 @@ impl QueryClient { (entity, request_id) } - /// Get all query entities of a given type pair. pub fn all_queries<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, ) -> Vec<Entity<QueryResource<T, E>>> { @@ -249,7 +222,6 @@ impl QueryClient { .unwrap_or_default() } - /// Get a specific query entity by key. pub fn query<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, key: &QueryKey, @@ -261,10 +233,8 @@ impl QueryClient { .and_then(|b| b.get(key)) } - /// Use the bucket's co-located sequencer to generate a `RequestId` for a - /// key. Returns `None` if no bucket entry exists for the key. The - /// sequencer is persistent, so IDs stay monotonic for the entry's - /// lifetime. + /// Returns `None` if no bucket entry exists for the key; the sequencer is + /// persistent, so IDs stay monotonic for the entry's lifetime. pub fn next_request_id_for_key< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -278,11 +248,8 @@ impl QueryClient { typed.sequencer_mut(key).map(|seq| seq.next_request()) } - // ── Erased-bucket recovery helper ─────────────────────────────────── - - /// Downcast an erased bucket to `&mut QueryBucket<T, E>`. On a mismatch - /// (unreachable while `TypeId` keys are sound) the bucket is replaced - /// with a fresh typed one rather than panicking. + /// Recreates the bucket in place on a downcast mismatch (unreachable + /// while `TypeId` keys are sound) instead of panicking. fn bucket_or_recreate<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( bucket: &mut Box<dyn ErasedBucket>, ) -> &mut QueryBucket<T, E> { @@ -298,22 +265,14 @@ impl QueryClient { ); *bucket = Box::new(QueryBucket::<T, E>::new()); } - // Infallible: either the original downcast succeeded, or we just - // replaced the bucket with a freshly constructed typed one. bucket .as_any_mut() .downcast_mut::<QueryBucket<T, E>>() .expect("QueryBucket downcast succeeds after bucket_or_recreate") } - // ── Data accessors ────────────────────────────────────────────────── - - /// Read the cached data for a query key directly, without going through - /// a hook. Returns `None` if no resource exists for the key, the entity - /// was collected, or the resource has not completed a fetch. - /// - /// The ergonomic equivalent of TanStack Query's - /// `queryClient.getQueryData(key)`. + /// Returns `None` if no resource exists for the key, the entity was + /// collected, or the resource has not completed a fetch. pub fn get_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &self, key: &QueryKey, @@ -323,12 +282,9 @@ impl QueryClient { entity.read_with(cx, |resource, _| resource.data().cloned()) } - /// Read the cached data via a borrow callback, with no clone of `T`. - /// - /// The zero-clone counterpart to [`get_query_data`](Self::get_query_data): - /// `f` receives `&T` for the duration of the call, for callers that only - /// inspect the data and would discard a full `T::clone()`. Returns - /// `None` under the same conditions as `get_query_data`. + /// Zero-clone counterpart to [`get_query_data`](Self::get_query_data): + /// `f` receives `&T` for the duration of the call. Same `None` + /// conditions. pub fn with_query_data< T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, @@ -343,13 +299,9 @@ impl QueryClient { entity.read_with(cx, |resource, _| resource.data().map(f)) } - /// Write data directly into the cache for a query key, creating the - /// resource if it does not already exist. The previous data is saved for - /// rollback via `rollback_to_previous()`. The write does not change the - /// resource's status or timestamp. - /// - /// The ergonomic equivalent of TanStack Query's - /// `queryClient.setQueryData(key, data)`. + /// Creates the resource if absent; the previous data is kept for + /// `rollback_to_previous()` and the resource's status and timestamp are + /// unchanged. pub fn set_query_data<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( &mut self, key: impl Into<QueryKey>, @@ -360,10 +312,6 @@ impl QueryClient { let entity = self.resource::<T, E>(key, cx); entity.update(cx, |resource, cx| { resource.set_data(data); - // Bump the dirty signal so `persist_with` schedules a save. - // `default_global` seeds the marker if absent and pushes GPUI's - // NotifyGlobalObservers effect, which the `persist_with` driver - // observes; it is infallible. #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); #[cfg(not(feature = "persist"))] diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 93525b8..4ce0b04 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -1,8 +1,6 @@ -//! Type-partitioned bucket for mutation resources. -//! -//! Mutations are keyed by a generated numeric id (they have no query key), -//! and GC measures recency from the resource's completion time, falling back -//! to the insertion timestamp for mutations that never completed. +//! Mutations have no query key: entries are keyed by a generated numeric id, +//! and GC measures recency from the completion time (insertion time for +//! mutations that never completed). use ahash::AHashMap; use gpui::{App, WeakEntity}; @@ -13,10 +11,9 @@ use super::ErasedMutationBucket; use super::bucket::types::{DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; use super::devtools::MutationDiagnostic; -/// Weak entity handle plus the eviction mirror. `updated_at` is the insertion -/// time; `last_updated_ms` / `loading` mirror the entity and are refreshed -/// wherever the bucket already reads it, so `evict_oldest` scans cheap fields -/// and confirms its winner with a single entity read. +/// `last_updated_ms` / `loading` mirror the entity, refreshed wherever the +/// bucket already reads it, so `evict_oldest` scans cheap fields and +/// confirms its winner with a single entity read. struct MutationEntry<V, T, E> { entity: WeakEntity<MutationResource<V, T, E>>, updated_at: u64, @@ -24,7 +21,6 @@ struct MutationEntry<V, T, E> { loading: bool, } -/// Type-partitioned storage for mutation resources. pub struct MutationBucket<V, T, E> { resources: AHashMap<u64, MutationEntry<V, T, E>>, next_id: u64, @@ -46,11 +42,9 @@ impl< } } - /// Evict the least-recently-updated entry to make room. Recency prefers - /// the completion-time mirror, falling back to the insertion time for - /// mutations that never completed. The winner gets one confirming entity - /// read (the mirror can be stale if a fetch began after the last - /// refresh); each retry marks the stale mirror and re-picks. + /// Skips loading entries; the winner gets one confirming entity read + /// (the mirror can be stale if a fetch began after the last refresh), + /// and each stale re-check marks the mirror and re-picks. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self @@ -66,7 +60,7 @@ impl< .min_by_key(|&(_, age)| age); let Some((id, _)) = target else { - return; // every live entry is loading: nothing safe to evict + return; }; let still_loading = self @@ -89,12 +83,8 @@ impl< } } - /// Insert a mutation entity, recording `now_ms` as `updated_at`, and - /// return the generated id. Evicts the oldest non-loading entry first - /// when at capacity. - /// - /// `next_id` saturates at `u64::MAX`: staying monotonic matters more than - /// uniqueness after ~1.8e19 insertions, which GC has long outlived. + /// `next_id` saturates at `u64::MAX`: staying monotonic matters more + /// than uniqueness after ~1.8e19 insertions, which GC has long outlived. pub(crate) fn insert( &mut self, entity: &gpui::Entity<MutationResource<V, T, E>>, @@ -141,11 +131,10 @@ impl< self } - /// Evict dead references and terminal mutations past their age window: - /// loading always survives; `Success` survives - /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`; `Idle`/`Failure` survive - /// `gc_time_ms`. The entry `loading` mirror is checked first so a - /// mid-flight mutation whose weak ref cannot upgrade survives one cycle. + /// Loading always survives; `Success` survives + /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`, `Idle`/`Failure` survive + /// `gc_time_ms`. The `loading` mirror is checked first so a mid-flight + /// mutation whose weak ref cannot upgrade survives one cycle. fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); @@ -173,8 +162,6 @@ impl< MutationStatus::Loading => return true, }; - // Recency from the completion time when available; insertion - // time for mutations that never completed. let base = resource.last_updated_at_ms().unwrap_or(entry.updated_at); now_ms.saturating_sub(base) < threshold }); @@ -198,8 +185,6 @@ impl< } } - /// `key` is `None` for keyless mutations, mirroring - /// [`MutationDiagnostic::key`]. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &App, out: &mut Vec<(Option<String>, MutationStatus)>) { for entry in self.resources.values() { diff --git a/crates/gpui-query/src/client/mutation_signal.rs b/crates/gpui-query/src/client/mutation_signal.rs index 5bcdf97..e234013 100644 --- a/crates/gpui-query/src/client/mutation_signal.rs +++ b/crates/gpui-query/src/client/mutation_signal.rs @@ -1,11 +1,8 @@ -/// Marker [`gpui::Global`] bumped whenever cached query data changes. -/// -/// The value carries no state: bump sites call -/// `cx.default_global::<CacheMutation>()`, which pushes GPUI's -/// `NotifyGlobalObservers` effect exactly like `set_global`, and that -/// notification is what `observe_global::<CacheMutation>()` listeners -/// (the `persist_with` driver) react to. `default_global` is infallible and -/// seeds the marker on first bump, so bump sites never panic. +/// Carries no state: bump sites call `cx.default_global::<CacheMutation>()`, +/// which pushes GPUI's `NotifyGlobalObservers` effect, and that notification +/// is what `observe_global` listeners (the `persist_with` driver) react to. +/// `default_global` seeds the marker on first bump, so bump sites never +/// panic. #[derive(Default)] pub struct CacheMutation; diff --git a/crates/gpui-query/src/client/observer.rs b/crates/gpui-query/src/client/observer.rs index 4371c53..30cc274 100644 --- a/crates/gpui-query/src/client/observer.rs +++ b/crates/gpui-query/src/client/observer.rs @@ -1,5 +1,3 @@ -//! Resource observers for reactive state tracking. -//! //! The three observer kinds (`QueryObserver`, `InfiniteQueryObserver`, //! `MutationObserver`) are aliases over one generic [`Observer<R>`]; they //! differ only in entity and status type. @@ -12,12 +10,8 @@ use crate::core::{ InfiniteQueryResource, MutationResource, MutationStatus, QueryResource, QueryStatus, }; -/// Bridges a resource type to its status for the generic [`Observer`]. -/// -/// Each resource exposes its status via an inherent `status()` method, which -/// cannot be called generically without a trait; this pub(crate) trait -/// surfaces it with an associated `Status` type so [`Observer<R>`] can dedup -/// notifications for any resource kind. +/// Surfaces each resource's inherent `status()` generically (with a `Status` +/// assoc type) so [`Observer<R>`] can dedup notifications for any kind. pub trait ObservableResource { type Status: PartialEq + Copy + 'static; @@ -48,10 +42,8 @@ impl<V: 'static, T: 'static, E: 'static> ObservableResource for MutationResource } } -/// Configuration for a query observer. #[derive(Clone, Debug)] pub struct ObserverConfig { - /// Only notify when status changes (dedup re-renders). pub notify_on_status_change_only: bool, } @@ -63,20 +55,15 @@ impl Default for ObserverConfig { } } -/// Observes a resource and triggers re-renders only on status changes. -/// /// With the default config, `cx.notify()` fires only when the status -/// actually changes, so intermediate updates that keep the status (retry -/// count increments, `prepare_retry`) do not re-render. Use the -/// [`QueryObserver`] / [`InfiniteQueryObserver`] / [`MutationObserver`] -/// aliases for the concrete kinds. +/// actually changes, so same-status updates (retry count increments, +/// `prepare_retry`) do not re-render. pub struct Observer<R> { entity: gpui::WeakEntity<R>, config: ObserverConfig, } impl<R: ObservableResource + 'static> Observer<R> { - /// Create a new observer for the given entity. pub fn new(entity: &Entity<R>) -> Self { Self { entity: entity.downgrade(), @@ -84,15 +71,13 @@ impl<R: ObservableResource + 'static> Observer<R> { } } - /// Set the observer configuration. pub fn with_config(mut self, config: ObserverConfig) -> Self { self.config = config; self } - /// Start observing the entity. Returns `None` if the entity was already - /// dropped. Takes `&self`: the body only reads the weak handle and the - /// `Copy` config flag. + /// Returns `None` if the entity was already dropped; takes `&self` since + /// the body only reads the weak handle and the `Copy` config flag. pub fn observe<W: 'static>(&self, cx: &mut Context<W>) -> Option<Subscription> { let upgraded = self.entity.upgrade()?; let notify_on_change = self.config.notify_on_status_change_only; @@ -115,11 +100,8 @@ impl<R: ObservableResource + 'static> Observer<R> { } } -/// Observer for a [`QueryResource`] (status type [`QueryStatus`]). pub type QueryObserver<T, E> = Observer<QueryResource<T, E>>; -/// Observer for an [`InfiniteQueryResource`] (status type [`QueryStatus`]). pub type InfiniteQueryObserver<T, E> = Observer<InfiniteQueryResource<T, E>>; -/// Observer for a [`MutationResource`] (status type [`MutationStatus`]). pub type MutationObserver<V, T, E> = Observer<MutationResource<V, T, E>>; diff --git a/crates/gpui-query/src/client/prepared_fetch.rs b/crates/gpui-query/src/client/prepared_fetch.rs index 4abf034..299a675 100644 --- a/crates/gpui-query/src/client/prepared_fetch.rs +++ b/crates/gpui-query/src/client/prepared_fetch.rs @@ -1,18 +1,9 @@ -//! [`PreparedFetch`]: the handle returned by the imperative fetch and -//! prefetch operations. - use gpui::{App, Entity}; use crate::core::QueryResource; -/// A prepared fetch returned by -/// [`QueryClient::prepare_fetch_query`](crate::client::QueryClient::prepare_fetch_query) -/// or -/// [`QueryClient::prepare_prefetch_query`](crate::client::QueryClient::prepare_prefetch_query). -/// -/// Holds the entity, request ID, and cooperative cancellation signal needed -/// to perform the async fetch: call your fetcher with `self.signal`, then -/// complete via `complete_success` or `complete_failure`. +/// Run your fetcher with `self.signal`, then complete via +/// `complete_success` or `complete_failure`. /// /// # Example /// @@ -34,26 +25,21 @@ use crate::core::QueryResource; /// ``` #[must_use = "the prepared fetch holds the request ID and cancellation signal; dropping it without calling complete_success/complete_failure abandons the in-flight request"] pub struct PreparedFetch<T, E> { - /// The query resource entity. pub entity: Entity<QueryResource<T, E>>, - /// The request ID for the started request. pub request_id: crate::core::RequestId, - /// The cooperative cancellation signal for the in-flight request. pub signal: crate::core::QuerySignal, - /// Completion time captured at prepare time; the fetch's logical - /// completion clock, reused by the complete_* methods. + /// The fetch's logical completion clock, captured at prepare time and + /// reused by the complete_* methods. pub(crate) now_ms: u64, } impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> PreparedFetch<T, E> { - /// Complete the fetch with success. A no-op if the request ID is no - /// longer active (replaced by a newer request). + /// A no-op if the request ID is no longer active (replaced by a newer + /// request). pub fn complete_success(self, data: T, cx: &mut App) { - // `_cx`: used only under the persist feature. self.entity.update(cx, |resource, _cx| { let accepted = resource.complete_current_success(self.request_id, data, self.now_ms); - // Wake the persistence driver, but only when the completion was - // actually accepted, so a stale no-op does not schedule a save. + // Wake the persistence driver only when accepted; a stale no-op must not schedule a save. if accepted { #[cfg(feature = "persist")] _cx.default_global::<crate::client::CacheMutation>(); @@ -61,8 +47,8 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare }); } - /// Complete the fetch with failure. A no-op if the request ID is no - /// longer active (replaced by a newer request). + /// A no-op if the request ID is no longer active (replaced by a newer + /// request). pub fn complete_failure(self, error: E, cx: &mut App) { self.entity.update(cx, |resource, _cx| { let accepted = resource.complete_current_failure(self.request_id, error, self.now_ms); diff --git a/crates/gpui-query/src/client/time.rs b/crates/gpui-query/src/client/time.rs index 0b55616..29a9964 100644 --- a/crates/gpui-query/src/client/time.rs +++ b/crates/gpui-query/src/client/time.rs @@ -1,12 +1,6 @@ -/// Returns the current time as milliseconds since the UNIX epoch. -/// -/// Callers can cache the value and pass it to -/// [`gc_with_time`](crate::client::QueryClient::gc_with_time) to avoid -/// repeated syscalls. -/// -/// A clock reading before the Unix epoch clamps to `0` rather than -/// propagating an error; GC treats `0` as "ancient", so the only effect of -/// such a clock anomaly is that entries become immediately GC-eligible. +/// A clock reading before the Unix epoch clamps to `0`, which GC treats as +/// "ancient": the only effect of such a clock anomaly is that entries become +/// immediately GC-eligible. pub fn current_time_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs b/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs index e816236..20defcb 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs @@ -1,8 +1,3 @@ -//! Concurrency / two-phase completion protocol tests. -//! -//! Verify that the two-phase completion protocol maintains invariants even when -//! requests are interleaved. Also covers signal and is_data_stale tests. - use crate::core::*; use crate::tests::test_support::*; @@ -13,7 +8,6 @@ fn two_phase_protocol_accept_then_complete_is_consistent() { let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - // Phase 1: accept let guard = r .accept_current_request(rid) .expect("should accept current request"); @@ -22,7 +16,6 @@ fn two_phase_protocol_accept_then_complete_is_consistent() { "accept clears active_request_id" ); - // Phase 2: complete with success r.complete_success(guard, "result", 200); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"result")); @@ -30,19 +23,15 @@ fn two_phase_protocol_accept_then_complete_is_consistent() { #[test] fn two_phase_stale_accept_then_complete_does_not_corrupt() { - // Begin two requests, try to complete the first (stale) — it should be - // rejected, and the second should complete successfully. let mut r = fresh_resource(); let mut s = test_sequencer(); let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - // rid1 is stale. complete_current_success should return false. assert!(!complete_success_id(&mut r, rid1, "stale_data", 300)); assert_eq!(r.ignored_results(), 1); - // rid2 is current. complete_current_success should return true. assert!(complete_success_id(&mut r, rid2, "fresh_data", 400)); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"fresh_data")); @@ -53,7 +42,6 @@ fn concurrent_replacements_increment_cancelled_count() { let mut r = fresh_resource(); let mut s = test_sequencer(); - // Each replacement increments cancelled_count. let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); assert_eq!(r.cancelled_count(), 0); let _ = r.begin_request(&mut s, 200, QueryFetchMode::Normal); @@ -73,11 +61,9 @@ fn ignore_while_loading_rejects_concurrent_requests() { ); let mut s = test_sequencer(); - // First request starts. let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); assert_eq!(r.active_request_id(), Some(rid1)); - // Second request is ignored. let result = r.begin_request(&mut s, 200, QueryFetchMode::Normal); match result { QueryBeginResult::IgnoredWhileLoading { active_request_id } => { @@ -92,7 +78,6 @@ fn ignore_while_loading_rejects_concurrent_requests() { ); assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); - // Complete the first request. complete_success_id(&mut r, rid1, "data", 300); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"data")); @@ -107,7 +92,6 @@ fn signal_cancelled_on_replacement() { let signal1 = r.signal().unwrap().clone(); assert!(!signal1.is_cancelled()); - // Replace the request — the old signal should be cancelled. let _ = r.begin_request(&mut s, 200, QueryFetchMode::Normal); assert!( signal1.is_cancelled(), @@ -161,12 +145,10 @@ fn is_data_stale_heuristic() { r.complete_current_success(rid, "data", 200); assert!(!r.is_data_stale(), "Success with data => not stale"); - // Start a refetch — data is stale (LoadingWithData). let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); assert_eq!(r.status(), QueryStatus::LoadingWithData); assert!(r.is_data_stale(), "LoadingWithData with data => stale"); - // Complete with failure — data still stale. let rid2 = begin_request_id(&mut r, &mut s, 400, QueryFetchMode::Normal); r.complete_current_failure_with_data(rid2, "fallback", QueryError::response("err"), 500); assert_eq!(r.status(), QueryStatus::Failure); diff --git a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs b/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs index f798d1b..cf8bab3 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs @@ -1,10 +1,3 @@ -//! Individual gap-filling tests. -//! -//! Covers: begin_request_with_id + SWR + IgnoreWhileLoading, stale request ID -//! rejection, Force mode + IgnoreWhileLoading, QueryError sanitized, QueryKey -//! join/from/deref/serde/hash, InfiniteQuery IgnoreWhileLoading / cross-direction / -//! reset / bidirectional / prepend. - use crate::core::*; use crate::tests::test_support::*; use std::num::NonZero; @@ -21,15 +14,11 @@ fn begin_request_with_id_swr_ignore_while_loading_with_active_request() { ); let mut seq = test_sequencer(); - // Seed cached data at t=100 r.apply_success("cached", 100); - // Start a fetch to create an active request let _ = r.begin_request(&mut seq, 1_500, QueryFetchMode::Force); assert!(r.is_loading()); - // Now call begin_request_with_id when data is stale and a request is active. - // Should get StaleCacheHit with the EXISTING active_request_id (no new request started). let result = r.begin_request_with_id( Some(RequestId::scoped(NonZero::new(99).unwrap(), 1)), 1_500, @@ -42,12 +31,10 @@ fn begin_request_with_id_swr_ignore_while_loading_with_active_request() { replaced_request_id, .. } => { - // Should use the EXISTING active request id, not the provided 99:1 assert!( replaced_request_id.is_none(), "no replacement under IgnoreWhileLoading" ); - // request_id should be the existing active request, not the one we passed assert_ne!( request_id, RequestId::scoped(NonZero::new(99).unwrap(), 1), @@ -66,14 +53,12 @@ fn complete_current_optional_success_rejects_stale_id() { let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - // rid1 is stale assert!( !r.complete_current_optional_success(rid1, Some("stale"), 300), "stale ID should be rejected" ); assert_eq!(r.ignored_results(), 1); - // rid2 is current assert!( r.complete_current_optional_success(rid2, Some("fresh"), 300), "current ID should be accepted" @@ -95,7 +80,6 @@ fn complete_current_failure_with_data_rejects_stale_id() { ); assert_eq!(r.ignored_results(), 1); - // The current request is still active assert!(r.active_request_id().is_some()); } @@ -152,10 +136,8 @@ fn record_cache_hit_does_not_clear_cancelled_status() { CachePolicy::Ttl { ttl_ms: 1_000 }, RequestPolicy::LatestWins, ); - // Seed data at t=1000 r.apply_success("data", 1_000); - // Use Force mode to bypass the fresh cache and start a real request let mut seq = test_sequencer(); let _ = r.begin_request(&mut seq, 1_100, QueryFetchMode::Force); r.cancel(QueryError::cancelled("abort")); @@ -176,7 +158,6 @@ fn join_appends_segment() { let extended = key.join("42"); assert_eq!(extended.parts().len(), 2); assert_eq!(extended.to_path(), "users::42"); - // Original unchanged assert_eq!(key.parts().len(), 1); } @@ -235,7 +216,6 @@ fn ignore_while_loading_prevents_previous_page_replacement() { let _id1 = r.begin_fetch_previous(&mut seq, 1_000).unwrap(); assert!(r.is_fetching_previous_page()); - // Second call with IgnoreWhileLoading should return None let id2 = r.begin_fetch_previous(&mut seq, 2_000); assert!( id2.is_none(), @@ -258,17 +238,11 @@ fn ignore_while_loading_cross_direction_next_then_prev() { let _id_next = r.begin_fetch_next(&mut seq, 1_000).unwrap(); assert!(r.is_fetching_next_page()); - // Cross-direction: begin_fetch_previous while next is active. - // Under IgnoreWhileLoading, this checks is_fetching_previous_page (false), - // so it should succeed despite is_fetching_next_page being true. - // BUT active_request_id.is_some() => cancelled_count++ let id_prev = r.begin_fetch_previous(&mut seq, 2_000); assert!( id_prev.is_some(), "cross-direction should succeed under IgnoreWhileLoading" ); - // The previous page fetch replaces the next page fetch (LatestWins-style - // cross-direction replacement), so cancelled_count increments. assert!(r.is_fetching_previous_page()); assert!(!r.is_fetching_next_page()); } diff --git a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs b/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs index 5073ee9..8a2f8c2 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs @@ -1,19 +1,8 @@ -//! Deterministic GC eviction tests (integration layer). -//! -//! These tests use #[gpui::test] because they exercise QueryClient, which -//! requires a GPUI AppContext. They only need the client layer, not hooks. - use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; use gpui::{BorrowAppContext as _, TestAppContext}; -/// Populate a `Success` resource whose `last_updated_at` is a known timestamp. -/// -/// GC reads live entity state, so we drive the resource -/// to `Success` with a controlled timestamp via `apply_success` instead of -/// faking a cached snapshot. `Ttl` has no stale window, so GC falls through to -/// the success-threshold age check (`success_threshold = 2 * gc_time_ms`). fn create_success_at_time( client: &mut QueryClient, cx: &mut gpui::App, @@ -34,11 +23,6 @@ fn create_success_at_time( #[gpui::test] fn test_gc_evicts_exactly_expired_resources(cx: &mut TestAppContext) { - // gc_time=1000ms. Success threshold = 2*1000 = 2000ms. - // Create 3 resources with different snapshot ages: - // - "young": snapshot at t=2000, GC at t=2500 => age=500 < 2000 => preserved - // - "middle": snapshot at t=1000, GC at t=2500 => age=1500 < 2000 => preserved - // - "old": snapshot at t=100, GC at t=2500 => age=2400 > 2000 => evicted setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -50,7 +34,6 @@ fn test_gc_evicts_exactly_expired_resources(cx: &mut TestAppContext) { client.gc_with_time(2_500, cx); - // "young" and "middle" should survive; "old" should be evicted. assert_eq!( client.all_queries::<String, QueryError>().len(), 2, @@ -83,7 +66,6 @@ fn test_gc_eviction_counts_match(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create 5 idle resources with no snapshot => all evicted. for i in 0..5 { let _ = client.resource::<String, QueryError>(format!("idle_{}", i), cx); } @@ -109,16 +91,13 @@ fn test_gc_preserves_loading_resource_with_snapshot(cx: &mut TestAppContext) { let prepared = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) .expect("should start"); - // Don't complete — leave in Loading state. - // GC at t=1_000_000 — Loading resources are never evicted. client.gc_with_time(1_000_000, cx); let entity = client .query::<String, QueryError>(&key) .expect("loading resource must survive GC"); - // Now complete the fetch to verify the entity is still usable. prepared.complete_success("data".to_string(), cx); assert_eq!( entity.read(cx).data(), @@ -131,31 +110,21 @@ fn test_gc_preserves_loading_resource_with_snapshot(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { - // Create resources in various states and verify exact eviction counts. - // gc_time=1000ms. Idle threshold=1000ms, Success threshold=2000ms. - // - // Use separate type buckets to avoid interactions between resources - // sharing the same bucket during snapshot updates. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Loading => preserved (loading is never evicted). let prepared = client .prepare_fetch_query::<String, QueryError>("loading", cx) .expect("should start"); - // Success (last_updated at t=1000, GC at t=2500 => age=1500 < 2000) => preserved. create_success_at_time(client, cx, "success_fresh", "data", 1_000); - // Success (snapshot at t=0, GC at t=2500 => age=2500 > 2000) => evicted. create_success_at_time(client, cx, "success_old", "data", 0); assert_eq!(client.all_queries::<String, QueryError>().len(), 3); client.gc_with_time(2_500, cx); - // loading + success_fresh = 2 preserved. - // success_old = 1 evicted. let remaining = client.all_queries::<String, QueryError>(); assert_eq!( remaining.len(), @@ -178,7 +147,6 @@ fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { remaining_keys ); - // Clean up: complete the loading fetch. prepared.complete_success("data".to_string(), cx); }); }); @@ -186,21 +154,18 @@ fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_survive_then_evict_after_threshold_crossed(cx: &mut TestAppContext) { - // Same resource survives GC at time T1, then gets evicted at T2. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("aged"); create_success_at_time(client, cx, "aged", "data", 1_000); - // GC at t=2000: age=1000 < success_threshold(2000) => preserved. client.gc_with_time(2_000, cx); assert!( client.query::<String, QueryError>(&key).is_some(), "age=1000ms < success_threshold=2000ms => should survive" ); - // GC at t=3500: age=2500 > success_threshold(2000) => evicted. client.gc_with_time(3_500, cx); assert!( client.query::<String, QueryError>(&key).is_none(), @@ -212,19 +177,12 @@ fn test_gc_survive_then_evict_after_threshold_crossed(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_boundary_success_threshold_exact(cx: &mut TestAppContext) { - // Test the exact boundary: age == success_threshold. - // gc_time=1000 => success_threshold=2000. - // Snapshot at t=1000, GC at t=3000 => age=2000 == success_threshold. - // - // GC uses `age_ms < success_threshold` to retain (line ~437 in bucket.rs). - // When age == threshold, the condition is false → evicted (>= semantics). setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("boundary"); create_success_at_time(client, cx, "boundary", "data", 1_000); - // GC at t=3000: age=3000-1000=2000 == success_threshold => evicted. client.gc_with_time(3_000, cx); assert!( client.query::<String, QueryError>(&key).is_none(), diff --git a/crates/gpui-query/src/tests/coverage_gaps/mod.rs b/crates/gpui-query/src/tests/coverage_gaps/mod.rs index e242a6d..d94cb45 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/mod.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/mod.rs @@ -1,19 +1,3 @@ -//! High-priority test coverage gaps for gpui-query. -//! -//! # Test categories -//! -//! 1. **Property-based tests** (no external framework): Systematic checks over -//! many inputs for RetryPolicy, CachePolicy, serde roundtrip, RequestSequencer. -//! -//! 2. **State-transition invariant tests**: Table-driven verification that -//! status and data are never inconsistent after any state transition. -//! -//! 3. **Deterministic GC eviction tests**: Concrete assertions on GC behavior -//! rather than "no panic" patterns. -//! -//! 4. **Concurrency guard tests**: Verify that the two-phase completion protocol -//! maintains invariants even when requests are interleaved. - mod concurrency; mod gap_tests; mod gc_eviction; diff --git a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs b/crates/gpui-query/src/tests/coverage_gaps/property_based.rs index bdea595..e5f428d 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/property_based.rs @@ -1,18 +1,11 @@ -//! Property-based tests for RetryPolicy, CachePolicy, serde roundtrip, and -//! RequestSequencer. - use crate::core::*; use crate::tests::test_support::*; use std::num::NonZero; -// --- RetryPolicy: delay_for_attempt never exceeds ABSOLUTE_MAX_DELAY_MS -------- - #[test] fn prop_retry_delay_never_exceeds_absolute_max_for_all_attempts() { - // ABSOLUTE_MAX_DELAY_MS = 3_600_000 (1 hour). const ABSOLUTE_MAX: u64 = 3_600_000; - // Test with various base delays and exponential backoff enabled. let base_delays: &[u64] = &[ 0, 1, @@ -35,7 +28,6 @@ fn prop_retry_delay_never_exceeds_absolute_max_for_all_attempts() { exponential_backoff: true, max_retry_delay_ms: max_delay, }; - // Check attempts 0 through 100, plus some very large ones. for attempt in 0..=100u32 { let delay = policy.delay_for_attempt(attempt); assert!( @@ -49,7 +41,6 @@ fn prop_retry_delay_never_exceeds_absolute_max_for_all_attempts() { max_delay ); } - // Extreme attempt numbers. for attempt in [u32::MAX, 200, 500, 1000] { let delay = policy.delay_for_attempt(attempt); assert!( @@ -69,8 +60,6 @@ fn prop_retry_delay_never_exceeds_absolute_max_for_all_attempts() { #[test] fn prop_retry_delay_without_backoff_is_constant() { - // Without exponential backoff, delay_for_attempt should return retry_delay_ms - // regardless of attempt number. let delays: &[u64] = &[0, 1, 100, 1_000, 30_000, u64::MAX]; for &base in delays { let policy = RetryPolicy { @@ -92,8 +81,6 @@ fn prop_retry_delay_without_backoff_is_constant() { #[test] fn prop_retry_delay_monotonically_increases_or_capped() { - // With exponential backoff and a reasonable base, delays should be - // monotonically non-decreasing (they can plateau at the cap). let policy = RetryPolicy::new(100) .with_delay(100) .with_exponential_backoff() @@ -112,12 +99,8 @@ fn prop_retry_delay_monotonically_increases_or_capped() { } } -// --- CachePolicy: is_fresh / is_expired / total_valid_ms relationships ------ - #[test] fn prop_cache_policy_fresh_and_expired_are_complementary_for_ttl() { - // For Ttl, every non-negative age is either fresh or expired (no gap). - // Boundary: age == ttl_ms is fresh (inclusive), age == ttl_ms + 1 is expired. let ttl_values: &[u64] = &[1, 10, 100, 1_000, 60_000, u64::MAX]; for &ttl in ttl_values { let policy = CachePolicy::Ttl { ttl_ms: ttl }; @@ -126,8 +109,6 @@ fn prop_cache_policy_fresh_and_expired_are_complementary_for_ttl() { .expect("Ttl should have total_valid_ms"); assert_eq!(total, ttl); - // Sample ages: 0, boundary-1, boundary, boundary+1, and large values. - // Use saturating_add for boundary+1 so u64::MAX does not overflow. let ages: &[u64] = &[ 0, ttl / 2, @@ -152,8 +133,6 @@ fn prop_cache_policy_fresh_and_expired_are_complementary_for_ttl() { #[test] fn prop_cache_policy_swr_three_way_partition() { - // For StaleWhileRevalidate, every non-negative age falls into exactly one of: - // fresh, stale-but-serveable, or expired. No gaps, no overlaps. let cases: &[(u64, u64)] = &[ (1, 1), (10, 10), @@ -171,19 +150,18 @@ fn prop_cache_policy_swr_three_way_partition() { let ages: &[u64] = &[ 0, ttl / 2, - ttl, // boundary: still fresh - ttl + 1, // just past TTL: stale - total / 2 + ttl / 2, // mid-stale window - total, // boundary: still stale-but-serveable - total + 1, // expired - total.saturating_mul(2), // way expired + ttl, + ttl + 1, + total / 2 + ttl / 2, + total, + total + 1, + total.saturating_mul(2), ]; for &age in ages { let is_fresh = policy.is_fresh(age); let is_stale = policy.is_stale_but_serveable(age); let is_expired = policy.is_expired(age); - // Exactly one must be true. let count = is_fresh as u8 + is_stale as u8 + is_expired as u8; assert_eq!( count, 1, @@ -233,7 +211,6 @@ fn prop_cache_policy_nocache_always_expired_never_fresh() { #[test] fn prop_cache_policy_total_valid_ms_consistency() { - // total_valid_ms must equal ttl_ms for Ttl, and ttl_ms + stale_ms for SWR. let cases: &[CachePolicy] = &[ CachePolicy::NoCache, CachePolicy::Ttl { ttl_ms: 1 }, @@ -270,8 +247,6 @@ fn prop_cache_policy_total_valid_ms_consistency() { } } -// --- Serde roundtrip: decode(encode(x)) == x ----------------------------- - #[test] fn prop_serde_roundtrip_all_statuses() { assert_serde_roundtrip(&[ @@ -344,7 +319,6 @@ fn prop_serde_roundtrip_query_error_all_kinds() { #[test] fn prop_serde_roundtrip_query_resource_multiple_states() { - // Use NoCache so begin_request always returns Started (no CacheHit). let mut r: QueryResource<String, QueryError> = QueryResource::new( "serde-test", CachePolicy::NoCache, @@ -352,7 +326,6 @@ fn prop_serde_roundtrip_query_resource_multiple_states() { ); let mut s = test_sequencer(); - // Test roundtrip in Success state. let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid, "hello".to_string(), 200); @@ -365,7 +338,6 @@ fn prop_serde_roundtrip_query_resource_multiple_states() { assert_eq!(back.request_policy(), RequestPolicy::LatestWins); assert!(back.signal().is_none(), "signal is #[serde(skip)]"); - // Test roundtrip in Failure state. let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); r.complete_current_failure(rid2, QueryError::transport("fail"), 400); let json2 = serde_json::to_string(&r).unwrap(); @@ -375,8 +347,6 @@ fn prop_serde_roundtrip_query_resource_multiple_states() { assert!(back2.signal().is_none()); } -// --- RequestSequencer: IDs always monotonically increasing ---------------- - #[test] fn prop_request_sequencer_monotonic_within_scope() { let mut seq = RequestSequencer::new(); @@ -395,8 +365,6 @@ fn prop_request_sequencer_monotonic_within_scope() { #[test] fn prop_request_sequencer_scope_advance_preserves_monotonicity() { - // Force the sequencer to the edge of overflow and verify monotonicity - // across the scope transition. let mut seq = RequestSequencer { scope_id: NonZero::new(1).unwrap(), next_request_id: u64::MAX - 5, @@ -413,7 +381,6 @@ fn prop_request_sequencer_scope_advance_preserves_monotonicity() { ); prev = curr; } - // After wrapping through u64::MAX, the scope should have advanced. assert!( seq.scope_id.get() >= 2, "scope should have advanced past overflow" @@ -435,53 +402,39 @@ fn prop_request_sequencer_uniqueness_across_many_ids() { fn prop_request_sequencer_two_sequencers_no_collision() { let mut seq1 = RequestSequencer::new(); let mut seq2 = RequestSequencer::new(); - // Different sequencers should produce different scope IDs or sequences, - // so their first IDs should differ. - // Both start at scope 1, seq 1, so they WILL produce the same first ID. - // But advancing one should make them diverge. - let id1_first = seq1.next_request(); // 1:1 - let id2_first = seq2.next_request(); // 1:1 (same scope/seq) + let id1_first = seq1.next_request(); + let id2_first = seq2.next_request(); assert_eq!(id1_first, id2_first, "both start at 1:1"); - // Now advance seq1 more. - let id1_second = seq1.next_request(); // 1:2 + let id1_second = seq1.next_request(); assert_ne!( id1_second, id2_first, "advanced id should differ from initial" ); - // If we create a sequencer that's been advanced, it should produce - // distinct ids from a fresh one. let mut seq3 = RequestSequencer { scope_id: NonZero::new(2).unwrap(), next_request_id: 1, }; - let id3 = seq3.next_request(); // 2:1 + let id3 = seq3.next_request(); assert_ne!(id3.scope_id(), id1_first.scope_id(), "different scopes"); } #[test] fn prop_request_sequencer_double_overflow_wraps_correctly() { - // Force scope_id to u64::MAX and next_request_id to u64::MAX - // to trigger double overflow. let mut seq = RequestSequencer { scope_id: NonZero::new(u64::MAX).unwrap(), next_request_id: u64::MAX, }; - let id_before = seq.next_request(); // u64::MAX:u64::MAX + let id_before = seq.next_request(); assert_eq!(id_before.scope_id(), NonZero::new(u64::MAX).unwrap()); assert_eq!(id_before.value(), u64::MAX); - // The sequencer should have advanced scope. After u64::MAX scope, - // checked_add overflows, so scope wraps to 1. - // Verify the next id is from the new scope. let id_after = seq.next_request(); - // Scope should have wrapped to 1 or been advanced. assert!( id_after.scope_id() <= NonZero::new(2).unwrap(), "scope should wrap after u64::MAX: got {}", id_after.scope_id() ); - // The ids should still be unique (different scope or sequence). assert_ne!(id_before, id_after, "ids must differ across scope wrap"); } diff --git a/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs b/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs index d74ca77..241f458 100644 --- a/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs +++ b/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs @@ -1,8 +1,3 @@ -//! State-transition invariant tests. -//! -//! Table-driven verification that status and data are never inconsistent after -//! any state transition. - use crate::core::*; use crate::tests::test_support::*; @@ -31,14 +26,11 @@ fn invariant_after_begin_loading_empty() { fn invariant_after_begin_loading_with_data() { let mut r = fresh_resource(); let mut s = test_sequencer(); - // First fetch succeeds to get data. let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid1, "data1", 200); - // Second fetch: resource has data, so status should be LoadingWithData. let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); assert_eq!(r.status(), QueryStatus::LoadingWithData); - // Data should still be present during refetch (optimistic). assert!( r.data().is_some(), "LoadingWithData => data should still be present" @@ -47,7 +39,6 @@ fn invariant_after_begin_loading_with_data() { assert!(r.error().is_none(), "begin_request clears error"); assert_eq!(r.active_request_id(), Some(rid2)); - // Complete the refetch — old data should become previous_data. r.complete_current_success(rid2, "data2", 400); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"data2")); @@ -68,7 +59,7 @@ fn invariant_after_complete_success() { r.active_request_id().is_none(), "completed => no active request" ); - assert!(r.signal().is_some()); // signal remains but request is done + assert!(r.signal().is_some()); } #[test] @@ -92,25 +83,18 @@ fn invariant_after_complete_failure() { #[test] fn invariant_after_complete_failure_from_loading_with_data() { - // When a refetch fails (apply_failure), data is RETAINED (not cleared). - // apply_failure only sets status=Failure and error; it does NOT touch data. - // This matches TanStack Query behavior where a failed refetch keeps stale data. let mut r = fresh_resource(); let mut s = test_sequencer(); - // First fetch succeeds. let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid1, "original", 200); - // Second fetch fails (refetch). let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); r.complete_current_failure(rid2, QueryError::transport("timeout"), 400); assert_eq!(r.status(), QueryStatus::Failure); assert!(r.error().is_some(), "Failure => error must be Some"); assert!(r.active_request_id().is_none()); - // Key invariant: apply_failure does NOT clear data or set previous_data. - // The data from before the refetch is retained in-place. assert_eq!( r.data(), Some(&"original"), @@ -150,11 +134,9 @@ fn invariant_after_cancel_from_loading_with_data() { let mut r = fresh_resource(); let mut s = test_sequencer(); - // First fetch succeeds. let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid1, "data", 200); - // Start a refetch, then cancel. let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); assert_eq!(r.status(), QueryStatus::LoadingWithData); assert_eq!(r.data(), Some(&"data")); @@ -201,7 +183,6 @@ fn invariant_after_reset() { assert_eq!(r.cancelled_count(), 0); assert_eq!(r.ignored_results(), 0); assert_eq!(r.retry_count(), 0); - // Policies are preserved. assert_eq!(r.cache_policy(), CachePolicy::NoCache); assert_eq!(r.request_policy(), RequestPolicy::LatestWins); } @@ -211,7 +192,6 @@ fn invariant_complete_success_optional_none_yields_idle() { let mut r = fresh_resource(); let mut s = test_sequencer(); let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - // complete_success_optional with None should yield Idle, not Success. let guard = r.accept_current_request(rid).unwrap(); r.complete_success_optional(guard, None, 200); @@ -258,48 +238,38 @@ fn invariant_stale_accept_rejected() { let mut r = fresh_resource(); let mut s = test_sequencer(); let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - // Start a second request (replaces the first under LatestWins). let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - // rid1 is now stale. accept_current_request should return None. assert!( r.accept_current_request(rid1).is_none(), "stale request should be rejected" ); assert_eq!(r.ignored_results(), 1); - // rid2 is current. accept_current_request should succeed. assert!( r.accept_current_request(rid2).is_some(), "current request should be accepted" ); } -// --- Table-driven: all transitions from each starting state --------------- - -/// Enumerate all possible state transitions and verify invariants. #[test] fn table_driven_all_transitions_from_idle() { let mut r = fresh_resource(); assert_eq!(r.status(), QueryStatus::Idle); - // Transition: Idle -> LoadingEmpty (begin_request) let mut s = test_sequencer(); let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); assert_eq!(r.status(), QueryStatus::LoadingEmpty); assert!(r.data().is_none()); - // Transition: LoadingEmpty -> Success (complete_success) r.complete_current_success(rid, "data", 200); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"data")); assert!(r.error().is_none()); - // Transition: Success -> LoadingWithData (begin_request again) let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); assert_eq!(r.status(), QueryStatus::LoadingWithData); assert_eq!(r.data(), Some(&"data"), "LoadingWithData preserves data"); - // Transition: LoadingWithData -> Failure (complete_failure) r.complete_current_failure(rid2, QueryError::response("fail"), 400); assert_eq!(r.status(), QueryStatus::Failure); assert_eq!( @@ -312,7 +282,6 @@ fn table_driven_all_transitions_from_idle() { "apply_failure does NOT set previous_data" ); - // Transition: Failure -> LoadingWithData (begin_request when data is present) let _rid3 = begin_request_id(&mut r, &mut s, 500, QueryFetchMode::Normal); assert_eq!( r.status(), @@ -321,13 +290,11 @@ fn table_driven_all_transitions_from_idle() { ); assert!(r.error().is_none(), "begin_request clears error"); - // Transition: LoadingEmpty -> Cancelled (cancel) r.cancel(QueryError::cancelled("abort")); assert_eq!(r.status(), QueryStatus::Cancelled); assert!(r.data().is_none()); assert!(r.error().is_some()); - // Transition: Cancelled -> Idle (reset) r.reset(); assert_eq!(r.status(), QueryStatus::Idle); assert!(r.data().is_none()); @@ -336,7 +303,6 @@ fn table_driven_all_transitions_from_idle() { #[test] fn table_driven_cancel_from_every_loading_state() { - // Cancel from LoadingEmpty. { let mut r = fresh_resource(); let mut s = test_sequencer(); @@ -348,7 +314,6 @@ fn table_driven_cancel_from_every_loading_state() { assert!(r.error().is_some()); } - // Cancel from LoadingWithData. { let mut r = fresh_resource(); let mut s = test_sequencer(); @@ -368,10 +333,7 @@ fn table_driven_cancel_from_every_loading_state() { #[test] fn table_driven_rollback_from_every_state() { - // rollback_to_previous only works when previous_data is set. - // It sets status to Success and restores data. - // From Success with previous_data (after a second successful fetch). { let mut r = fresh_resource(); let mut s = test_sequencer(); @@ -387,7 +349,6 @@ fn table_driven_rollback_from_every_state() { assert_eq!(r.data(), Some(&"v1"), "rollback restores previous data"); } - // From Cancelled with previous_data (cancel saves to previous_data). { let mut r = fresh_resource(); let mut s = test_sequencer(); @@ -403,26 +364,21 @@ fn table_driven_rollback_from_every_state() { assert_eq!(r.data(), Some(&"v1")); } - // From Failure with data retained (apply_failure does NOT clear data or set previous_data). - // To have previous_data from a failure scenario, we need to use the optimistic update path. { let mut r = fresh_resource(); let mut s = test_sequencer(); let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid1, "v1", 200); - // Optimistic update sets previous_data. r.set_data("v2_optimistic"); assert_eq!(r.data(), Some(&"v2_optimistic")); assert_eq!(r.previous_data(), Some(&"v1")); - // Now rollback. let rolled_back = r.rollback_to_previous(); assert!(rolled_back); assert_eq!(r.status(), QueryStatus::Success); assert_eq!(r.data(), Some(&"v1")); } - // From Idle with no previous_data => rollback returns false. { let mut r = fresh_resource(); assert!( diff --git a/crates/gpui-query/src/tests/integration_client/client_basics.rs b/crates/gpui-query/src/tests/integration_client/client_basics.rs index 8343e69..66c70c4 100644 --- a/crates/gpui-query/src/tests/integration_client/client_basics.rs +++ b/crates/gpui-query/src/tests/integration_client/client_basics.rs @@ -1,14 +1,9 @@ -//! Tests for basic QueryClient operations: creation, resource CRUD, -//! type partitioning, diagnostics, and observer creation. - use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; use crate::client::{MutationObserver, ObserverConfig, QueryClient, QueryObserver}; use crate::core::*; use crate::tests::test_support::*; -// ── 1. QueryClient creation and Global registration ──────────────────── - #[gpui::test] fn test_client_creation_and_global_registration(cx: &mut TestAppContext) { setup_query_client(cx); @@ -48,7 +43,6 @@ fn test_client_with_gc_time(cx: &mut TestAppContext) { entity.update(cx, |r, _| { r.apply_success("hello".to_string(), 100); }); - // GC at t=3000: success, age=2900 > success_threshold(2*1000=2000) -> evicted client.gc_with_time(3_000, cx); let remaining = client.all_queries::<String, QueryError>(); assert!( @@ -59,8 +53,6 @@ fn test_client_with_gc_time(cx: &mut TestAppContext) { }); } -// ── 2. resource() creates and retrieves typed entities ────────────────── - #[gpui::test] fn test_resource_creates_and_deduplicates(cx: &mut TestAppContext) { setup_query_client(cx); @@ -71,7 +63,6 @@ fn test_resource_creates_and_deduplicates(cx: &mut TestAppContext) { let e1 = client.resource::<String, QueryError>(key.clone(), cx); assert_eq!(e1.read(cx).status(), QueryStatus::Idle); - // Same key returns same entity (deduplication) let e2 = client.resource::<String, QueryError>(key.clone(), cx); assert_eq!( e1.entity_id(), @@ -79,7 +70,6 @@ fn test_resource_creates_and_deduplicates(cx: &mut TestAppContext) { "same key should return same entity" ); - // Different key creates new entity let e3 = client.resource::<String, QueryError>("user:2", cx); assert_ne!( e1.entity_id(), @@ -127,26 +117,21 @@ fn test_query_retrieves_existing_entity(cx: &mut TestAppContext) { let created = client.resource::<String, QueryError>(key.clone(), cx); - // query() returns Some for existing key let retrieved = client.query::<String, QueryError>(&key); assert!(retrieved.is_some(), "should find existing key"); assert_eq!(created.entity_id(), retrieved.unwrap().entity_id()); - // query() returns None for missing key let missing = client.query::<String, QueryError>(&QueryKey::from("nope")); assert!(missing.is_none(), "should not find nonexistent key"); }); }); } -// ── 3. Type-partitioned buckets: different (T,E) types don't conflict ── - #[gpui::test] fn test_type_partitioned_buckets_no_conflict(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Same key "data" but different types — must not conflict let string_entity = client.resource::<String, QueryError>("data", cx); let u32_entity = client.resource::<u32, QueryError>("data", cx); let user_entity = client.resource::<User, QueryError>("data", cx); @@ -167,12 +152,10 @@ fn test_type_partitioned_buckets_no_conflict(cx: &mut TestAppContext) { "u32 and User must be separate" ); - // all_queries::<String, _> returns only String entities let strings = client.all_queries::<String, QueryError>(); assert_eq!(strings.len(), 1); assert_eq!(strings[0].entity_id(), string_entity.entity_id()); - // all_queries::<User, _> returns only User entities let users = client.all_queries::<User, QueryError>(); assert_eq!(users.len(), 1); assert_eq!(users[0].entity_id(), user_entity.entity_id()); @@ -185,7 +168,6 @@ fn test_same_type_different_error_types_no_conflict(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Same T but different E — these should be in separate buckets let e1 = client.resource::<String, QueryError>("key", cx); let e2 = client.resource::<String, String>("key", cx); @@ -198,8 +180,6 @@ fn test_same_type_different_error_types_no_conflict(cx: &mut TestAppContext) { }); } -// ── 8. Diagnostics output ────────────────────────────────────────────── - #[gpui::test] fn test_diagnostics_empty_client(cx: &mut TestAppContext) { setup_query_client(cx); @@ -219,7 +199,6 @@ fn test_diagnostics_with_resources(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { let _e1 = client.resource::<String, QueryError>(QueryKey::from(["users", "1"]), cx); - // Use prepare_fetch_query + complete_success for a proper lifecycle let prepared = client .prepare_fetch_query::<String, QueryError>(QueryKey::from(["users", "2"]), cx) .expect("should start"); @@ -239,9 +218,6 @@ fn test_diagnostics_with_resources(cx: &mut TestAppContext) { "diagnostics should have two query records" ); - // Verify that the completed query shows up in diagnostics. - // Note: diagnostics reads live entity state via collect_diagnostics, - // which upgrades weak refs and reads entity state. let users_diags: Vec<_> = diag .queries .iter() @@ -249,7 +225,6 @@ fn test_diagnostics_with_resources(cx: &mut TestAppContext) { .collect(); assert_eq!(users_diags.len(), 2, "should have two user queries"); - // The entity that was completed via PreparedFetch should show Success let success_diag = users_diags .iter() .find(|q| q.status == QueryStatus::Success); @@ -276,8 +251,6 @@ fn test_diagnostics_across_type_buckets(cx: &mut TestAppContext) { }); } -// ── 11. Observer creation and notification ───────────────────────────── - #[gpui::test] fn test_query_observer_creation(cx: &mut TestAppContext) { setup_query_client(cx); @@ -285,7 +258,6 @@ fn test_query_observer_creation(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { let entity = client.resource::<String, QueryError>("obs_key", cx); let _observer = QueryObserver::new(&entity); - // Observer created successfully — entity is still alive let weak = entity.downgrade(); assert!(weak.upgrade().is_some(), "entity should still be alive"); }); @@ -318,7 +290,6 @@ fn test_mutation_observer_creation(cx: &mut TestAppContext) { let entity = cx .new(|_| MutationResource::<String, User, QueryError>::new(RetryPolicy::no_retries())); let _observer = MutationObserver::<String, User, QueryError>::new(&entity); - // No panic — observer created }); } @@ -332,7 +303,6 @@ fn test_observer_config_custom_settings(cx: &mut TestAppContext) { notify_on_status_change_only: false, }; let _observer = QueryObserver::new(&entity).with_config(config); - // Observer created with custom config — no panic }); }); } diff --git a/crates/gpui-query/src/tests/integration_client/data_access.rs b/crates/gpui-query/src/tests/integration_client/data_access.rs index 5089bc1..8aa074b 100644 --- a/crates/gpui-query/src/tests/integration_client/data_access.rs +++ b/crates/gpui-query/src/tests/integration_client/data_access.rs @@ -1,7 +1,3 @@ -//! Tests for data access: signal cancellation, query removal, set/rollback -//! query data, dehydrate/hydrate, request ID sequences, PreparedFetch, -//! prefetch, and persistence. - use std::sync::Mutex; use gpui::{BorrowAppContext as _, TestAppContext}; @@ -10,8 +6,6 @@ use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -// ── 9. Signal retrieval and cancellation ─────────────────────────────── - #[gpui::test] fn test_cancel_queries_cancels_loading_requests(cx: &mut TestAppContext) { setup_query_client(cx); @@ -20,7 +14,6 @@ fn test_cancel_queries_cancels_loading_requests(cx: &mut TestAppContext) { let key = QueryKey::from("cancel_target"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Start a request let request_id = client .next_request_id_for_key::<String, QueryError>(&key) .expect("should get request id"); @@ -29,7 +22,6 @@ fn test_cancel_queries_cancels_loading_requests(cx: &mut TestAppContext) { }); assert!(entity.read(cx).is_loading()); - // Grab the signal before cancelling let signal = entity .read(cx) .signal() @@ -37,7 +29,6 @@ fn test_cancel_queries_cancels_loading_requests(cx: &mut TestAppContext) { .clone(); assert!(!signal.is_cancelled()); - // Cancel via client bulk operation client.cancel_queries(&QueryKeyFilter::Exact(&key), cx); assert!( @@ -56,7 +47,6 @@ fn test_cancel_queries_skips_idle_resources(cx: &mut TestAppContext) { let key = QueryKey::from("idle_key"); let _entity = client.resource::<String, QueryError>(key.clone(), cx); - // Resource is idle — cancel_queries should be a no-op client.cancel_queries(&QueryKeyFilter::Exact(&key), cx); let entity = client.query::<String, QueryError>(&key); @@ -102,8 +92,6 @@ fn test_remove_queries_removes_matching(cx: &mut TestAppContext) { }); } -// ── 10. set_query_data / rollback_query_data ─────────────────────────── - #[gpui::test] fn test_set_query_data_sets_data_on_existing_resource(cx: &mut TestAppContext) { setup_query_client(cx); @@ -112,13 +100,11 @@ fn test_set_query_data_sets_data_on_existing_resource(cx: &mut TestAppContext) { let key = QueryKey::from("user:1"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Populate with real data entity.update(cx, |r, _| { r.apply_success("Alice".to_string(), 1_000); }); assert_eq!(entity.read(cx).data().unwrap(), "Alice"); - // Overwrite via set_query_data client.set_query_data::<String, QueryError>("user:1", "Bob".to_string(), cx); assert_eq!(entity.read(cx).data().unwrap(), "Bob"); @@ -136,7 +122,6 @@ fn test_set_query_data_creates_resource_if_missing(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // set_query_data on a key that does not exist yet client.set_query_data::<String, QueryError>("new_key", "hello".to_string(), cx); let data = client.get_query_data::<String, QueryError>(&QueryKey::from("new_key"), cx); @@ -163,8 +148,6 @@ fn test_with_query_data_reads_without_clone(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>("len_key", "hello".to_string(), cx); - // `with_query_data` lends `&T` to the closure; no clone of the - // `String` ever happens (unlike `get_query_data`). let len = client.with_query_data::<String, QueryError, usize>( &QueryKey::from("len_key"), cx, @@ -172,7 +155,6 @@ fn test_with_query_data_reads_without_clone(cx: &mut TestAppContext) { ); assert_eq!(len, Some(5)); - // Missing key -> `None`; the closure is never called. let missing = client.with_query_data::<String, QueryError, usize>( &QueryKey::from("absent"), cx, @@ -191,7 +173,6 @@ fn test_rollback_query_data_via_resource(cx: &mut TestAppContext) { let key = QueryKey::from("user:1"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Populate then optimistic update entity.update(cx, |r, _| r.apply_success("Alice".to_string(), 1_000)); client.set_query_data::<String, QueryError>( "user:1", @@ -200,7 +181,6 @@ fn test_rollback_query_data_via_resource(cx: &mut TestAppContext) { ); assert_eq!(entity.read(cx).data().unwrap(), "Alice (optimistic)"); - // Rollback via the resource directly let rolled_back = entity.update(cx, |r, _| r.rollback_to_previous()); assert!(rolled_back); assert_eq!(entity.read(cx).data().unwrap(), "Alice"); @@ -209,18 +189,15 @@ fn test_rollback_query_data_via_resource(cx: &mut TestAppContext) { }); } -// ── 14. Dehydrate / hydrate ──────────────────────────────────────────── #[cfg(feature = "persist")] #[gpui::test] fn test_dehydrate_collects_success_entries(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Success resource — should appear in dehydrate let e1 = client.resource::<String, QueryError>(QueryKey::from("ok"), cx); e1.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // Idle resource — should NOT appear let _e2 = client.resource::<String, QueryError>(QueryKey::from("idle"), cx); let state = client.dehydrate(cx); @@ -241,13 +218,11 @@ fn test_dehydrate_skips_non_success_resources(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Failure resource let e1 = client.resource::<String, QueryError>("fail", cx); e1.update(cx, |r, _| { r.apply_failure(QueryError::response("err"), 1_000) }); - // Idle (no data) let _e2 = client.resource::<String, QueryError>("idle2", cx); let state = client.dehydrate(cx); @@ -259,8 +234,6 @@ fn test_dehydrate_skips_non_success_resources(cx: &mut TestAppContext) { }); } -// ── 15. next_request_id_for_key monotonic sequence ───────────────────── - #[gpui::test] fn test_next_request_id_is_monotonically_increasing(cx: &mut TestAppContext) { setup_query_client(cx); @@ -299,8 +272,6 @@ fn test_next_request_id_returns_none_for_missing_key(cx: &mut TestAppContext) { }); } -// ── 16. PreparedFetch lifecycle ──────────────────────────────────────── - #[gpui::test] fn test_prepare_fetch_query_success_lifecycle(cx: &mut TestAppContext) { setup_query_client(cx); @@ -316,10 +287,8 @@ fn test_prepare_fetch_query_success_lifecycle(cx: &mut TestAppContext) { "signal should start uncancelled" ); - // Complete with success prepared.complete_success("fetched_data".to_string(), cx); - // Verify data is stored let data = client.get_query_data::<String, QueryError>(&key, cx); assert_eq!(data, Some("fetched_data".to_string())); }); @@ -344,8 +313,6 @@ fn test_prepare_fetch_query_failure_lifecycle(cx: &mut TestAppContext) { }); } -// ── 17. prepare_prefetch_query ───────────────────────────────────────── - #[gpui::test] fn test_prepare_prefetch_query_starts_for_stale_resource(cx: &mut TestAppContext) { setup_query_client(cx); @@ -354,10 +321,8 @@ fn test_prepare_prefetch_query_starts_for_stale_resource(cx: &mut TestAppContext let key = QueryKey::from("prefetch_key"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Populate with stale data (old timestamp) entity.update(cx, |r, _| r.apply_success("old_data".to_string(), 100)); - // Prefetch should start since data is stale at t=5000 with TTL=1000 let prepared = client.prepare_prefetch_query::<String, QueryError>( key.clone(), CachePolicy::Ttl { ttl_ms: 1_000 }, @@ -378,18 +343,15 @@ fn test_prepare_prefetch_query_starts_for_stale_resource(cx: &mut TestAppContext }); } -// ── 18. Persistence trait integration ────────────────────────────────── #[cfg(feature = "persist")] #[gpui::test] fn test_persist_and_restore_cycle(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create a success resource let entity = client.resource::<String, QueryError>("persist_me", cx); entity.update(cx, |r, _| r.apply_success("value".to_string(), 1_000)); - // Mock persister backed by in-memory storage struct MemPersister { entries: Mutex<Vec<crate::client::DehydratedEntry>>, } @@ -406,10 +368,8 @@ fn test_persist_and_restore_cycle(cx: &mut TestAppContext) { entries: Mutex::new(Vec::new()), }; - // Persist client.persist(&persister, cx); - // Restore let loaded = QueryClient::restore(&persister); assert_eq!(loaded.len(), 1, "should have one persisted entry"); assert_eq!(loaded[0].key, "persist_me"); diff --git a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs index 81d5ef5..ba43b6e 100644 --- a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs +++ b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs @@ -1,13 +1,9 @@ -//! Tests for cache management: invalidation, reset, and garbage collection. - use gpui::{BorrowAppContext as _, TestAppContext}; use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -// ── 4. invalidate_queries with Exact/Prefix/All filters ─────────────── - #[gpui::test] fn test_invalidate_queries_exact_filter(cx: &mut TestAppContext) { setup_query_client(cx); @@ -28,7 +24,6 @@ fn test_invalidate_queries_exact_filter(cx: &mut TestAppContext) { ); assert!(e2.read(cx).is_cache_fresh(1_500)); - // Invalidate only users/1 client.invalidate_queries(&QueryKeyFilter::Exact(&key1), cx); assert!( @@ -57,7 +52,6 @@ fn test_invalidate_queries_prefix_filter_across_types(cx: &mut TestAppContext) { u2.update(cx, |r, _| r.apply_success("user2".to_string(), 1_000)); p1.update(cx, |r, _| r.apply_success("post1".to_string(), 1_000)); - // Invalidate all "users" — posts unaffected let prefix = QueryKey::from(["users"]); client.invalidate_queries(&QueryKeyFilter::Prefix(&prefix), cx); @@ -96,8 +90,6 @@ fn test_invalidate_queries_all_filter(cx: &mut TestAppContext) { }); } -// ── 5. reset_queries clears data across matching resources ────────────── - #[gpui::test] fn test_reset_queries_clears_data_and_status(cx: &mut TestAppContext) { setup_query_client(cx); @@ -146,26 +138,14 @@ fn test_reset_queries_prefix_preserves_non_matching(cx: &mut TestAppContext) { }); } -// ── 6. GC evicts stale Idle/Failure/Success resources ─────────────────── -// -// GC reads live entity state via `entity.read(cx)`, so tests drive resources -// to a known status / timestamp with direct entity updates, then call -// `gc_with_time()` and assert the outcome. With gc_time_ms=1000 (the enforced -// floor): Idle/Failure evict at age >= 1000ms, Success at age >= 2000ms, -// Loading is never evicted, and a missing timestamp counts as fully aged. -// More GC edge cases live in `coverage_gaps/gc_eviction.rs`. - #[gpui::test] fn test_gc_evicts_idle_resources_with_no_snapshot(cx: &mut TestAppContext) { - // Resources that have never been fetched (Idle, no snapshot update) are - // evicted by GC since last_updated_ms=None is treated as expired. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let _entity = client.resource::<String, QueryError>("idle_key", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); - // GC immediately — Idle with no snapshot is treated as expired client.gc_with_time(1_500, cx); let queries = client.all_queries::<String, QueryError>(); @@ -179,19 +159,16 @@ fn test_gc_evicts_idle_resources_with_no_snapshot(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_evicts_failure_resources_after_gc_time(cx: &mut TestAppContext) { - // A Failure resource whose snapshot age exceeds gc_time_ms MUST be evicted. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("fail_key"); - // Drive the resource to Failure at a controlled timestamp (t=1000). let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| { r.apply_failure(QueryError::response("broken"), 1_000) }); - // GC at t=2500: age = 2500 - 1000 = 1500 > gc_threshold(1000) -> evicted client.gc_with_time(2_500, cx); assert!( @@ -204,19 +181,16 @@ fn test_gc_evicts_failure_resources_after_gc_time(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_preserves_failure_resources_before_gc_time(cx: &mut TestAppContext) { - // A Failure resource whose snapshot age is within gc_time_ms MUST survive GC. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("fail_key_early"); - // Drive the resource to Failure at a controlled timestamp (t=2000). let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| { r.apply_failure(QueryError::response("broken"), 2_000) }); - // GC at t=2500: age = 2500 - 2000 = 500 < gc_threshold(1000) -> preserved client.gc_with_time(2_500, cx); let entity = client @@ -233,19 +207,15 @@ fn test_gc_preserves_failure_resources_before_gc_time(cx: &mut TestAppContext) { #[gpui::test] fn test_gc_preserves_loading_resources_regardless_of_age(cx: &mut TestAppContext) { - // A Loading resource MUST survive GC even when its age far exceeds gc_time. - // This tests the GC's "never evict loading" invariant through the public API. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("loading_key"); - // Start a fetch via the public API but don't complete it. let prepared = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) .expect("should start"); - // GC at t=1_000_000 — age is enormous, but Loading resources are never evicted client.gc_with_time(1_000_000, cx); assert!( @@ -253,7 +223,6 @@ fn test_gc_preserves_loading_resources_regardless_of_age(cx: &mut TestAppContext "loading resource must survive GC regardless of age" ); - // Complete the fetch via the public API to verify it still works prepared.complete_success("data".to_string(), cx); let entity = client @@ -275,18 +244,14 @@ fn test_gc_preserves_loading_resources_regardless_of_age(cx: &mut TestAppContext #[gpui::test] fn test_gc_evicts_success_resources_after_success_threshold(cx: &mut TestAppContext) { - // A Success resource whose snapshot age exceeds SUCCESS_GC_MULTIPLIER * gc_time - // (2 * 1000 = 2000ms) MUST be evicted. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("success_old"); - // Drive the resource to Success at a controlled timestamp (t=1000). let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // GC at t=3500: age = 3500 - 1000 = 2500 > success_threshold(2000) -> evicted client.gc_with_time(3_500, cx); assert!( @@ -299,18 +264,14 @@ fn test_gc_evicts_success_resources_after_success_threshold(cx: &mut TestAppCont #[gpui::test] fn test_gc_preserves_success_resources_within_success_threshold(cx: &mut TestAppContext) { - // A Success resource whose snapshot age is within SUCCESS_GC_MULTIPLIER * gc_time - // (2 * 1000 = 2000ms) MUST survive GC. setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("success_fresh"); - // Drive the resource to Success at a controlled timestamp (t=2000). let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| r.apply_success("data".to_string(), 2_000)); - // GC at t=3500: age = 3500 - 2000 = 1500 < success_threshold(2000) -> preserved client.gc_with_time(3_500, cx); let entity = client.query::<String, QueryError>(&key).expect( @@ -330,15 +291,12 @@ fn test_gc_across_multiple_type_buckets(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create resources of different types let _s = client.resource::<String, QueryError>("s", cx); let _n = client.resource::<u32, QueryError>("n", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_queries::<u32, QueryError>().len(), 1); - // GC — idle resources with no snapshot will be evicted - // (last_updated_ms=None is treated as age >= gc_threshold) client.gc_with_time(3_000, cx); assert!( diff --git a/crates/gpui-query/src/tests/integration_client/mod.rs b/crates/gpui-query/src/tests/integration_client/mod.rs index e3272de..b2e3d96 100644 --- a/crates/gpui-query/src/tests/integration_client/mod.rs +++ b/crates/gpui-query/src/tests/integration_client/mod.rs @@ -1,18 +1,3 @@ -//! Integration tests for the QueryClient layer. -//! -//! Tests use `#[gpui::test]` with `TestAppContext` and the `test_support` -//! helpers, exercising the full client API: resource creation, type -//! partitioning, invalidation, reset, GC, mutations, diagnostics, signals, -//! data access, and observers. -//! -//! All tests use `cx.update_global::<QueryClient, _>(|client, cx| ...)`: -//! methods like `resource()` need `&mut self` and `&mut App`, so the -//! immutable `cx.global()` cannot be used. -//! -//! GC reads live entity state via `entity.read(cx)`, so tests drive -//! resources to a known status / timestamp with direct entity updates -//! (e.g. `apply_success`) before calling `gc_with_time()`. - mod client_basics; mod data_access; mod invalidation_reset_gc; diff --git a/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs b/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs index aa83757..a7ce6c8 100644 --- a/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs +++ b/crates/gpui-query/src/tests/integration_client/mutations_lifecycle.rs @@ -1,13 +1,9 @@ -//! Tests for mutation lifecycle, full query lifecycle, and optimistic updates. - use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -// ── 7. Mutation lifecycle through QueryClient ────────────────────────── - #[gpui::test] fn test_mutation_lifecycle_through_client(cx: &mut TestAppContext) { setup_query_client(cx); @@ -19,18 +15,15 @@ fn test_mutation_lifecycle_through_client(cx: &mut TestAppContext) { client.register_mutation::<String, User, QueryError>(&entity, cx); - // Verify registration let mutations = client.all_mutations::<String, User, QueryError>(); assert_eq!(mutations.len(), 1, "should have one registered mutation"); - // Begin mutation entity.update(cx, |m, _| { m.begin("new_name".to_string()); }); assert!(entity.read(cx).is_loading()); assert_eq!(entity.read(cx).variables(), Some(&"new_name".to_string())); - // Complete with success entity.update(cx, |m, _| { m.complete_success(User::new(1, "Alice Updated")); }); @@ -49,7 +42,6 @@ fn test_mutation_failure_and_retry(cx: &mut TestAppContext) { cx.new(|_| MutationResource::<String, User, QueryError>::new(RetryPolicy::new(2))); client.register_mutation::<String, User, QueryError>(&entity, cx); - // Begin and fail first attempt entity.update(cx, |m, _| { m.begin("vars".to_string()); m.complete_failure(QueryError::response("timeout")); @@ -57,13 +49,11 @@ fn test_mutation_failure_and_retry(cx: &mut TestAppContext) { assert!(entity.read(cx).is_failure()); assert_eq!(entity.read(cx).retry_count(), 1); - // Retry entity.update(cx, |m, _| { assert!(m.retry()); }); assert!(entity.read(cx).is_loading()); - // Fail again — retries exhausted entity.update(cx, |m, _| { m.complete_failure(QueryError::response("timeout again")); }); @@ -94,17 +84,11 @@ fn test_mutation_reset_clears_state(cx: &mut TestAppContext) { }); } -// ── 12. Full lifecycle: Idle -> Loading -> Success -> GC ────────────── - #[gpui::test] fn test_full_lifecycle_idle_to_loading_to_success_to_gc(cx: &mut TestAppContext) { - // Uses gc_time=5000ms. After completing a fetch and updating the snapshot - // to Success at t=1000, GC at t=2800 produces age=1800 which is within - // success_threshold (2*5000=10000ms), so the resource MUST survive. setup_query_client_with_gc(cx, 5_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // 1. Start fetch via the public API let key = QueryKey::from(["users", "42"]); let _prepared = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) @@ -115,16 +99,12 @@ fn test_full_lifecycle_idle_to_loading_to_success_to_gc(cx: &mut TestAppContext) .expect("entity should exist"); assert!(entity.read(cx).is_loading()); - // 2. Complete with success at a controlled timestamp (t=1000) so - // the GC age is deterministic. entity.update(cx, |r, _| r.apply_success("Carol".to_string(), 1_000)); assert_eq!(entity.read(cx).status(), QueryStatus::Success); assert_eq!(entity.read(cx).data().unwrap(), "Carol"); - // 3. GC at t=2800: age = 2800 - 1000 = 1800 < success_threshold(10000) -> preserved client.gc_with_time(2_800, cx); - // 5. Unconditional assertion: the resource MUST survive GC let surviving = client.query::<String, QueryError>(&key).expect( "success resource should survive GC (age 1800ms < success_threshold 10000ms)", ); @@ -145,7 +125,6 @@ fn test_full_lifecycle_failure_recovery(cx: &mut TestAppContext) { let key = QueryKey::from("flaky"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Start and fail let rid1 = client .next_request_id_for_key::<String, QueryError>(&key) .expect("request id"); @@ -158,7 +137,6 @@ fn test_full_lifecycle_failure_recovery(cx: &mut TestAppContext) { assert_eq!(entity.read(cx).status(), QueryStatus::Failure); assert!(entity.read(cx).data().is_none()); - // Retry and succeed let rid2 = client .next_request_id_for_key::<String, QueryError>(&key) .expect("request id 2"); @@ -174,21 +152,16 @@ fn test_full_lifecycle_failure_recovery(cx: &mut TestAppContext) { }); } -// ── 13. Optimistic update full lifecycle ─────────────────────────────── - #[gpui::test] fn test_optimistic_update_and_rollback_lifecycle(cx: &mut TestAppContext) { - // Use NoCache policy so begin_request always starts a new fetch setup_query_client_with_policies(cx, CachePolicy::NoCache, RequestPolicy::LatestWins); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from(["users", "42"]); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // 1. Populate with real data via direct apply entity.update(cx, |r, _| r.apply_success("Carol".to_string(), 1_000)); - // 2. Optimistic update client.set_query_data::<String, QueryError>( key.clone(), "Carol (saving...)".to_string(), @@ -197,7 +170,6 @@ fn test_optimistic_update_and_rollback_lifecycle(cx: &mut TestAppContext) { assert_eq!(entity.read(cx).data().unwrap(), "Carol (saving...)"); assert_eq!(entity.read(cx).previous_data().unwrap(), "Carol"); - // 3. Start mutation request (Force mode to bypass cache) let rid = client .next_request_id_for_key::<String, QueryError>(&key) .expect("request id"); @@ -206,7 +178,6 @@ fn test_optimistic_update_and_rollback_lifecycle(cx: &mut TestAppContext) { }); assert!(entity.read(cx).is_loading()); - // 4. Mutation succeeds with real server data entity.update(cx, |r, _| { r.complete_current_success(rid, "Carol (saved)".to_string(), 1_200) }); @@ -224,17 +195,14 @@ fn test_optimistic_update_rollback_on_failure(cx: &mut TestAppContext) { let key = QueryKey::from(["users", "42"]); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // 1. Populate entity.update(cx, |r, _| r.apply_success("Carol".to_string(), 1_000)); - // 2. Optimistic update client.set_query_data::<String, QueryError>( key.clone(), "Carol (saving...)".to_string(), cx, ); - // 3. Start and fail (Force mode to bypass cache) let rid = client .next_request_id_for_key::<String, QueryError>(&key) .expect("request id"); @@ -247,7 +215,6 @@ fn test_optimistic_update_rollback_on_failure(cx: &mut TestAppContext) { assert_eq!(entity.read(cx).status(), QueryStatus::Failure); - // 4. Rollback let rolled_back = entity.update(cx, |r, _| r.rollback_to_previous()); assert!(rolled_back); assert_eq!(entity.read(cx).data().unwrap(), "Carol"); diff --git a/crates/gpui-query/src/tests/test_support.rs b/crates/gpui-query/src/tests/test_support.rs index b55ad49..e84a047 100644 --- a/crates/gpui-query/src/tests/test_support.rs +++ b/crates/gpui-query/src/tests/test_support.rs @@ -1,18 +1,3 @@ -//! Shared test infrastructure: `TestAppContext`/`QueryClient` setup helpers, -//! core resource constructors, assertion helpers, and async test utilities. -//! -//! ```ignore -//! use crate::tests::test_support::*; -//! -//! #[gpui::test] -//! fn my_test(cx: &mut TestAppContext) { -//! setup_test(cx); -//! cx.update(|cx| { -//! // ... test code using cx.global::<QueryClient>() ... -//! }); -//! } -//! ``` - use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -27,15 +12,6 @@ use crate::core::{ #[cfg(feature = "hook")] use crate::hook::MutationOptions; -// ── TestAppContext setup ─────────────────────────────────────────────── - -/// Install a default [`QueryClient`] as a [`Global`] on the given context. -/// -/// Call this at the start of any integration test that needs the client -/// layer. After this, `cx.global::<QueryClient>()` and -/// `cx.update_global::<QueryClient, _>(…)` are available. -/// -/// [`setup_test`] is the preferred shorter alias for this helper. #[cfg(feature = "client")] pub fn setup_query_client(cx: &mut TestAppContext) { cx.update(|cx| { @@ -43,17 +19,11 @@ pub fn setup_query_client(cx: &mut TestAppContext) { }); } -/// Preferred entry point for test setup. Installs a default [`QueryClient`] -/// as a [`Global`] on the context. Equivalent to [`setup_query_client`]. -/// -/// Tests that need custom policies should use [`setup_query_client_with_policies`] -/// or [`setup_query_client_with_gc`] instead. #[cfg(feature = "client")] pub fn setup_test(cx: &mut TestAppContext) { setup_query_client(cx); } -/// Install a [`QueryClient`] with custom policies as a [`Global`]. #[cfg(feature = "client")] pub fn setup_query_client_with_policies( cx: &mut TestAppContext, @@ -65,7 +35,6 @@ pub fn setup_query_client_with_policies( }); } -/// Install a [`QueryClient`] with a custom GC time. #[cfg(feature = "client")] pub fn setup_query_client_with_gc(cx: &mut TestAppContext, gc_time_ms: u64) { cx.update(|cx| { @@ -73,9 +42,6 @@ pub fn setup_query_client_with_gc(cx: &mut TestAppContext, gc_time_ms: u64) { }); } -// ── Core resource constructors ──────────────────────────────────────── - -/// Create a test resource with default policies (TTL 1s, LatestWins). pub fn test_resource() -> QueryResource<&'static str> { QueryResource::new( "test", @@ -84,7 +50,6 @@ pub fn test_resource() -> QueryResource<&'static str> { ) } -/// Create a test resource with custom policies. pub fn test_resource_with_policies( key: impl Into<QueryKey>, cache_policy: CachePolicy, @@ -93,14 +58,10 @@ pub fn test_resource_with_policies( QueryResource::new(key, cache_policy, request_policy) } -/// Create a [`RequestSequencer`] for use in lifecycle tests. pub fn test_sequencer() -> RequestSequencer { RequestSequencer::new() } -// ── Assertion helpers ────────────────────────────────────────────────── - -/// Assert that a resource has the expected status. pub fn assert_status(resource: &QueryResource<impl Clone, impl Clone>, expected: QueryStatus) { let actual = resource.status(); assert_eq!( @@ -110,30 +71,14 @@ pub fn assert_status(resource: &QueryResource<impl Clone, impl Clone>, expected: ); } -// ── Resource factories for state-transition tests ────────────────────── - -/// Create a resource with `NoCache` + `LatestWins`. -/// -/// Every `begin_request` on this resource will return `Started` (never `CacheHit`), -/// making it ideal for state-transition tests that want deterministic control -/// over every fetch lifecycle step without worrying about TTL freshness windows. pub fn nocache_resource(key: impl Into<QueryKey>) -> QueryResource<&'static str> { QueryResource::new(key, CachePolicy::NoCache, RequestPolicy::LatestWins) } -/// Create a fresh resource with a fixed key for state-transition invariant tests. -/// -/// Convenience alias for [`nocache_resource`] with key `"invariant-test"`. -/// Every `begin_request` on this resource will return `Started` (never `CacheHit`). pub fn fresh_resource() -> QueryResource<&'static str> { nocache_resource("invariant-test") } -/// Begin a request on the resource and extract the `RequestId`. -/// -/// Panics with a descriptive message if the result is anything other than `Started`. -/// Use this in tests that need the `request_id` for subsequent `complete_*` calls -/// but don't care about the full `QueryBeginResult`. pub fn begin_request_id( r: &mut QueryResource<impl Clone, impl Clone>, seq: &mut RequestSequencer, @@ -152,14 +97,6 @@ pub fn begin_request_id( } } -/// Accept the current request by `request_id` and complete it with success. -/// -/// Convenience wrapper around [`QueryResource::complete_current_success`] -/// Mirrors [`begin_request_id`] so tests that just need to -/// drive a request through to `Success` can do so in one call without -/// repeating the `(request_id, data, now_ms)` triple inline. -/// -/// Returns `true` if the request was the active one and was completed. pub fn complete_success_id<T, E>( r: &mut QueryResource<T, E>, request_id: RequestId, @@ -169,18 +106,6 @@ pub fn complete_success_id<T, E>( r.complete_current_success(request_id, data, now_ms) } -// ── Cache/mutation option factories ──────────────────────────────────── - -/// Build [`MutationOptions`] with `RetryPolicy::no_retries()` and the -/// standard test GC time (`gc_time_ms: 300_000`). -/// -/// Equivalent to: -/// ```ignore -/// MutationOptions { -/// retry_policy: RetryPolicy::no_retries(), -/// gc_time_ms: 300_000, -/// } -/// ``` #[cfg(feature = "hook")] pub fn no_retry_mutation_options() -> MutationOptions { MutationOptions { @@ -189,29 +114,9 @@ pub fn no_retry_mutation_options() -> MutationOptions { } } -// ── Async test helpers ───────────────────────────────────────────────── - -/// A minimal `DummyView` unit struct for tests that need a view entity to -/// drive `observer.observe(cx)` on a hook-managed entity. -/// -/// Many hook-layer tests create a local `struct DummyView;` inside the test -/// body just to host an observer subscription. This shared struct lets those -/// tests call `cx.new(|_| DummyView)` without redefining the unit type each -/// time, and pairs with [`observe_with_dummy_view`] for the -/// create-view-then-observe dance. #[derive(Default)] pub(crate) struct DummyView; -/// Create a `DummyView` entity, then run `observer.observe(cx)` inside a -/// view-scoped update. Returns the [`gpui::Subscription`] (or `None` if the -/// observer's weak reference is no longer live). -/// -/// This collapses the common pattern: -/// ```ignore -/// struct DummyView; -/// let view = cx.new(|_| DummyView); -/// let sub = view.update(cx, |_view, cx| observer.observe(cx)); -/// ``` #[cfg(feature = "client")] pub fn observe_with_dummy_view<T, E>( cx: &mut App, @@ -225,42 +130,16 @@ where view.update(cx, |_view, cx| observer.observe(cx)) } -/// A minimal generic test harness that owns a single entity handle. -/// -/// Many hook-layer tests define a one-off `struct H { entity: Entity<...> }` -/// purely to host hook calls via `cx.new(|cx| ...)` and later inspect the -/// entity. [`HookHarness`] replaces that boilerplate for the common -/// single-entity case: tests construct `cx.new(|cx| HookHarness::new(entity))` -/// and read back via `harness.read(cx).entity.read(cx)`. -/// -/// This is intentionally narrow (one entity). Tests that need to hold several -/// handles, observer subscriptions, or counters should keep their bespoke -/// harness struct — full migration of every harness is explicitly optional. pub struct HookHarness<T> { - /// The hook-managed entity under test. pub entity: Entity<T>, } impl<T> HookHarness<T> { - /// Wrap a single entity handle in a harness. pub fn new(entity: Entity<T>) -> Self { Self { entity } } } -/// Run the executor until parked, then read from `entity` inside a fresh -/// `cx.update` closure. -/// -/// Equivalent to: -/// ```ignore -/// cx.run_until_parked(); -/// cx.update(|cx| { -/// let value = entity.read_with(cx, |state, _| /* projection */); -/// // ... assert on value ... -/// }); -/// ``` -/// The closure receives the borrowed state and inner `App`, mirroring -/// `entity.read_with` so callers don't need to re-wrap each read. pub fn run_until_parked_and_read<T, R>( cx: &mut TestAppContext, entity: &Entity<T>, @@ -273,40 +152,6 @@ where cx.update(|cx| entity.read_with(cx, f)) } -/// A one-shot async gate for tests that need to hold a fetcher/mutation in -/// flight while a second call is issued. -/// -/// Replaces the busy-wait `while !gate.load(Ordering::Acquire) { -/// executor.timer(Duration::from_millis(1)).await; }` pattern duplicated -/// across several hook tests. The gate is `Arc`-friendly and clonable so it -/// can be moved into a `move || async move { ... }` fetcher closure. -/// -/// # Usage -/// -/// ```ignore -/// use crate::tests::test_support::*; -/// -/// let gate = Gate::new(); -/// let gate_clone = gate.clone(); -/// let executor = cx.background_executor.clone(); -/// let harness = cx.new(|cx| { -/// fetch_query( -/// &entity, -/// move || { -/// let gate = gate.clone(); -/// async move { -/// gate.wait(&executor).await; -/// Ok::<_, QueryError>("first") -/// } -/// }, -/// cx, -/// ); -/// // issue a second call while the first is gated... -/// }); -/// -/// gate.release(); -/// cx.run_until_parked(); -/// ``` pub struct Gate { inner: Arc<Mutex<bool>>, } @@ -326,29 +171,20 @@ impl Default for Gate { } impl Gate { - /// Create a closed gate — [`Gate::wait`] will block until [`Gate::release`] - /// is called. pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(false)), } } - /// Release the gate, unblocking all current and future [`Gate::wait`] - /// callers. Releasing twice is a no-op. pub fn release(&self) { *self.inner.lock().unwrap() = true; } - /// Returns `true` once [`Gate::release`] has been called. pub fn is_released(&self) -> bool { *self.inner.lock().unwrap() } - /// Wait until the gate is released, polling the `executor` with 1ms timers - /// (matching the prior busy-wait pattern). When released, returns - /// immediately. Uses `executor.timer()` so the wait is async-friendly - /// and does not block the executor thread. pub async fn wait(&self, executor: &BackgroundExecutor) { while !self.is_released() { executor.timer(Duration::from_millis(1)).await; @@ -356,9 +192,6 @@ impl Gate { } } -// ── Test fixture types ───────────────────────────────────────────────── - -/// A simple user struct for integration tests. #[derive(Clone, Debug, PartialEq, Default)] pub struct User { pub id: u32, @@ -374,18 +207,11 @@ impl User { } } -/// A simple post type tag for integration tests. Only used as a type -/// parameter (`client.resource::<Post, _>`), never constructed with data. #[derive(Clone, Debug, PartialEq, Default)] pub struct Post; -// ── Time helpers ─────────────────────────────────────────────────────── - -/// A fixed "now" timestamp for deterministic cache tests (ms since UNIX epoch). pub const TEST_NOW_MS: u64 = 1_000_000; -/// Assert that every value in `cases` survives a JSON serialize -> deserialize -/// roundtrip unchanged. pub fn assert_serde_roundtrip<T>(cases: &[T]) where T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, From aa40822026537f878de924b389b3e3d1a4a88f13 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 19:31:34 +0200 Subject: [PATCH 038/111] refactor: purge hook comments to one-line-or-examples and strip test comments --- crates/gpui-query/src/hook/fetch_retry.rs | 46 +++----- crates/gpui-query/src/hook/gpui_compat.rs | 20 +--- crates/gpui-query/src/hook/mod.rs | 26 +---- .../src/hook/mutation_hooks/hooks.rs | 77 +++---------- .../src/hook/mutation_hooks/internals.rs | 45 ++------ .../gpui-query/src/hook/mutation_hooks/mod.rs | 8 +- crates/gpui-query/src/hook/options.rs | 90 ++++------------ crates/gpui-query/src/hook/query_hooks.rs | 83 +++----------- .../hook/use_infinite_query/fetch_helpers.rs | 23 +--- .../hook/use_infinite_query/fetch_runners.rs | 34 ++---- .../src/hook/use_infinite_query/hook.rs | 26 +---- .../src/hook/use_infinite_query/mod.rs | 9 +- .../gpui-query/src/hook/use_query_select.rs | 30 +----- .../tests/hook_tests/infinite_query_tests.rs | 33 ------ crates/gpui-query/src/tests/hook_tests/mod.rs | 14 --- .../hook_tests/mutation_tests/basic_tests.rs | 6 -- .../mutation_tests/callback_tests.rs | 3 - .../tests/hook_tests/mutation_tests/mod.rs | 2 - .../mutation_tests/retry_reset_tests.rs | 10 -- .../hook_tests/query_tests/advanced_hooks.rs | 39 ------- .../hook_tests/query_tests/basic_hooks.rs | 27 ----- .../query_tests/fetch_and_lifecycle/fetch.rs | 33 ------ .../fetch_and_lifecycle/lifecycle.rs | 22 ---- .../query_tests/fetch_and_lifecycle/mod.rs | 3 - .../src/tests/hook_tests/query_tests/mod.rs | 3 - .../select_and_retry/retry_tests.rs | 15 --- .../select_and_retry/select_tests.rs | 24 ----- .../hook_tests/query_tests/with_policy.rs | 19 ---- .../src/tests/hook_tests/regression_tests.rs | 54 ---------- .../client_basics.rs | 52 --------- .../client_bucket_coverage.rs | 24 ----- .../client_gap_coverage/gc_coverage.rs | 102 +----------------- .../client_gap_coverage/hook_coverage.rs | 51 +-------- .../client_gap_coverage/mod.rs | 2 - .../client_mutations.rs | 28 ----- .../fetch_prefetch_cancel.rs | 47 -------- .../client_operations/gc_query_operations.rs | 91 ---------------- .../client_operations/mod.rs | 3 - .../tests/integration_client_coverage/mod.rs | 5 - .../property_tests/cache_policy_retry.rs | 45 -------- .../query_key/deterministic_tests.rs | 32 ------ .../src/tests/property_tests/query_key/mod.rs | 2 - .../property_tests/query_key/proptests.rs | 42 -------- .../property_tests/query_key/strategies.rs | 94 +++++----------- 44 files changed, 133 insertions(+), 1311 deletions(-) diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index becdc3a..8971709 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -10,8 +10,6 @@ use crate::core::{ use super::{current_time_ms, read_entity}; -/// Decomposed fetcher success: the data, an optional server-derived -/// [`CachePolicy`], and (under `persist`) optional opaque metadata. pub(crate) struct FetchParts<T> { pub data: T, pub server_policy: Option<CachePolicy>, @@ -19,8 +17,8 @@ pub(crate) struct FetchParts<T> { pub meta: Option<serde_json::Value>, } -/// Adapt a fetcher success payload into [`FetchParts`]. Plain `T` yields no -/// server policy; [`Fetched<T>`] carries both optional extras. +/// Plain `T` yields no server policy; [`Fetched<T>`] carries the optional +/// policy and (under `persist`) meta. pub(crate) trait FetchedLike<T> { fn into_parts(self) -> FetchParts<T>; } @@ -47,18 +45,11 @@ impl<T> FetchedLike<T> for Fetched<T> { } } -/// Begin a request on a query entity: runs the cache-freshness / -/// `IgnoreWhileLoading` check and the `Loading` transition atomically in one -/// `entity.update`, and reads the freshly created signal in the same pass. +/// Runs the freshness check, `Loading` transition, and signal read atomically +/// in one `entity.update`; returns `(None, None)` on `CacheHit` / +/// `IgnoredWhileLoading` (skip the fetch). /// -/// Returns `(Some(request_id), Some(signal))` when a fetch should be spawned; -/// `(None, None)` on `CacheHit` / `IgnoredWhileLoading` (skip the fetch). -/// -/// When a [`QueryClient`] global is present, the bucket's co-located sequencer -/// mints the `RequestId` (shared with the imperative `prepare_fetch_query` -/// path so the two never collide for the same key); otherwise -/// `begin_request_with_id` falls back to the resource's own monotonic -/// sequencer. `known_key` spares callers that already hold the key a re-read. +/// With a [`QueryClient`], the bucket sequencer mints the `RequestId`, shared with `prepare_fetch_query` so the two never collide for the same key. pub(crate) fn begin_request_on_entity<T, E, C>( entity: &Entity<QueryResource<T, E>>, cx: &mut Context<C>, @@ -95,14 +86,11 @@ where }) } -/// Single retry loop shared by every query fetch shape. +/// Shared retry loop; with `signal = Some`, a fresh signal is re-read after +/// each delay, and the loop stops once a newer request supersedes this one. /// -/// `signal` is `None` for signal-less fetchers; `Some(initial)` re-reads a -/// fresh signal from the resource after each retry delay. After each delay the -/// loop checks `is_current_request` and stops early if a newer request has -/// superseded this one. `cx.notify()` fires only when a result is actually -/// accepted, and `entity.update` results are discarded because `update` -/// returns `Result<R>` under `AsyncApp`. +/// `cx.notify()` fires only when a result is accepted; `entity.update` results +/// are discarded (`update` returns `Result<R>` under `AsyncApp`). async fn run_query_retry_loop<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, @@ -129,15 +117,12 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( let meta = parts.meta; let now_ms = current_time_ms(); let Some(e) = entity.upgrade() else { - // Owning component unmounted: result is silently discarded. return; }; let _ = e.update(cx, |resource, cx| { resource.reset_retry_count(); if let Some(guard) = resource.accept_current_request(request_id) { resource.complete_success(guard, parts.data, now_ms); - // Server wins: a fetcher-supplied policy overrides the - // resource's stored one. if let Some(policy) = parts.server_policy { resource.set_cache_policy(policy); } @@ -165,8 +150,7 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( if retry_policy.should_retry(attempt) { let delay_ms = retry_policy.delay_for_attempt(attempt); let Some(e) = entity.upgrade() else { return }; - // No notify: retry counters do not change status (stays - // Loading), and the observer dedupes on status. + // No notify: retry counters keep status Loading; the observer dedupes on status. let _ = e.update(cx, |resource, _cx| { resource.increment_retry(); }); @@ -203,7 +187,6 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.complete_failure(guard, error, failure_now_ms); - // Reset so the next begin_request starts clean. resource.reset_retry_count(); cx.notify(); #[cfg(feature = "persist")] @@ -223,8 +206,7 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( } } -/// Fetch with retry for a query resource (no-signal fetcher): a thin wrapper -/// over [`run_query_retry_loop`] with `signal = None`. +/// No-signal wrapper over [`run_query_retry_loop`]. pub(crate) async fn fetch_with_retry<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, @@ -243,9 +225,7 @@ pub(crate) async fn fetch_with_retry<T, E, Out, F, Fut>( .await; } -/// Like [`fetch_with_retry`] but for fetchers that take a [`QuerySignal`]. -/// On retry, a fresh signal is read from the resource and handed to the -/// fetcher. +/// Signal variant: each retry hands the fetcher a fresh signal from the resource. pub(crate) async fn fetch_signal_with_retry<T, E, Out, F, Fut>( fetcher: F, initial_signal: QuerySignal, diff --git a/crates/gpui-query/src/hook/gpui_compat.rs b/crates/gpui-query/src/hook/gpui_compat.rs index 0c73fb3..fd14002 100644 --- a/crates/gpui-query/src/hook/gpui_compat.rs +++ b/crates/gpui-query/src/hook/gpui_compat.rs @@ -1,20 +1,8 @@ -//! Compatibility shim for reading GPUI entities across divergent `read_with` -//! signatures. -//! -//! `Entity::read_with` returns `R` directly in older gpui (e.g. the Zed git -//! revisions some apps pin), but returns `C::Result<R>` — which is -//! `Result<R>` for `AsyncApp` — in gpui 0.2.2 (crates.io). A single call site -//! cannot name both return types, so [`read_entity`] makes the inner closure -//! return `()`. `read_with` therefore yields `()` on the older API and -//! `Result<()>` on 0.2.2; both are discarded, and the real value is captured -//! through a mutable local. The captured value is identical on either version, -//! so the crate compiles unchanged against both. +//! `Entity::read_with` returns `R` in older gpui but `C::Result<R>` (i.e. +//! `Result<R>` for `AsyncApp`) in 0.2.2. [`read_entity`] makes the closure +//! return `()` on both and captures the real value through a mutable local. -/// Read a value from a GPUI entity in a way that is source-compatible with both -/// the `R`-returning and the `Result<R>`-returning `Entity::read_with`. -/// -/// Returns `Some(R)` on success, or `None` if the entity could not be read -/// (e.g. dropped or accessed off-thread) under the `Result`-returning API. +/// `None` when the entity could not be read (dropped, off-thread) under the `Result`-returning `read_with`. #[inline] pub(crate) fn read_entity<T: 'static, R, C: gpui::AppContext>( entity: &gpui::Entity<T>, diff --git a/crates/gpui-query/src/hook/mod.rs b/crates/gpui-query/src/hook/mod.rs index 3a853cc..e42f81b 100644 --- a/crates/gpui-query/src/hook/mod.rs +++ b/crates/gpui-query/src/hook/mod.rs @@ -1,9 +1,4 @@ -//! `use_query` and `use_mutation` hooks: query and mutation subscriptions for -//! GPUI components. -//! -//! The primary API is options-first. The fetcher always receives a -//! [`QuerySignal`](crate::core::QuerySignal) for cooperative cancellation. -//! `use_query_unsignalled` remains for callers that want a signal-free fetcher. +//! `use_query`, `use_mutation`, and `use_infinite_query` hooks. //! //! # Query usage //! @@ -27,7 +22,6 @@ //! .cache_policy(CachePolicy::Ttl { ttl_ms: 60_000 }) //! .request_policy(RequestPolicy::LatestWins), //! |signal| async move { -//! // Your async fetcher here //! Ok(vec![]) //! }, //! cx, @@ -66,15 +60,6 @@ //! } //! } //! ``` -//! -//! # Discard behavior -//! -//! Async tasks here access the owning entity through -//! [`gpui::WeakEntity::upgrade()`]. If the component unmounts while a fetch is -//! in-flight, `upgrade()` returns `None` and the result is silently discarded: -//! no callback or notification fires. Stale writes are prevented by the -//! two-phase `accept_current_request` + complete protocol rather than by -//! aborting tasks. mod fetch_retry; mod gpui_compat; @@ -84,8 +69,6 @@ mod query_hooks; mod use_infinite_query; mod use_query_select; -// Source-compat shim: read entities regardless of whether `read_with` returns -// `R` (older gpui / git) or `Result<R>` (gpui 0.2.2 / crates.io). pub(crate) use gpui_compat::read_entity; pub use options::{InfiniteQueryOptions, MutationCallbacks, MutationOptions, QueryOptions}; @@ -102,16 +85,11 @@ pub use use_infinite_query::{ pub use use_query_select::use_query_select; -// `use_mutation_with_options` is deprecated and deliberately not re-exported; -// callers that reach it via the full path still get the deprecation warning. pub use mutation_hooks::{ mutate, mutate_arc, mutate_by_ref, mutate_with_callbacks, use_mutation, use_mutation_state, }; -/// Current time as milliseconds since the UNIX epoch. -/// -/// A clock that reports a time before the epoch clamps to `0`, which callers -/// treat as "ancient" (stale). No panic is propagated. +/// Milliseconds since the UNIX epoch; pre-epoch clocks clamp to `0` (treated as stale). #[inline] pub fn current_time_ms() -> u64 { std::time::SystemTime::now() diff --git a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs index 8c62ed9..2a0e6d1 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs @@ -1,6 +1,3 @@ -//! Public mutation hooks: `use_mutation`, `mutate`, `mutate_with_callbacks`, -//! `mutate_by_ref`, `mutate_arc`, and `use_mutation_state`. - use std::sync::Arc; use gpui::{AppContext as _, BorrowAppContext as _, Context, Entity, Subscription}; @@ -12,17 +9,7 @@ use super::super::MutationOptions; use super::super::options::MutationCallbacks; use super::internals::{run_mutation_loop_by_ref, run_mutation_loop_by_ref_with_callbacks}; -/// Hook for executing mutations (create, update, delete operations). -/// -/// Creates a [`MutationResource`] entity and returns it with a subscription -/// for state observation during render. Trigger it with [`mutate`] from event -/// handlers. Accepts `impl Into<MutationOptions>`, so both `use_mutation((), cx)` -/// and `use_mutation(MutationOptions::default(), cx)` work. -/// -/// The observer dedupes on `MutationStatus`: intermediate updates like -/// `increment_retry()` stay in Loading and do not trigger re-renders. The -/// entity is registered with the global [`QueryClient`] so `use_mutation_state` -/// finds it and GC respects `gc_time_ms`. +/// The observer dedupes on `MutationStatus` (retry ticks stay in Loading, no re-render); the entity registers with [`QueryClient`] so `use_mutation_state` finds it and GC respects `gc_time_ms`. /// /// # Example /// @@ -72,8 +59,7 @@ where let subscription = match observer.observe(cx) { Some(sub) => sub, None => { - // The entity was just created, so this only fires on a GPUI - // internal regression. Do not panic production builds. + // Only reachable on a GPUI internal regression; never panic production. debug_assert!( false, "MutationObserver::observe failed: entity was just created and \ @@ -92,14 +78,13 @@ where (entity, subscription) } -/// Hook for executing mutations with a custom retry policy. Deprecated alias -/// of [`use_mutation`], which now accepts `MutationOptions` directly. +/// Deprecated alias of [`use_mutation`], which now takes `MutationOptions` +/// via `Into`. #[deprecated( since = "0.2.0", note = "Use `use_mutation(options, cx)` instead — it now accepts MutationOptions via Into" )] -// Retained for the deprecated source-compat path and exercised by -// `test_deprecated_use_mutation_with_options_still_works`. +// Not re-exported; kept alive by the deprecated source-compat test. #[allow(dead_code)] pub fn use_mutation_with_options<V, T, E, C>( options: &MutationOptions, @@ -114,15 +99,12 @@ where use_mutation(options.clone(), cx) } -/// Observe all mutation state across the application for a given -/// `(V, T, E)` type triple. Returns an empty vec if no mutations of this type -/// exist or no [`QueryClient`] is set up. +/// All registered mutations for the `(V, T, E)` triple; empty when none exist or no [`QueryClient`] is set. /// /// # Example /// /// ```no_run /// use gpui_query::hook::use_mutation_state; -/// use gpui_query::MutationResource; /// # #[derive(Clone)] /// # struct NewUser; /// # #[derive(Clone)] @@ -132,10 +114,6 @@ where /// # fn _doc<C: 'static>(cx: &mut gpui::Context<C>) { /// /// let mutations = use_mutation_state::<NewUser, User, QueryError, _>(cx); -/// for entity in &mutations { -/// let status = entity.read(cx).status(); -/// // ... -/// } /// # } /// ``` pub fn use_mutation_state<V, T, E, C>(cx: &mut Context<C>) -> Vec<Entity<MutationResource<V, T, E>>> @@ -152,17 +130,7 @@ where } } -/// Trigger a mutation on an existing mutation entity. -/// -/// Transitions the entity to Loading with the given variables, spawns the -/// mutator, and retries per the entity's policy. Variables are wrapped in an -/// `Arc<V>` so each retry only clones once; prefer [`mutate_by_ref`] or -/// [`mutate_arc`] to skip the per-attempt `V::clone` entirely. -/// -/// A call while the mutation is already Loading is a no-op: the check and the -/// `begin` transition happen inside one `entity.update`, so racing callers -/// cannot both start. The spawned task is stored on the resource, so a -/// replacement call or entity drop aborts a prior in-flight task. +/// Variables are wrapped in an `Arc<V>` (one `V::clone` per attempt); a call while Loading is a no-op, and a replacement call or entity drop aborts the prior task. /// /// # Example /// @@ -192,7 +160,6 @@ pub fn mutate<V, T, E, C, F, Fut>( F: Fn(V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - // One V::clone per attempt, matching the Fn(V) mutator contract. begin_and_spawn( entity, Arc::new(variables), @@ -202,13 +169,9 @@ pub fn mutate<V, T, E, C, F, Fut>( ); } -/// Like [`mutate`] but with lifecycle callbacks. -/// -/// Callbacks fire on the final outcome (first success or retries exhausted), -/// never on intermediate attempts. They receive cloned data/error and run -/// outside any entity borrow, so they may safely call `entity.update()`. If -/// the entity is dropped mid-mutation, `on_error` and `on_settled` still fire -/// so callers always get a terminal callback. +/// Callbacks fire on the terminal outcome only, outside any entity borrow (safe +/// to call `entity.update()`); `on_error`/`on_settled` still fire if the entity +/// drops mid-mutation. pub fn mutate_with_callbacks<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: V, @@ -232,12 +195,8 @@ pub fn mutate_with_callbacks<V, T, E, C, F, Fut>( ); } -/// Like [`mutate`] but the mutator receives `&V`, so the retry loop borrows -/// the variables from the stored `Arc<V>` and performs no `V::clone` per -/// attempt. Clone inside the mutator only if it needs an owned value across an -/// `.await`. -/// -/// `V` is still `Clone` because `begin` stores an owned copy on the resource. +/// The mutator receives `&V` borrowed from the stored `Arc<V>`: no `V::clone` +/// per attempt (`V: Clone` is still required; `begin` stores an owned copy). pub fn mutate_by_ref<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: V, @@ -254,8 +213,7 @@ pub fn mutate_by_ref<V, T, E, C, F, Fut>( begin_and_spawn(entity, Arc::new(variables), mutator, cx, None); } -/// Like [`mutate_by_ref`] but accepts `Arc<V>` directly, letting the caller -/// share the variables buffer across invocations without an extra `Arc::new`. +/// [`mutate_by_ref`] taking `Arc<V>` directly, so callers share one variables buffer. pub fn mutate_arc<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -272,11 +230,8 @@ pub fn mutate_arc<V, T, E, C, F, Fut>( begin_and_spawn(entity, variables, mutator, cx, None); } -/// Shared guard/begin/spawn for every `mutate*` entrypoint. -/// -/// The `is_loading` guard and the `begin` transition happen inside one -/// `entity.update` so racing callers cannot both begin. The spawned task is -/// stored via `set_current_task` so replacement or drop aborts it. +/// The `is_loading` guard and `begin` run in one `entity.update`, so racing +/// callers cannot both begin; `set_current_task` makes replacement or drop abort. fn begin_and_spawn<V, T, E, C, F, Fut>( entity: &Entity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -322,8 +277,6 @@ fn begin_and_spawn<V, T, E, C, F, Fut>( run_mutation_loop_by_ref(&weak, variables, mutator, &retry_policy, cx).await; } }); - // No notify: set_current_task does not change status (already Loading - // from begin, which notified). entity.update(cx, |r, _| { r.set_current_task(task); }); diff --git a/crates/gpui-query/src/hook/mutation_hooks/internals.rs b/crates/gpui-query/src/hook/mutation_hooks/internals.rs index 1840d71..6692c5f 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/internals.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/internals.rs @@ -1,10 +1,6 @@ -//! Internal retry loops for mutations. -//! -//! Everything funnels into [`run_mutation_loop_inner`], which takes -//! `Option<MutationCallbacks>` and a `Fn(&V) -> Fut` mutator so the variables -//! are borrowed from the stored `Arc<V>` on every attempt (no `V::clone` per -//! retry). The public `mutate` entrypoints that accept `Fn(V) -> Fut` adapt at -//! the call site with a one-line wrapper. +//! All entrypoints funnel into [`run_mutation_loop_inner`], whose `Fn(&V)` +//! mutator borrows the variables from the stored `Arc<V>` (no `V::clone` per +//! retry); the `Fn(V)` public entrypoints adapt at the call site. use std::sync::Arc; @@ -14,19 +10,10 @@ use super::super::options::MutationCallbacks; use crate::hook::read_entity; -/// Unified retry loop for mutations, shared by the no-callback and -/// with-callback variants. -/// -/// While retries remain, uses `increment_retry()` + `prepare_retry()` instead -/// of `complete_failure()` + `retry()` so observers never see a transient -/// Failure flash between attempts; only exhausted retries produce a terminal -/// `complete_failure()`. Neither intermediate call notifies: the status stays -/// Loading and the `MutationObserver` dedupes. -/// -/// After each retry delay the loop checks whether the mutation is still in -/// Loading state; a cancelled or reset mutation stops retrying immediately. -/// `entity.update` results are discarded because `update` returns `Result<R>` -/// under `AsyncApp`. +/// Retries via `increment_retry()` + `prepare_retry()` so observers never see +/// a transient Failure between attempts; only exhausted retries produce a +/// terminal `complete_failure()`. Stops once the mutation leaves Loading +/// (cancelled or reset); intermediate calls don't notify (observer dedupes). async fn run_mutation_loop_inner<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -48,12 +35,9 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( match result { Ok(data) => { - // Clone data before update only when callbacks need it. let data_for_callback = callbacks.is_some().then(|| data.clone()); let Some(entity) = weak.upgrade() else { - // Entity dropped mid-mutation: fire on_settled with None - // for both so the caller sees the discard. if let Some(ref cb) = callbacks && let Some(ref f) = cb.on_settled { @@ -68,8 +52,7 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( cx.default_global::<crate::client::CacheMutation>(); }); - // Fire outside the entity borrow so callbacks can safely - // call entity.update(). + // Fire outside the entity borrow so callbacks can call entity.update(). if let Some(ref cb) = callbacks { if let Some(ref d) = data_for_callback && let Some(ref f) = cb.on_success @@ -108,8 +91,6 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( return; }; if !read_entity(&entity, cx, |r, _| r.is_loading()).unwrap_or(false) { - // Cancelled or reset during the delay: still fire the - // terminal callbacks. fire_error_callbacks(&callbacks, &error_for_callback); #[cfg(debug_assertions)] eprintln!( @@ -124,9 +105,6 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( attempt += 1; } else { - // Terminal failure. Capture availability before - // complete_failure so callbacks fire even if the entity - // drops in between. if let Some(entity) = weak.upgrade() { let _ = entity.update(cx, |resource, cx| { resource.complete_failure(error); @@ -145,8 +123,7 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( } } -/// Fire `on_error` / `on_settled` for a failed mutation, whether the entity is -/// still alive or not. +/// Fires `on_error` / `on_settled` whether or not the entity survived. fn fire_error_callbacks<T, E>( callbacks: &Option<MutationCallbacks<T, E>>, error_for_callback: &Option<E>, @@ -163,8 +140,6 @@ fn fire_error_callbacks<T, E>( } } -/// Retry loop for the `Fn(&V) -> Fut` mutator signature: borrows the variables -/// via the stored `Arc<V>` on every attempt, no `V::clone` per retry. pub(super) async fn run_mutation_loop_by_ref<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, @@ -181,8 +156,6 @@ pub(super) async fn run_mutation_loop_by_ref<V, T, E, F, Fut>( run_mutation_loop_inner(weak, variables, mutator, retry_policy, None, cx).await; } -/// Like [`run_mutation_loop_by_ref`] but fires lifecycle callbacks on the -/// final outcome. pub(super) async fn run_mutation_loop_by_ref_with_callbacks<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, diff --git a/crates/gpui-query/src/hook/mutation_hooks/mod.rs b/crates/gpui-query/src/hook/mutation_hooks/mod.rs index 2315051..e69cfa6 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/mod.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/mod.rs @@ -1,9 +1,7 @@ -//! Mutation hooks and internals: `use_mutation`, `mutate`, `mutate_with_callbacks`, -//! `mutate_by_ref`, `mutate_arc`, and the internal retry loops. +//! Mutation hooks and internal retry loops. //! -//! `use_mutation_with_options` is deprecated and deliberately not re-exported -//! from the public surface; the `pub use` list would otherwise fire the -//! deprecated lint on every import of this module. +//! `use_mutation_with_options` is deprecated and deliberately not re-exported: +//! the `pub use` would fire the deprecation lint on every import. mod hooks; mod internals; diff --git a/crates/gpui-query/src/hook/options.rs b/crates/gpui-query/src/hook/options.rs index b16eea1..6bffbbe 100644 --- a/crates/gpui-query/src/hook/options.rs +++ b/crates/gpui-query/src/hook/options.rs @@ -1,15 +1,11 @@ -//! Query and mutation options with builder pattern and sensible defaults. -//! -//! All options implement `Default` and `From<&str>` so callers can pass just a -//! string key for the simplest case. +//! Query and mutation options; `From<&str>`/`From<String>` let a bare key +//! stand in for a full options value. use std::sync::Arc; use crate::core::{CachePolicy, RefetchTrigger, RequestPolicy, RetryPolicy}; -/// Options for `use_query` and `fetch_query`. -/// -/// # Quick start +/// # Examples /// /// ```no_run /// use gpui_query::QueryOptions; @@ -21,12 +17,10 @@ use crate::core::{CachePolicy, RefetchTrigger, RequestPolicy, RetryPolicy}; /// # struct MyError; /// # fn _doc(cx: &mut gpui::Context<()>) { /// -/// // Simplest: just a string key /// let result = use_query("users", |signal| async move { /// Ok::<Vec<User>, MyError>(vec![]) /// }, cx); /// -/// // With options: /// let result = use_query( /// QueryOptions::new("users") /// .cache_policy(CachePolicy::Ttl { ttl_ms: 300_000 }) @@ -40,41 +34,30 @@ use crate::core::{CachePolicy, RefetchTrigger, RequestPolicy, RetryPolicy}; /// ``` #[derive(Clone, Debug)] pub struct QueryOptions { - /// The query key. Can be a string or multi-segment key. pub key: crate::core::QueryKey, - /// Cache policy. Default: Ttl { ttl_ms: 60_000 }. + /// Default: `Ttl { ttl_ms: 60_000 }`. pub cache_policy: CachePolicy, - /// Request policy. Default: LatestWins. + /// Default: `LatestWins`. pub request_policy: RequestPolicy, - /// Retry policy. Default: 3 retries with exponential backoff. + /// Default: 3 retries with exponential backoff. pub retry_policy: RetryPolicy, - /// GC time in milliseconds. Default: 300_000 (5 minutes). - /// - /// Reserved: stored but not yet consumed. GC currently runs off the global - /// time set via `QueryClient::with_gc_time`; setting this has no effect - /// today. + /// Default 300_000; reserved: GC runs off `QueryClient::with_gc_time`, so this field has no effect today. pub gc_time_ms: u64, - /// Keep previous data when the key changes. - /// - /// Reserved: stored but not yet consumed; setting it has no effect today. + /// Reserved: stored but not consumed; setting it has no effect today. pub keep_previous_data: bool, - /// Whether to force a fetch (ignore cache). When `true`, `use_query` - /// passes `QueryFetchMode::Force` to `begin_request`, bypassing freshness - /// checks. + /// When `true`, `use_query` bypasses freshness checks (`QueryFetchMode::Force`). pub force_fetch: bool, - /// Refetch on mount trigger. Reserved: stored but not yet consumed. + /// Reserved: stored but not consumed. pub refetch_on_mount: RefetchTrigger, - /// Refetch on window focus trigger. Reserved: stored but not yet consumed. + /// Reserved: stored but not consumed. pub refetch_on_window_focus: RefetchTrigger, - /// Refetch on reconnect trigger. Reserved: stored but not yet consumed. + /// Reserved: stored but not consumed. pub refetch_on_reconnect: RefetchTrigger, } impl Default for QueryOptions { fn default() -> Self { Self { - // Not const-constructable: QueryKey wraps an Arc, so `from` - // allocates. Only reached when a caller omits the key. key: crate::core::QueryKey::from("default"), cache_policy: CachePolicy::default(), request_policy: RequestPolicy::default(), @@ -89,30 +72,26 @@ impl Default for QueryOptions { } } -/// Generates the builder methods shared by [`QueryOptions`] and -/// [`InfiniteQueryOptions`] so the two cannot drift. +/// Builder methods shared by [`QueryOptions`] and [`InfiniteQueryOptions`] so +/// the two cannot drift. macro_rules! impl_query_options_builders { ($t:ident) => { impl $t { - /// Set the cache policy. pub fn cache_policy(mut self, policy: CachePolicy) -> Self { self.cache_policy = policy; self } - /// Set the request policy. pub fn request_policy(mut self, policy: RequestPolicy) -> Self { self.request_policy = policy; self } - /// Set the retry policy. pub fn retry_policy(mut self, policy: RetryPolicy) -> Self { self.retry_policy = policy; self } - /// Set the GC time in milliseconds. pub fn gc_time(mut self, ms: u64) -> Self { self.gc_time_ms = ms; self @@ -122,7 +101,6 @@ macro_rules! impl_query_options_builders { } impl QueryOptions { - /// Create options with just a key. pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { Self { key: key.into(), @@ -138,16 +116,11 @@ impl QueryOptions { } } - /// Force a fetch, ignoring cache freshness checks. pub fn force(mut self) -> Self { self.force_fetch = true; self } - /// Keep previous data when the key changes. - /// - /// Reserved: sets the field, which is not yet consumed by `use_query`. - /// Calling it has no effect today. pub fn keep_previous(mut self) -> Self { self.keep_previous_data = true; self @@ -187,12 +160,11 @@ impl From<(crate::core::QueryKey, CachePolicy, RequestPolicy)> for QueryOptions } } -/// Options for `use_mutation`. #[derive(Clone, Debug)] pub struct MutationOptions { - /// Retry policy. Default: no retries. + /// Default: no retries. pub retry_policy: RetryPolicy, - /// GC time in milliseconds. + /// Default: 300_000. pub gc_time_ms: u64, } @@ -206,13 +178,11 @@ impl Default for MutationOptions { } impl MutationOptions { - /// Set the retry policy. pub fn retry_policy(mut self, policy: RetryPolicy) -> Self { self.retry_policy = policy; self } - /// Set the GC time in milliseconds. pub fn gc_time(mut self, ms: u64) -> Self { self.gc_time_ms = ms; self @@ -225,12 +195,7 @@ pub type MutationErrorCallback<E> = Option<Arc<dyn Fn(&E) + Send + Sync>>; pub type MutationSettledCallback<T, E> = Option<Arc<dyn Fn(Option<&T>, Option<&E>) + Send + Sync>>; -/// Lifecycle callbacks for mutations. -/// -/// `Clone` is manual (every field is an `Option<Arc<...>>`, so cloning bumps -/// refcounts without requiring `T: Clone` / `E: Clone`). Callbacks are shared -/// across concurrent mutation invocations. `E` should implement `Debug` so -/// callbacks can log or display error details. +/// Manual `Clone` (field `Arc`s, no `T: Clone` / `E: Clone` bound); shared across concurrent invocations. pub struct MutationCallbacks<T, E> { /// Fired on terminal success. pub on_success: MutationSuccessCallback<T>, @@ -261,24 +226,20 @@ impl<T, E> Default for MutationCallbacks<T, E> { } impl<T, E> MutationCallbacks<T, E> { - /// Create empty callbacks. pub fn new() -> Self { Self::default() } - /// Set the success callback. pub fn on_success(mut self, f: impl Fn(&T) + Send + Sync + 'static) -> Self { self.on_success = Some(Arc::new(f)); self } - /// Set the error callback. pub fn on_error(mut self, f: impl Fn(&E) + Send + Sync + 'static) -> Self { self.on_error = Some(Arc::new(f)); self } - /// Set the settled callback (fires on both success and failure). pub fn on_settled( mut self, f: impl Fn(Option<&T>, Option<&E>) + Send + Sync + 'static, @@ -288,20 +249,15 @@ impl<T, E> MutationCallbacks<T, E> { } } -/// Options for infinite queries. #[derive(Clone, Debug)] pub struct InfiniteQueryOptions { - /// The query key. pub key: crate::core::QueryKey, - /// Cache policy. pub cache_policy: CachePolicy, - /// Request policy. pub request_policy: RequestPolicy, - /// Maximum pages to retain. Default: 50. + /// Default: 50; oldest pages are evicted beyond it. pub max_pages: Option<usize>, - /// Retry policy. pub retry_policy: RetryPolicy, - /// GC time in milliseconds. Default: 300_000 (5 minutes). + /// Default: 300_000. pub gc_time_ms: u64, } @@ -319,7 +275,6 @@ impl Default for InfiniteQueryOptions { } impl InfiniteQueryOptions { - /// Create with just a key. pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { Self { key: key.into(), @@ -331,15 +286,14 @@ impl InfiniteQueryOptions { } } - /// Set max pages: the number of retained pages before old ones are - /// evicted. Use [`InfiniteQueryOptions::unbounded_pages`] for no limit. + /// Retained pages before old ones are evicted; see + /// [`InfiniteQueryOptions::unbounded_pages`] for no limit. pub fn max_pages(mut self, max: usize) -> Self { self.max_pages = Some(max); self } - /// Allow unbounded page accumulation (no limit). Use with caution: page - /// storage grows without bound if the user scrolls far enough. + /// No limit: page storage grows without bound if the user scrolls far enough. pub fn unbounded_pages(mut self) -> Self { self.max_pages = None; self diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index 102d9c2..e195101 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -1,11 +1,6 @@ -//! Query hook functions: `use_query`, `use_query_unsignalled`, `use_query_manual`, -//! `fetch_query`, and `fetch_query_with_signal`. -//! -//! Plain-query fetch tasks are deliberately detached. Stale writes are already -//! prevented by the cooperative `QuerySignal` plus the two-phase -//! `is_current_request` / `accept_current_request` guard in the retry loop, and -//! hard-aborting on replacement would break that contract. Each task holds only -//! a `WeakEntity`, so it self-terminates once the owning entity is dropped. +//! Plain-query fetch tasks are deliberately detached: stale writes are guarded +//! by the two-phase `accept_current_request` protocol, and each task holds +//! only a `WeakEntity`, so it self-terminates on entity drop. use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; @@ -17,15 +12,8 @@ use super::fetch_retry::{ FetchedLike, begin_request_on_entity, fetch_signal_with_retry, fetch_with_retry, }; -/// Subscribe to a query resource and re-render when it changes. -/// -/// The primary hook: creates or reuses the resource in the global -/// [`QueryClient`], sets up a [`QueryObserver`], propagates the retry policy -/// from `options`, and spawns a signal-accepting fetch if the resource is -/// idle. Call it in a component constructor, not in `render`. -/// -/// If the component unmounts mid-fetch the result is silently discarded; use -/// [`fetch_query_with_signal`] directly when you need completion guarantees. +/// Creates or reuses the resource in the global [`QueryClient`] and spawns a +/// fetch if it is idle; call it in a constructor, never in `render`. pub fn use_query<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -41,17 +29,8 @@ where use_query_impl(options.into(), fetcher, cx) } -/// Like [`use_query`], but the fetcher returns -/// [`Fetched<T>`](crate::core::Fetched) so a server-derived -/// [`CachePolicy`](crate::core::CachePolicy) can override the caller's -/// per-query policy on success ("server wins"). -/// -/// The resource's policy is established at `begin_request` time from -/// [`QueryOptions`](crate::hook::QueryOptions). A fetcher returning -/// [`Fetched::with_policy`](crate::core::Fetched::with_policy) replaces that -/// stored policy right after `complete_success`, so later freshness checks use -/// the server's TTL; [`Fetched::new`](crate::core::Fetched::new) keeps the -/// caller's policy. +/// A fetcher returning [`Fetched::with_policy`](crate::core::Fetched::with_policy) +/// overrides the resource's stored policy right after success (server wins). pub fn use_query_with_policy<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -67,8 +46,6 @@ where use_query_impl(options.into(), fetcher, cx) } -/// Shared body of [`use_query`] and [`use_query_with_policy`], generic over -/// the fetcher output via [`FetchedLike`]. fn use_query_impl<T, E, C, F, Fut, Out>( options: crate::hook::QueryOptions, fetcher: F, @@ -92,11 +69,8 @@ where } = options; let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); - // Propagate the user's retry policy; the resource would otherwise keep - // its no-retries default. entity.update(cx, |r, _| r.set_retry_policy(retry_policy.clone())); - // Start fetch if resource is idle if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { let fetch_mode = if force_fetch { QueryFetchMode::Force @@ -119,8 +93,7 @@ where (entity, subscription) } -/// Like [`use_query`], but the fetcher receives no signal argument. Exists for -/// backward compatibility; prefer the signal-accepting [`use_query`]. +/// Signal-free fetcher variant; prefer the signal-accepting [`use_query`]. pub fn use_query_unsignalled<T, E, C, F, Fut>( key: QueryKey, cache_policy: crate::core::CachePolicy, @@ -137,7 +110,6 @@ where { let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); - // Start fetch if resource is idle if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) && let (Some(request_id), _signal) = begin_request_on_entity(&entity, cx, QueryFetchMode::Normal, Some(key)) @@ -153,10 +125,8 @@ where (entity, subscription) } -/// Build the entity and observation from a -/// [`QueryOptions`](crate::hook::QueryOptions) value. Only `key`, -/// `cache_policy`, and `request_policy` are consumed here; use [`use_query`] -/// to honor the rest. +/// Consumes only `key`, `cache_policy`, and `request_policy`; use +/// [`use_query`] to honor the rest. pub fn use_query_manual_opts<T, E, C>( options: impl Into<crate::hook::QueryOptions>, cx: &mut Context<C>, @@ -170,8 +140,6 @@ where use_query_manual(opts.key, opts.cache_policy, opts.request_policy, cx) } -/// Convenience wrapper around [`use_query_unsignalled`] that accepts an -/// `impl Into<QueryOptions>` instead of the raw policy triple. pub fn use_query_unsignalled_opts<T, E, C, F, Fut>( options: impl Into<crate::hook::QueryOptions>, fetcher: F, @@ -194,15 +162,7 @@ where ) } -/// Lower-level hook that sets up the entity and observation without starting -/// a fetch. Use this when you need full control over when and how fetching -/// happens. -/// -/// # Panics (debug builds only) -/// -/// In debug builds, panics if no [`QueryClient`] has been set via -/// `cx.set_global::<QueryClient>()`. Release builds fall back to a standalone -/// entity (no shared caching, no GC). +/// Entity + observer without starting a fetch; panics in debug builds when no [`QueryClient`] global is set (release falls back to a standalone entity). pub fn use_query_manual<T, E, C>( key: QueryKey, cache_policy: crate::core::CachePolicy, @@ -253,9 +213,8 @@ where (entity, subscription) } -/// Initiate a fetch on an existing query entity (e.g. on button click or -/// timer). Respects the resource's retry policy on failure. If the cache is -/// fresh or a fetch is already loading, no task is spawned. +/// No-op when the cache is fresh or a fetch is already loading; otherwise +/// spawns a retry-aware fetch. pub fn fetch_query<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -270,11 +229,8 @@ pub fn fetch_query<T, E, C, F, Fut>( fetch_query_impl(entity, fetcher, cx); } -/// Like [`fetch_query`], but the fetcher returns -/// [`Fetched<T>`](crate::core::Fetched) so a server-derived -/// [`CachePolicy`](crate::core::CachePolicy) can override the resource's -/// policy on success. See [`use_query_with_policy`] for the server-wins -/// semantics. +/// [`fetch_query`] whose fetcher may return [`Fetched<T>`](crate::core::Fetched) +/// to override the resource's policy on success. pub fn fetch_query_with_policy<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -289,7 +245,6 @@ pub fn fetch_query_with_policy<T, E, C, F, Fut>( fetch_query_impl(entity, fetcher, cx); } -/// Shared body of [`fetch_query`] and [`fetch_query_with_policy`]. fn fetch_query_impl<T, E, C, F, Fut, Out>( entity: &Entity<QueryResource<T, E>>, fetcher: F, @@ -315,12 +270,8 @@ fn fetch_query_impl<T, E, C, F, Fut, Out>( task.detach(); } -/// Like [`fetch_query`], but the fetcher receives a [`QuerySignal`] it can -/// check for cooperative cancellation. -/// -/// The fetcher is `FnOnce`, so no retries are possible. Stale writes are -/// prevented by the `accept_current_request` guard, not by a -/// `signal.is_cancelled()` check after the fetch (that would be racy). +/// `FnOnce` fetcher, so no retries; staleness is guarded by +/// `accept_current_request`, not a post-fetch `is_cancelled()` check (racy). pub fn fetch_query_with_signal<T, E, C, F, Fut>( entity: &Entity<QueryResource<T, E>>, fetcher: F, diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs index c2d6618..1c0df78 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs @@ -1,6 +1,4 @@ -//! Public fetch helpers for infinite query entities, called from event -//! handlers (scroll-to-bottom, button click) to fetch the next or previous -//! page. +//! Event-handler entrypoints for fetching the next or previous page. use gpui::{BorrowAppContext as _, Context, Entity}; @@ -12,11 +10,7 @@ use super::fetch_runners::{ }; use crate::hook::current_time_ms; -/// Initiate a fetch of the next page on an existing infinite query entity. -/// -/// Reads the last page from the entity and passes it to the fetcher. Applies -/// the retry policy stored on the entity; if a fetch is already in flight the -/// old signal is cancelled and the new request supersedes it. +/// If a fetch is already in flight, its signal is cancelled and the new request supersedes it. /// /// # Example /// @@ -47,11 +41,7 @@ pub fn fetch_next_page_infinite<T, E, C, FNext, Fut>( fetch_page_infinite(entity, fetcher, cx, PageDirection::Next); } -/// Initiate a fetch of the previous page on an existing infinite query entity. -/// -/// Like [`fetch_next_page_infinite`] but backward: the fetcher receives the -/// first page (not the last) so it can determine the cursor for the previous -/// page. +/// Backward variant: the fetcher receives the first page (not the last) as its cursor. pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: FPrev, @@ -66,8 +56,6 @@ pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( fetch_page_infinite(entity, fetcher, cx, PageDirection::Previous); } -/// Shared body of [`fetch_next_page_infinite`] / [`fetch_previous_page_infinite`]; -/// only `direction` differs (which `begin_fetch_*` call and which runner). fn fetch_page_infinite<T, E, C, F, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: F, @@ -82,8 +70,6 @@ fn fetch_page_infinite<T, E, C, F, Fut>( { let weak = entity.downgrade(); - // Bucket-sequenced RequestId so event-driven page fetches stay monotonic - // per key; without a QueryClient the resource mints a transient one. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { @@ -105,8 +91,7 @@ fn fetch_page_infinite<T, E, C, F, Fut>( if let Some(request_id) = request_id { let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Stored on the resource so a replacement fetch or unmount aborts the - // prior in-flight task. + // Stored on the resource: a replacement fetch or unmount aborts the prior task. let task: gpui::Task<()> = cx.spawn(async move |_this, cx| match direction { PageDirection::Next => { run_fetch_next_page_with_id(&weak, &fetcher, request_id, &retry_policy, cx).await; diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs index 44c2cf3..e4896a6 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs @@ -1,5 +1,4 @@ -//! Internal async fetch runners for infinite query page fetches: retry-aware, -//! running with a captured [`RequestId`] and two-phase completion. +//! Retry-aware page-fetch runners with two-phase completion. use std::sync::Arc; @@ -7,12 +6,8 @@ use crate::core::{InfiniteQueryResource, RequestId}; use crate::hook::{current_time_ms, read_entity}; -/// Direction of an infinite-query page fetch. -/// -/// The next/previous runners differ only in which page they read as the -/// cursor and which `is_next` flag they pass to -/// [`InfiniteQueryResource::complete_success_with_guard`]; this enum -/// parameterizes that difference so the body lives in one place. +/// The runners differ only in cursor page and the `is_next` flag passed to +/// [`InfiniteQueryResource::complete_success_with_guard`]; this enum carries that. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum PageDirection { Next, @@ -20,14 +15,11 @@ pub(super) enum PageDirection { } impl PageDirection { - /// The `is_next` flag handed to `complete_success_with_guard`. fn is_next(self) -> bool { matches!(self, PageDirection::Next) } - /// The page used as the fetcher cursor: the last page for `Next`, the - /// first page for `Previous`. Read via the refcount-bumped `Arc<T>` - /// accessor (no full page clone). + /// Last page for `Next`, first for `Previous`; the `Arc<T>` accessor is a refcount bump, no page clone. fn cursor_page_arc<T: Clone + Send + Sync + 'static, E>( self, resource: &InfiniteQueryResource<T, E>, @@ -39,13 +31,9 @@ impl PageDirection { } } -/// Execute a page fetch with a captured `RequestId` in the given direction. -/// -/// The `request_id` is the one returned from `begin_fetch_*`, not re-read -/// after the fetcher completes, and completion is two-phase -/// (`accept_current_request` then complete) so a superseded request can never -/// write. After each retry delay the signal and the active request are checked -/// in one read pass; a cancelled or superseded fetch stops retrying. +/// The `request_id` comes from `begin_fetch_*` (never re-read after the fetcher), +/// and completion is two-phase so a superseded request can never write; a +/// cancelled or superseded fetch stops retrying after the delay. async fn run_fetch_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, @@ -62,8 +50,6 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( let mut attempt: u32 = 0; loop { - // Re-read the cursor fresh each attempt so the fetcher sees - // up-to-date data; the Arc access is a cheap refcount bump. let cursor_page_arc: Option<Arc<T>> = { let Some(e) = entity.upgrade() else { return }; read_entity(&e, cx, |r, _| direction.cursor_page_arc(r)).flatten() @@ -104,8 +90,6 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( .await; } - // No notify during retry wait: status stays Loading and - // the InfiniteQueryObserver dedupes. let Some(e) = entity.upgrade() else { return }; let (cancelled, still_current) = read_entity(&e, cx, |r, _| { ( @@ -133,8 +117,6 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( } } -/// Execute a fetch-next-page operation with a captured `RequestId`. Thin -/// direction-specific wrapper around [`run_fetch_page_with_id`]. pub(super) async fn run_fetch_next_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, @@ -158,8 +140,6 @@ pub(super) async fn run_fetch_next_page_with_id<T, E, F, Fut>( .await; } -/// Execute a fetch-previous-page operation with a captured `RequestId`. Thin -/// direction-specific wrapper around [`run_fetch_page_with_id`]. pub(super) async fn run_fetch_previous_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, diff --git a/crates/gpui-query/src/hook/use_infinite_query/hook.rs b/crates/gpui-query/src/hook/use_infinite_query/hook.rs index c2e8207..eda8adb 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/hook.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/hook.rs @@ -1,5 +1,5 @@ -//! The `use_infinite_query` hook: infinite scrolling / pagination for GPUI -//! components. +//! The fetcher receives `Option<&T>` (the last page, if any) and returns +//! `(T, bool)`, where the bool says whether more pages exist. //! //! # Usage //! @@ -21,7 +21,6 @@ //! let (entity, _subscription) = use_infinite_query( //! InfiniteQueryOptions::new(QueryKey::from(["feed"])), //! |last_page| async move { -//! // Your async fetcher here //! Ok((vec![], false)) //! }, //! cx, @@ -33,7 +32,6 @@ //! fetch_next_page_infinite( //! &self.feed, //! |last_page| async move { -//! // Your async fetcher here //! Ok((vec![], false)) //! }, //! cx, @@ -51,17 +49,7 @@ use super::fetch_runners::run_fetch_next_page_with_id; use crate::hook::current_time_ms; use crate::hook::options::InfiniteQueryOptions; -/// Hook for infinite scrolling / pagination. -/// -/// Creates an [`InfiniteQueryResource`] entity (registered with -/// [`QueryClient`] for shared caching, GC, and bulk invalidation) and -/// subscribes via an observer that dedupes on status, so intermediate retry -/// updates do not re-render. Returns the entity and the subscription; store -/// both. The retry policy from options is stored on the entity and applied to -/// every page fetch. -/// -/// The fetcher receives `Option<&T>` (the last page, if any) and returns -/// `Result<(T, bool), E>` where the bool says whether more pages exist. +/// The observer dedupes on status, so retry ticks do not re-render; the options' retry policy applies to every page fetch. pub fn use_infinite_query<T, E, C, FNext, Fut>( options: InfiniteQueryOptions, fetch_next: FNext, @@ -96,13 +84,9 @@ where Call cx.set_global(QueryClient::new()) in your app setup." ); } - // max_pages and retry_policy are applied unconditionally below for - // both paths. cx.new(|_| InfiniteQueryResource::new(key, cache_policy, request_policy)) }; - // QueryClient-created entities don't set max_pages; apply it and store - // the retry policy in one pass. entity.update(cx, |resource, cx| { if let Some(max) = max_pages { resource.set_max_pages(Some(max)); @@ -125,10 +109,8 @@ where } }; - // Start the initial fetch if idle if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { - // The initial fetch also mints from the bucket's sequencer, so later - // fetch_next/previous_page ids continue the same sequence. + // The initial fetch mints from the bucket's sequencer too, so later page-fetch ids continue the same sequence. let maybe_request_id = if cx.has_global::<QueryClient>() { let key = entity.read_with(cx, |r, _| r.key().clone()); cx.update_global::<QueryClient, _>(|client, _| { diff --git a/crates/gpui-query/src/hook/use_infinite_query/mod.rs b/crates/gpui-query/src/hook/use_infinite_query/mod.rs index 5867f23..66e6e40 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/mod.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/mod.rs @@ -1,11 +1,4 @@ -//! The `use_infinite_query` hook — ergonomic infinite scrolling / pagination -//! for GPUI components. -//! -//! This module is split into three focused submodules: -//! -//! - [`hook`] — the main `use_infinite_query` hook function -//! - [`fetch_helpers`] — public `fetch_next_page_infinite` and `fetch_previous_page_infinite` -//! - [`fetch_runners`] — internal async fetch runners with retry logic +//! `use_infinite_query` plus the public `fetch_*_page_infinite` helpers. mod fetch_helpers; mod fetch_runners; diff --git a/crates/gpui-query/src/hook/use_query_select.rs b/crates/gpui-query/src/hook/use_query_select.rs index e2d0505..055bcc1 100644 --- a/crates/gpui-query/src/hook/use_query_select.rs +++ b/crates/gpui-query/src/hook/use_query_select.rs @@ -1,11 +1,5 @@ -//! The `use_query_select` hook: combines `use_query` with a -//! [`SelectTransform`]. -//! -//! Mirrors TanStack Query's `select` option: cached data is projected into a -//! derived shape, re-running only when the data changes. Rust needs `U` (the -//! transform output) at compile time and `QueryOptions` is not generic, so the -//! transform is a separate parameter and the hook returns a -//! `MappedQueryResource<T, U, E>` entity. +//! Cached data projected into a derived shape, re-running only when the data +//! changes; the hook returns a `MappedQueryResource<T, U, E>`. //! //! # Usage //! @@ -29,7 +23,6 @@ //! QueryOptions::new("users"), //! count_transform, //! |signal| async move { -//! // Your async fetcher here //! Ok(vec![]) //! }, //! cx, @@ -47,22 +40,14 @@ use crate::core::{MappedQueryResource, QueryResource, SelectTransform}; use super::{QueryOptions, use_query}; -/// The result of [`use_query_select`]: the projected view entity, the -/// underlying query entity, and the subscriptions that keep both observations -/// alive. +/// (mapped entity, source query entity, both subscriptions). pub type QuerySelectResult<T, U, E> = ( Entity<MappedQueryResource<T, U, E>>, Entity<QueryResource<T, E>>, (Subscription, Subscription), ); -/// Subscribe to a query and project its data through a [`SelectTransform`]. -/// -/// Creates the underlying query via [`use_query`], seeds a -/// `MappedQueryResource<T, U, E>` with the current data, and observes the -/// source entity so the mapped resource refreshes whenever the data actually -/// changes. The transform itself runs lazily on every `mapped.data()` call -/// (no output cache), so reuse the result if it is expensive: +/// The transform runs lazily on every `mapped.data()` call (no output cache), so reuse the result if it is expensive: /// /// ```no_run /// use gpui_query::core::{MappedQueryResource, SelectTransform}; @@ -86,18 +71,11 @@ where { let (query_entity, query_subscription) = use_query(options, fetcher, cx); - // Seed the mapped resource with whatever data the query has now. The - // source owns `T` by value and only lends `&T`, so this is the one - // unavoidable clone. let initial_data: Option<Arc<T>> = query_entity.read_with(cx, |r, _| r.data().map(|d| Arc::new(d.clone()))); let mapped = MappedQueryResource::new(initial_data, transform); let mapped_entity = cx.new(|_| mapped); - // Keep the mapped resource in sync. On each notification, bump the cached - // `Arc<T>` out (cheap), compare `&T` vs `&T` without cloning, and only - // clone + update + notify when the content actually changed. The borrow on - // `mapped` ends before the source read, so nothing nests. let mapped_weak = mapped_entity.downgrade(); let mapped_subscription = cx.observe(&query_entity, move |_, entity, cx| { if let Some(mapped) = mapped_weak.upgrade() { diff --git a/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs b/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs index 9aa4bc6..ba62e34 100644 --- a/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs @@ -1,6 +1,3 @@ -//! Tests for `use_infinite_query`, `fetch_next_page_infinite`, and -//! `fetch_previous_page_infinite`. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -11,8 +8,6 @@ use crate::core::{ use crate::hook::*; use crate::tests::test_support::*; -// ── use_infinite_query ───────────────────────────────────────────────────── - #[gpui::test] fn test_use_infinite_query_creates_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -89,7 +84,6 @@ fn test_fetch_next_page_appends_page(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).pages().len(), 1); }); - // Fetch the next page. harness.update(cx, |this, cx| { fetch_next_page_infinite( &this.entity, @@ -109,8 +103,6 @@ fn test_fetch_next_page_appends_page(cx: &mut TestAppContext) { }); } -// ── use_infinite_query: fetch_next_page while already fetching ────────────── - #[gpui::test] fn test_fetch_next_page_while_fetching(cx: &mut TestAppContext) { setup_query_client(cx); @@ -129,14 +121,12 @@ fn test_fetch_next_page_while_fetching(cx: &mut TestAppContext) { H { entity } }); - // Wait for first page to load. cx.run_until_parked(); cx.update(|cx| { assert_eq!(harness.read(cx).entity.read(cx).pages().len(), 1); }); - // Start a fetch_next_page. This should trigger a loading state. harness.update(cx, |this, cx| { fetch_next_page_infinite( &this.entity, @@ -158,8 +148,6 @@ fn test_fetch_next_page_while_fetching(cx: &mut TestAppContext) { }); } -// ── use_infinite_query: fetch_previous_page ───────────────────────────────── - #[gpui::test] fn test_fetch_previous_page_prepends_page(cx: &mut TestAppContext) { setup_query_client(cx); @@ -185,7 +173,6 @@ fn test_fetch_previous_page_prepends_page(cx: &mut TestAppContext) { assert_eq!(resource.pages()[0].as_ref(), &vec![5]); }); - // Enable previous page flag so fetch_previous_page_infinite can proceed. let entity = cx.update(|cx| harness.read(cx).entity.clone()); cx.update(|cx| { entity.update(cx, |r, _| { @@ -193,7 +180,6 @@ fn test_fetch_previous_page_prepends_page(cx: &mut TestAppContext) { }); }); - // Fetch a previous page — it should be prepended. harness.update(cx, |this, cx| { fetch_previous_page_infinite( &this.entity, @@ -220,8 +206,6 @@ fn test_fetch_previous_page_prepends_page(cx: &mut TestAppContext) { }); } -// ── use_infinite_query: max_pages enforcement through hook ────────────────── - #[gpui::test] fn test_infinite_query_max_pages_enforcement(cx: &mut TestAppContext) { setup_query_client(cx); @@ -243,12 +227,10 @@ fn test_infinite_query_max_pages_enforcement(cx: &mut TestAppContext) { cx.run_until_parked(); - // First page loaded. cx.update(|cx| { assert_eq!(harness.read(cx).entity.read(cx).pages().len(), 1); }); - // Fetch page 2. harness.update(cx, |this, cx| { fetch_next_page_infinite( &this.entity, @@ -263,7 +245,6 @@ fn test_infinite_query_max_pages_enforcement(cx: &mut TestAppContext) { assert_eq!(pages.len(), 2); }); - // Fetch page 3 — max_pages is 2, so page 1 should be evicted. harness.update(cx, |this, cx| { fetch_next_page_infinite( &this.entity, @@ -286,8 +267,6 @@ fn test_infinite_query_max_pages_enforcement(cx: &mut TestAppContext) { }); } -// ── fetch_next_page_infinite: direct call on existing entity ──────────────── - #[gpui::test] fn test_fetch_next_page_infinite_direct_call(cx: &mut TestAppContext) { setup_query_client(cx); @@ -296,8 +275,6 @@ fn test_fetch_next_page_infinite_direct_call(cx: &mut TestAppContext) { entity: Entity<InfiniteQueryResource<Vec<&'static str>, QueryError>>, } - // Create entity via use_infinite_query, wait for first page, then call - // fetch_next_page_infinite directly. let harness = cx.new(|cx| { let (entity, _sub) = use_infinite_query( InfiniteQueryOptions::new("direct-next").cache_policy(CachePolicy::Ttl { ttl_ms: 0 }), @@ -327,8 +304,6 @@ fn test_fetch_next_page_infinite_direct_call(cx: &mut TestAppContext) { }); } -// ── fetch_previous_page_infinite: direct call ────────────────────────────── - #[gpui::test] fn test_fetch_previous_page_infinite_direct_call(cx: &mut TestAppContext) { setup_query_client(cx); @@ -348,7 +323,6 @@ fn test_fetch_previous_page_infinite_direct_call(cx: &mut TestAppContext) { cx.run_until_parked(); - // Enable previous page flag so fetch_previous_page_infinite can proceed. let entity = cx.update(|cx| harness.read(cx).entity.clone()); cx.update(|cx| { entity.update(cx, |r, _| { @@ -378,8 +352,6 @@ fn test_fetch_previous_page_infinite_direct_call(cx: &mut TestAppContext) { }); } -// ── use_infinite_query: error handling on first page ──────────────────────── - #[gpui::test] fn test_infinite_query_first_page_failure(cx: &mut TestAppContext) { setup_query_client(cx); @@ -410,8 +382,6 @@ fn test_infinite_query_first_page_failure(cx: &mut TestAppContext) { }); } -// ── use_infinite_query: retry on failure ──────────────────────────────────── - #[gpui::test] fn test_infinite_query_retry_on_failure(cx: &mut TestAppContext) { setup_query_client(cx); @@ -464,8 +434,6 @@ fn test_infinite_query_retry_on_failure(cx: &mut TestAppContext) { ); } -// ── use_infinite_query: multiple pages appended sequentially ──────────────── - #[gpui::test] fn test_infinite_query_sequential_pages(cx: &mut TestAppContext) { setup_query_client(cx); @@ -485,7 +453,6 @@ fn test_infinite_query_sequential_pages(cx: &mut TestAppContext) { cx.run_until_parked(); - // Fetch 3 more pages sequentially. for page_num in 2..=4 { let harness_ref = &harness; harness_ref.update(cx, |this, cx| { diff --git a/crates/gpui-query/src/tests/hook_tests/mod.rs b/crates/gpui-query/src/tests/hook_tests/mod.rs index 141d7e8..88335c2 100644 --- a/crates/gpui-query/src/tests/hook_tests/mod.rs +++ b/crates/gpui-query/src/tests/hook_tests/mod.rs @@ -1,17 +1,3 @@ -//! Hook-layer integration tests for gpui-query. -//! -//! Tests use `#[gpui::test]` with `TestAppContext` to exercise the full -//! hook pipeline: entity creation, observation subscription, fetch spawning, -//! completion, and lifecycle management. -//! -//! # Context pattern -//! -//! Hook functions require `&mut Context<C>` (a component-typed context), not -//! `&mut App`. We create harness entities via `cx.new(|cx| ...)` which provides -//! `Context<Harness>`. For post-creation hook calls (e.g. `fetch_query`, `mutate`), -//! we use `harness.update(cx, |_, cx| ...)`. Harness structs store entity handles -//! so they can be inspected after async work completes. - mod infinite_query_tests; mod mutation_tests; mod query_tests; diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs index a5d6935..1f00c49 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs @@ -1,5 +1,3 @@ -//! Basic mutation tests: creation, mutate, failure, client registration, concurrent guard. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -99,7 +97,6 @@ fn test_mutate_rejects_concurrent_calls(cx: &mut TestAppContext) { let harness = cx.new(|cx| { let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); - // Start the first mutation. mutate( &entity, "first".to_string(), @@ -108,7 +105,6 @@ fn test_mutate_rejects_concurrent_calls(cx: &mut TestAppContext) { ); assert!(entity.read(cx).is_loading()); - // A second mutate while the first is still loading is a no-op. mutate( &entity, "second".to_string(), @@ -162,7 +158,6 @@ fn test_mutate_double_while_loading_second_rejected(cx: &mut TestAppContext) { let harness = cx.new(|cx| { let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); - // First mutate. mutate( &entity, "first".to_string(), @@ -176,7 +171,6 @@ fn test_mutate_double_while_loading_second_rejected(cx: &mut TestAppContext) { cx, ); - // Second mutate while still loading: rejected. mutate( &entity, "second".to_string(), diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs index e7bc22d..ec60f23 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs @@ -1,5 +1,3 @@ -//! Tests for `mutate_with_callbacks` success, failure, and settled callback behavior. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -237,7 +235,6 @@ fn test_mutate_callbacks_settled_always_fires_on_success(cx: &mut TestAppContext let _harness = cx.new(|cx| { let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); - // Only set on_settled, not on_success or on_error. mutate_with_callbacks( &entity, "settled-only".to_string(), diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/mod.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/mod.rs index dd88864..29d1cc4 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/mod.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/mod.rs @@ -1,5 +1,3 @@ -//! Mutation hook tests split into focused sub-modules. - mod basic_tests; mod callback_tests; mod retry_reset_tests; diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/retry_reset_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/retry_reset_tests.rs index 8a19233..eb1bbbe 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/retry_reset_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/retry_reset_tests.rs @@ -1,5 +1,3 @@ -//! Tests for mutation retry behavior, reset, custom retry policy, and concurrent callback rejection. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -90,7 +88,6 @@ fn test_mutation_reset_clears_state(cx: &mut TestAppContext) { assert_eq!(resource.data(), Some(&"reset-result".to_string())); }); - // Reset the mutation in a separate update to avoid borrow conflict. let mutation = cx.update(|cx| harness.read(cx).mutation.clone()); cx.update(|cx| { mutation.update(cx, |m, _| { @@ -139,9 +136,6 @@ fn test_mutate_with_callbacks_rejects_concurrent(cx: &mut TestAppContext) { let settled_count = Arc::new(Mutex::new(0u32)); let sc = settled_count.clone(); - // Gate: the first mutation blocks until the test releases it after issuing - // the second concurrent mutate_with_callbacks call. Uses the shared `Gate` - // helper which polls the executor with 1ms timers instead of thread::sleep. let gate = Gate::new(); let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); @@ -162,8 +156,6 @@ fn test_mutate_with_callbacks_rejects_concurrent(cx: &mut TestAppContext) { let gate_clone = gate_clone.clone(); let executor = executor.clone(); async move { - // Wait for the gate via the shared helper. This allows - // the second mutation call to be scheduled while we wait. gate_clone.wait(&executor).await; Ok::<_, QueryError>("first-result".to_string()) } @@ -174,7 +166,6 @@ fn test_mutate_with_callbacks_rejects_concurrent(cx: &mut TestAppContext) { cx, ); - // Second concurrent call should be rejected. mutate_with_callbacks( &entity, "second".to_string(), @@ -186,7 +177,6 @@ fn test_mutate_with_callbacks_rejects_concurrent(cx: &mut TestAppContext) { H { mutation: entity } }); - // Release the gate so the first mutation can complete. gate.release(); cx.run_until_parked(); diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs index 97d87aa..226e9a4 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/advanced_hooks.rs @@ -1,5 +1,3 @@ -//! Tests for cache hit behavior, signal cancellation, and signal availability. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -8,8 +6,6 @@ use crate::core::{CachePolicy, QueryError, QueryKey, QueryResource, QueryStatus, use crate::hook::*; use crate::tests::test_support::*; -// ── use_query: key change triggers new fetch ──────────────────────────────── - #[gpui::test] fn test_use_query_same_key_returns_cached_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -30,7 +26,6 @@ fn test_use_query_same_key_returns_cached_entity(cx: &mut TestAppContext) { |_signal| async move { Ok::<_, QueryError>(20) }, cx, ); - // Same key via QueryClient returns the same entity. assert_eq!( entity_a.entity_id(), entity_b.entity_id(), @@ -49,8 +44,6 @@ fn test_use_query_same_key_returns_cached_entity(cx: &mut TestAppContext) { }); } -// ── use_query: cache hit skips fetch ──────────────────────────────────────── - #[gpui::test] fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -62,7 +55,6 @@ fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { entity: Entity<QueryResource<&'static str, QueryError>>, } - // First call: populate cache with a long TTL. let harness = cx.new(|cx| { let (entity, _sub) = use_query( QueryOptions::new("cached-key").cache_policy(CachePolicy::Ttl { ttl_ms: 60_000 }), @@ -95,12 +87,8 @@ fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { "first fetch should have occurred" ); - // Drain any pending executor work so the cache is fully settled. cx.run_until_parked(); - // Explicitly assert the precondition: the first entity must be in Success - // state before we create the second harness. This guards against flakiness - // if cx.run_until_parked() ever changes its parking behavior. cx.update(|cx| { assert_eq!( harness.read(cx).entity.read(cx).status(), @@ -109,9 +97,6 @@ fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { ); }); - // Second use_query with the same key and fresh cache: should be a cache hit. - // Assert fetch_count is still 1 *before* creating the second harness so any - // regression that triggers an extra fetch is caught deterministically. assert_eq!( *fetch_count.lock().unwrap(), 1, @@ -131,13 +116,11 @@ fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { }, cx, ); - // Entity should be the same cached one. assert_eq!( entity.entity_id(), harness.read(cx).entity.entity_id(), "should return the same cached entity" ); - // The second entity should NOT be in a loading state — it received cached data. let status = entity.read(cx).status(); assert!( !matches!(status, QueryStatus::LoadingEmpty), @@ -163,14 +146,10 @@ fn test_use_query_cache_hit_does_not_refetch(cx: &mut TestAppContext) { ); } -// ── use_query: force_fetch option causes fetch even on Success entity ────── - #[gpui::test] fn test_use_query_force_fetch_option_set(cx: &mut TestAppContext) { setup_query_client(cx); - // Verify that QueryOptions::force() sets the flag correctly and that - // a fresh use_query with force() still fetches normally. let opts = QueryOptions::new("force-opt").force(); assert!(opts.force_fetch, "force() should set force_fetch to true"); @@ -198,15 +177,10 @@ fn test_use_query_force_fetch_option_set(cx: &mut TestAppContext) { }); } -// ── use_query: signal cancelled on replacement ──────────────────────────── - #[gpui::test] fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { setup_test(cx); - // When a second fetch replaces an in-flight fetch, the first fetcher's - // signal is cancelled. use_query_manual + fetch_query because use_query - // only auto-fetches when Idle. let gate = Gate::new(); let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); @@ -229,7 +203,6 @@ fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { cx, ); - // First fetch: blocks on the gate until we release it. let executor1 = executor.clone(); fetch_query( &entity, @@ -238,11 +211,8 @@ fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { let gate_clone = gate_clone.clone(); let executor = executor1.clone(); async move { - // Record cancellation state when this fetcher first runs. *fc1.lock().unwrap() = Some(false); - // Wait for the gate via the shared helper. gate_clone.wait(&executor).await; - // Record final cancellation state — should be cancelled now. *fc1.lock().unwrap() = Some(true); Ok::<_, QueryError>("first-data") } @@ -253,8 +223,6 @@ fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { H { entity } }); - // Now issue a second fetch (replacement) via fetch_query — this triggers - // begin_request which cancels the first signal (LatestWins). harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -269,25 +237,21 @@ fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { ); }); - // Release the gate so the first fetcher can observe the cancellation. gate.release(); cx.run_until_parked(); - // Verify the first fetcher's initial state was recorded. assert_eq!( *first_cancelled.lock().unwrap(), Some(true), "first fetcher's signal should be cancelled after replacement fetch" ); - // The second fetcher should not be cancelled. assert_eq!( *second_cancelled.lock().unwrap(), Some(false), "replacement fetcher's signal should not be cancelled" ); - // The entity should have the second fetch's data. cx.update(|cx| { assert_eq!( harness.read(cx).entity.read(cx).data(), @@ -296,8 +260,6 @@ fn test_use_query_signal_cancelled_on_replacement(cx: &mut TestAppContext) { }); } -// ── use_query: signal checked during fetch ────────────────────────────────── - #[gpui::test] fn test_use_query_signal_available_during_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -316,7 +278,6 @@ fn test_use_query_signal_available_during_fetch(cx: &mut TestAppContext) { let sw = sw.clone(); async move { *sw.lock().unwrap() = true; - // Signal should be a valid, non-cancelled signal. assert!(!signal.is_cancelled(), "signal should not be cancelled"); Ok::<_, QueryError>("ok") } diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs index e2a9fd8..f23f54c 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs @@ -1,6 +1,3 @@ -//! Basic tests for `use_query`, `use_query_manual`, `fetch_query`, -//! `use_query_unsignalled`, subscriptions, key changes, and retry policy. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -11,8 +8,6 @@ use crate::core::{ use crate::hook::*; use crate::tests::test_support::*; -// ── use_query ────────────────────────────────────────────────────────────── - #[gpui::test] fn test_use_query_auto_fetches(cx: &mut TestAppContext) { setup_query_client(cx); @@ -28,7 +23,6 @@ fn test_use_query_auto_fetches(cx: &mut TestAppContext) { |_signal| async move { Ok::<_, QueryError>("data") }, cx, ); - // Immediately after use_query, the resource should be loading. let status = entity.read(cx).status(); assert!( status.is_loading(), @@ -122,8 +116,6 @@ fn test_use_query_completes_with_failure(cx: &mut TestAppContext) { }); } -// ── use_query_with_signal ────────────────────────────────────────────────── - #[gpui::test] fn test_use_query_signal_not_cancelled_on_normal_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -164,8 +156,6 @@ fn test_use_query_signal_not_cancelled_on_normal_fetch(cx: &mut TestAppContext) ); } -// ── use_query_manual ─────────────────────────────────────────────────────── - #[gpui::test] fn test_use_query_manual_creates_entity_without_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -193,8 +183,6 @@ fn test_use_query_manual_creates_entity_without_fetch(cx: &mut TestAppContext) { }); } -// ── fetch_query ──────────────────────────────────────────────────────────── - #[gpui::test] fn test_fetch_query_triggers_refetch_on_existing_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -251,7 +239,6 @@ fn test_fetch_query_can_refetch_after_success(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"first")); }); - // Refetch with different data. NoCache ensures begin_request won't short-circuit. harness.update(cx, |this, cx| { fetch_query(&this.entity, || async { Ok::<_, QueryError>("second") }, cx); }); @@ -263,8 +250,6 @@ fn test_fetch_query_can_refetch_after_success(cx: &mut TestAppContext) { }); } -// ── Subscription lifecycle ───────────────────────────────────────────────── - #[gpui::test] fn test_subscription_drops_gracefully(cx: &mut TestAppContext) { setup_query_client(cx); @@ -281,7 +266,6 @@ fn test_subscription_drops_gracefully(cx: &mut TestAppContext) { cx, ); assert_eq!(entity.read(cx).status(), QueryStatus::Idle); - // Drop the subscription inside the context. Entity should remain valid. drop(sub); assert_eq!(entity.read(cx).status(), QueryStatus::Idle); H { entity } @@ -314,10 +298,8 @@ fn test_multiple_subscriptions_same_key(cx: &mut TestAppContext) { cx, ); - // Same key = same entity from QueryClient. assert_eq!(entity1.entity_id(), entity2.entity_id()); - // Both subscriptions should be droppable without issues. drop(sub1); drop(sub2); @@ -332,8 +314,6 @@ fn test_multiple_subscriptions_same_key(cx: &mut TestAppContext) { }); } -// ── Key change triggers new fetch ────────────────────────────────────────── - #[gpui::test] fn test_different_keys_create_distinct_entities(cx: &mut TestAppContext) { setup_query_client(cx); @@ -367,8 +347,6 @@ fn test_different_keys_create_distinct_entities(cx: &mut TestAppContext) { }); } -// ── Retry policy propagation ─────────────────────────────────────────────── - #[gpui::test] fn test_use_query_propagates_retry_policy_to_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -394,8 +372,6 @@ fn test_use_query_propagates_retry_policy_to_entity(cx: &mut TestAppContext) { let _ = harness; } -// ── use_query_unsignalled ────────────────────────────────────────────────── - #[gpui::test] fn test_use_query_unsignalled_auto_fetches(cx: &mut TestAppContext) { setup_query_client(cx); @@ -424,8 +400,6 @@ fn test_use_query_unsignalled_auto_fetches(cx: &mut TestAppContext) { }); } -// ── use_query force_fetch ────────────────────────────────────────────────── - #[gpui::test] fn test_fetch_query_refetch_after_success(cx: &mut TestAppContext) { setup_query_client(cx); @@ -449,7 +423,6 @@ fn test_fetch_query_refetch_after_success(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&1)); }); - // Refetch with different data. harness.update(cx, |this, cx| { fetch_query(&this.entity, || async { Ok::<_, QueryError>(2_i32) }, cx); }); diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs index 0089fb7..072a57d 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs @@ -1,5 +1,3 @@ -//! Tests for `use_query_manual`, `fetch_query`, and `fetch_query_with_signal`. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -8,8 +6,6 @@ use crate::core::{CachePolicy, QueryError, QueryKey, QueryResource, QueryStatus, use crate::hook::*; use crate::tests::test_support::*; -// ── use_query_manual: entity exists but no auto-fetch ─────────────────────── - #[gpui::test] fn test_use_query_manual_no_auto_fetch_then_manual_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -25,13 +21,11 @@ fn test_use_query_manual_no_auto_fetch_then_manual_fetch(cx: &mut TestAppContext RequestPolicy::LatestWins, cx, ); - // No auto-fetch: resource stays idle. assert_eq!(entity.read(cx).status(), QueryStatus::Idle); assert!(entity.read(cx).data().is_none()); H { entity } }); - // Still idle after parking — no fetch was spawned. cx.run_until_parked(); cx.update(|cx| { @@ -42,7 +36,6 @@ fn test_use_query_manual_no_auto_fetch_then_manual_fetch(cx: &mut TestAppContext ); }); - // Now manually fetch. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -60,8 +53,6 @@ fn test_use_query_manual_no_auto_fetch_then_manual_fetch(cx: &mut TestAppContext }); } -// ── use_query_manual: entity can be fetched multiple times manually ────────── - #[gpui::test] fn test_use_query_manual_multiple_fetches(cx: &mut TestAppContext) { setup_query_client(cx); @@ -84,7 +75,6 @@ fn test_use_query_manual_multiple_fetches(cx: &mut TestAppContext) { H { entity } }); - // First manual fetch. let cc_first = cc1.clone(); harness.update(cx, |this, cx| { fetch_query( @@ -106,7 +96,6 @@ fn test_use_query_manual_multiple_fetches(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&1)); }); - // Second manual fetch. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -133,8 +122,6 @@ fn test_use_query_manual_multiple_fetches(cx: &mut TestAppContext) { ); } -// ── fetch_query: on non-existent (fresh) key ──────────────────────────────── - #[gpui::test] fn test_fetch_query_on_idle_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -169,8 +156,6 @@ fn test_fetch_query_on_idle_entity(cx: &mut TestAppContext) { }); } -// ── fetch_query: on cancelled resource ────────────────────────────────────── - #[gpui::test] fn test_fetch_query_after_resource_reset(cx: &mut TestAppContext) { setup_query_client(cx); @@ -193,7 +178,6 @@ fn test_fetch_query_after_resource_reset(cx: &mut TestAppContext) { cx.update(|cx| { assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"initial")); }); - // Reset the resource to idle in a separate update to avoid borrow conflict. let entity = cx.update(|cx| harness.read(cx).entity.clone()); cx.update(|cx| { entity.update(cx, |r, _| { @@ -205,7 +189,6 @@ fn test_fetch_query_after_resource_reset(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).status(), QueryStatus::Idle); }); - // Fetch again after reset. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -223,8 +206,6 @@ fn test_fetch_query_after_resource_reset(cx: &mut TestAppContext) { }); } -// ── fetch_query: concurrent calls ─────────────────────────────────────────── - #[gpui::test] fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { setup_test(cx); @@ -233,9 +214,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { entity: Entity<QueryResource<&'static str, QueryError>>, } - // Gate: the first fetcher blocks until the test releases it after the second - // fetch_query is issued. Uses the shared `Gate` helper which polls the - // executor with 1ms timers instead of thread::sleep. let gate = Gate::new(); let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); @@ -247,7 +225,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { RequestPolicy::LatestWins, cx, ); - // Fire two fetches. LatestWins means the second cancels the first. let executor = executor.clone(); fetch_query( &entity, @@ -255,8 +232,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { let gate_clone = gate_clone.clone(); let executor = executor.clone(); async move { - // Wait for the gate using the shared helper. This allows - // the second fetch_query to be scheduled while we wait. gate_clone.wait(&executor).await; Ok::<_, QueryError>("first") } @@ -267,9 +242,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { H { entity } }); - // Release the gate so the first fetcher can proceed — but by now the second - // fetch_query has already been issued with LatestWins, so the first will be - // cancelled/replaced. gate.release(); cx.run_until_parked(); @@ -277,7 +249,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!(resource.status(), QueryStatus::Success); - // LatestWins: the last fetch_query wins. assert_eq!( resource.data(), Some(&"second"), @@ -286,8 +257,6 @@ fn test_fetch_query_concurrent_calls_latest_wins(cx: &mut TestAppContext) { }); } -// ── fetch_query_with_signal: basic success ────────────────────────────────── - #[gpui::test] fn test_fetch_query_with_signal_completes(cx: &mut TestAppContext) { setup_query_client(cx); @@ -320,8 +289,6 @@ fn test_fetch_query_with_signal_completes(cx: &mut TestAppContext) { }); } -// ── fetch_query_with_signal: failure handled ──────────────────────────────── - #[gpui::test] fn test_fetch_query_with_signal_failure(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs index 94d601a..91fc6f5 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs @@ -1,5 +1,3 @@ -//! Tests for subscription lifecycle and request policies. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -8,8 +6,6 @@ use crate::core::{CachePolicy, QueryError, QueryKey, QueryResource, QueryStatus, use crate::hook::*; use crate::tests::test_support::*; -// ── Subscription lifecycle: dropping subscription stops observation ────────── - #[gpui::test] fn test_dropping_subscription_stops_observation(cx: &mut TestAppContext) { setup_query_client(cx); @@ -19,9 +15,6 @@ fn test_dropping_subscription_stops_observation(cx: &mut TestAppContext) { _sub: gpui::Subscription, } - // We keep the subscription alive this time, and verify that the entity - // can still receive updates. Then we drop it and verify the entity still - // works (just without observation). let harness = cx.new(|cx| { let (entity, sub) = use_query_manual::<&'static str, QueryError, _>( QueryKey::from("drop-obs"), @@ -32,7 +25,6 @@ fn test_dropping_subscription_stops_observation(cx: &mut TestAppContext) { H { entity, _sub: sub } }); - // Fetch data — entity should update. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -48,8 +40,6 @@ fn test_dropping_subscription_stops_observation(cx: &mut TestAppContext) { }); } -// ── Subscription lifecycle: multiple subscriptions on same entity ──────────── - #[gpui::test] fn test_multiple_observations_same_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -67,7 +57,6 @@ fn test_multiple_observations_same_entity(cx: &mut TestAppContext) { RequestPolicy::LatestWins, cx, ); - // Create a second observation on the same entity. let observer2 = crate::client::QueryObserver::new(&entity); let sub2 = observer2 .observe(cx) @@ -79,7 +68,6 @@ fn test_multiple_observations_same_entity(cx: &mut TestAppContext) { } }); - // Fetch data — both observations should be active. harness.update(cx, |this, cx| { fetch_query(&this.entity, || async { Ok::<_, QueryError>(42_u32) }, cx); }); @@ -91,8 +79,6 @@ fn test_multiple_observations_same_entity(cx: &mut TestAppContext) { }); } -// ── use_query: IgnoreWhileLoading request policy ──────────────────────────── - #[gpui::test] fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { setup_test(cx); @@ -101,9 +87,6 @@ fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { let fc1 = fetch_count.clone(); let fc2 = fetch_count.clone(); - // Gate: the first fetcher blocks until the test releases it after issuing - // the second fetch_query. Uses the shared `Gate` helper which polls the - // executor with 1ms timers instead of thread::sleep. let gate = Gate::new(); let gate_clone = gate.clone(); let executor = cx.background_executor.clone(); @@ -123,8 +106,6 @@ fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { let executor = executor.clone(); async move { *fc1.lock().unwrap() += 1; - // Wait for the gate via the shared helper. This allows - // the second fetch_query to be scheduled while we wait. gate_clone.wait(&executor).await; Ok::<_, QueryError>("first-fetch") } @@ -134,7 +115,6 @@ fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { H { entity } }); - // While the first fetch is still in progress (gate held), try fetch_query. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -149,7 +129,6 @@ fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { ); }); - // Release the gate so the first fetcher can proceed. gate.release(); cx.run_until_parked(); @@ -157,7 +136,6 @@ fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!(resource.status(), QueryStatus::Success); - // The first fetch should win. The second was ignored. assert_eq!(resource.data(), Some(&"first-fetch")); }); assert_eq!( diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/mod.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/mod.rs index 07020d6..7f46ce8 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/mod.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/mod.rs @@ -1,5 +1,2 @@ -//! Tests for manual fetch flows, `fetch_query_with_signal`, concurrent calls, -//! subscription lifecycle, and request policies. - mod fetch; mod lifecycle; diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/mod.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/mod.rs index 15eea0f..f428185 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/mod.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/mod.rs @@ -1,6 +1,3 @@ -//! Tests for `use_query`, `use_query_manual`, `fetch_query`, -//! `fetch_query_with_signal`, `use_query_unsignalled`, and `use_query_select`. - mod advanced_hooks; mod basic_hooks; mod fetch_and_lifecycle; diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/retry_tests.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/retry_tests.rs index b1f9603..a256486 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/retry_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/retry_tests.rs @@ -1,6 +1,3 @@ -//! Tests for retry with backoff, retry exhaustion, refetch after failure, -//! and cache policy behavior. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -9,8 +6,6 @@ use crate::core::{CachePolicy, QueryError, QueryResource, QueryStatus, RetryPoli use crate::hook::*; use crate::tests::test_support::*; -// ── use_query: with exponential backoff retry ─────────────────────────────── - #[gpui::test] fn test_use_query_retries_with_backoff(cx: &mut TestAppContext) { setup_query_client(cx); @@ -58,8 +53,6 @@ fn test_use_query_retries_with_backoff(cx: &mut TestAppContext) { ); } -// ── use_query: retry exhaustion ends in failure ───────────────────────────── - #[gpui::test] fn test_use_query_retry_exhaustion(cx: &mut TestAppContext) { setup_query_client(cx); @@ -100,12 +93,9 @@ fn test_use_query_retry_exhaustion(cx: &mut TestAppContext) { let err = resource.error().expect("should have error"); assert!(err.to_string().contains("always-fail")); }); - // 1 initial + 2 retries = 3 total calls. assert_eq!(*call_count.lock().unwrap(), 3); } -// ── use_query: entity remains usable after failed fetch ───────────────────── - #[gpui::test] fn test_use_query_refetch_after_failure(cx: &mut TestAppContext) { setup_query_client(cx); @@ -147,10 +137,8 @@ fn test_use_query_refetch_after_failure(cx: &mut TestAppContext) { ); }); - // Allow the next fetch to succeed. *should_fail.lock().unwrap() = false; - // Refetch. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -177,8 +165,6 @@ fn test_use_query_refetch_after_failure(cx: &mut TestAppContext) { }); } -// ── use_query: cache policy NoCache allows repeated fetches ───────────────── - #[gpui::test] fn test_use_query_no_cache_allows_refetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -202,7 +188,6 @@ fn test_use_query_no_cache_allows_refetch(cx: &mut TestAppContext) { assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"first")); }); - // With NoCache, fetch_query should always succeed (no cache short-circuit). harness.update(cx, |this, cx| { fetch_query(&this.entity, || async { Ok::<_, QueryError>("second") }, cx); }); diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs index 5fbe64f..5d42d5f 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs @@ -1,6 +1,3 @@ -//! Tests for `use_query_select`: transform applied, updated on refetch, -//! memoization, fetch failure, and multiple selects on same query. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -12,8 +9,6 @@ use crate::core::{ use crate::hook::*; use crate::tests::test_support::*; -// ── use_query_select: transform applied ───────────────────────────────────── - #[gpui::test] fn test_use_query_select_transform_applied(cx: &mut TestAppContext) { setup_query_client(cx); @@ -55,8 +50,6 @@ fn test_use_query_select_transform_applied(cx: &mut TestAppContext) { }); } -// ── use_query_select: transform updated on refetch ────────────────────────── - #[gpui::test] fn test_use_query_select_transform_updated_on_refetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -105,7 +98,6 @@ fn test_use_query_select_transform_updated_on_refetch(cx: &mut TestAppContext) { assert_eq!(mapped_data, Some(1), "first fetch should have 1 item"); }); - // Refetch — should now produce 2 items, and the transform should give 2. harness.update(cx, |this, cx| { fetch_query( &this.query, @@ -138,8 +130,6 @@ fn test_use_query_select_transform_updated_on_refetch(cx: &mut TestAppContext) { }); } -// ── use_query_select: memoization (same data, same result) ───────────────── - #[gpui::test] fn test_use_query_select_memoization_consistency(cx: &mut TestAppContext) { setup_query_client(cx); @@ -168,7 +158,6 @@ fn test_use_query_select_memoization_consistency(cx: &mut TestAppContext) { cx.run_until_parked(); - // Read the mapped data twice — transform should produce consistent results. let result1 = cx.update(|cx| harness.read(cx).mapped.read(cx).data()); let result2 = cx.update(|cx| harness.read(cx).mapped.read(cx).data()); @@ -179,8 +168,6 @@ fn test_use_query_select_memoization_consistency(cx: &mut TestAppContext) { assert_eq!(result1, Some(5), "length of 'hello' is 5"); } -// ── use_query_select: handles fetch failure gracefully ────────────────────── - #[gpui::test] fn test_use_query_select_handles_fetch_failure(cx: &mut TestAppContext) { setup_query_client(cx); @@ -215,7 +202,6 @@ fn test_use_query_select_handles_fetch_failure(cx: &mut TestAppContext) { let query_status = h.query.read(cx).status(); assert_eq!(query_status, QueryStatus::Failure); - // Mapped data should be None when query has no data. let mapped_data = h.mapped.read(cx).data(); assert_eq!( mapped_data, None, @@ -224,15 +210,10 @@ fn test_use_query_select_handles_fetch_failure(cx: &mut TestAppContext) { }); } -// ── use_query_select: multiple selects on same query ──────────────────────── - #[gpui::test] fn test_use_query_select_multiple_transforms_same_query(cx: &mut TestAppContext) { setup_query_client(cx); - // Counting fetchers: each call returns a different value so we can - // distinguish "cache hit (re-used first fetcher's data)" from "re-fetched - // (second fetcher ran and produced its own data)". let fetch_count = Arc::new(Mutex::new(0u32)); let fc1 = fetch_count.clone(); let fc2 = fetch_count.clone(); @@ -258,7 +239,6 @@ fn test_use_query_select_multiple_transforms_same_query(cx: &mut TestAppContext) *g += 1; *g }; - // Call 1 returns ["a","b","c"], call 2+ returns different data. let items: Vec<String> = (0..n + 2).map(|i| format!("item-{}", i)).collect(); Ok::<_, QueryError>(items) } @@ -284,7 +264,6 @@ fn test_use_query_select_multiple_transforms_same_query(cx: &mut TestAppContext) cx, ); - // Both selects should reference the same cached query entity. assert_eq!( query.entity_id(), query2.entity_id(), @@ -306,9 +285,6 @@ fn test_use_query_select_multiple_transforms_same_query(cx: &mut TestAppContext) let h = harness.read(cx); let len = h.mapped_len.read(cx).data(); let first = h.mapped_first.read(cx).data(); - // Only one fetch should have occurred (the second select is a cache hit). - // If the second had re-fetched, the data would be 4 items / "item-2" - // instead of 3 items / "item-0". assert_eq!( len, Some(3), diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/with_policy.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/with_policy.rs index 85fa873..1182016 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/with_policy.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/with_policy.rs @@ -1,15 +1,9 @@ -//! Tests for the `*_with_policy` fetcher variant ("server wins"): a fetcher -//! returning `Fetched<T>` can override the caller's per-query `CachePolicy` on -//! success. - use gpui::{AppContext as _, Entity, TestAppContext}; use crate::core::{CachePolicy, Fetched, QueryError, QueryResource, QueryStatus}; use crate::hook::*; use crate::tests::test_support::*; -// ── use_query_with_policy ──────────────────────────────────────────────── - #[gpui::test] fn test_use_query_with_policy_overrides_cache_policy(cx: &mut TestAppContext) { setup_query_client(cx); @@ -18,7 +12,6 @@ fn test_use_query_with_policy_overrides_cache_policy(cx: &mut TestAppContext) { entity: Entity<QueryResource<&'static str, QueryError>>, } - // Caller asks for NoCache; the fetcher (the "server") overrides to a TTL. let harness = cx.new(|cx| { let (entity, _sub) = use_query_with_policy( QueryOptions::new("override").cache_policy(CachePolicy::NoCache), @@ -38,7 +31,6 @@ fn test_use_query_with_policy_overrides_cache_policy(cx: &mut TestAppContext) { cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!(resource.status(), QueryStatus::Success); - // Server wins: the resource's policy is the server's, not the caller's. assert_eq!(resource.cache_policy(), CachePolicy::Ttl { ttl_ms: 60_000 }); assert_eq!(resource.data(), Some(&"data")); }); @@ -67,7 +59,6 @@ fn test_use_query_with_policy_none_keeps_caller_policy(cx: &mut TestAppContext) cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!(resource.status(), QueryStatus::Success); - // No server policy → the caller's policy is retained. assert_eq!(resource.cache_policy(), caller_policy); assert_eq!(resource.data(), Some(&"data")); }); @@ -81,8 +72,6 @@ fn test_use_query_with_policy_stale_while_revalidate_override(cx: &mut TestAppCo entity: Entity<QueryResource<u32, QueryError>>, } - // The server can hand back a StaleWhileRevalidate policy (e.g. parsed from - // `Cache-Control: max-age=30, stale-while-revalidate=60`). let server_policy = CachePolicy::StaleWhileRevalidate { ttl_ms: 30_000, stale_ms: 60_000, @@ -109,8 +98,6 @@ fn test_use_query_with_policy_stale_while_revalidate_override(cx: &mut TestAppCo }); } -// ── fetch_query_with_policy (refetch path) ─────────────────────────────── - #[gpui::test] fn test_fetch_query_with_policy_overrides_on_refetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -119,10 +106,6 @@ fn test_fetch_query_with_policy_overrides_on_refetch(cx: &mut TestAppContext) { entity: Entity<QueryResource<&'static str, QueryError>>, } - // Start with a plain fetch using NoCache. NoCache (no TTL) is required so an - // immediate refetch is not short-circuited as a cache hit: `is_cache_fresh` - // uses an inclusive `age_ms <= ttl_ms` boundary, so any `Ttl` policy would - // treat a same-millisecond refetch as fresh and skip the fetch. let harness = cx.new(|cx| { let (entity, _sub) = use_query( QueryOptions::new("refetch-override").cache_policy(CachePolicy::NoCache), @@ -141,7 +124,6 @@ fn test_fetch_query_with_policy_overrides_on_refetch(cx: &mut TestAppContext) { assert_eq!(resource.data(), Some(&"first")); }); - // Refetch through the with_policy variant, overriding the policy to a TTL. harness.update(cx, |this, cx| { fetch_query_with_policy( &this.entity, @@ -160,7 +142,6 @@ fn test_fetch_query_with_policy_overrides_on_refetch(cx: &mut TestAppContext) { cx.update(|cx| { let resource = harness.read(cx).entity.read(cx); assert_eq!(resource.status(), QueryStatus::Success); - // Server wins on refetch: the resource's policy is now the server's TTL. assert_eq!(resource.cache_policy(), CachePolicy::Ttl { ttl_ms: 60_000 }); assert_eq!(resource.data(), Some(&"second")); }); diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index cb97fdd..c7297ef 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -1,16 +1,3 @@ -//! Regression tests for stored-task cancellation and the cross-context -//! mutate race. -//! -//! Stored mutation/infinite tasks are cancelled when superseded: `set_current_task` -//! drops the previous `gpui::Task`, and dropping a GPUI task aborts its future. -//! `test_stored_mutation_task_aborted_when_entity_dropped` checks that an in-flight -//! mutation whose entity is dropped never runs its post-gate side effect. -//! -//! `test_mutate_from_two_spawn_contexts_second_rejected` races two `mutate()` -//! calls from different async spawn contexts (the synchronous double-call case -//! is covered by `test_mutate_double_while_loading_*`); the atomic check+begin -//! guard must still reject the second while the first is Loading. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -19,14 +6,10 @@ use crate::core::{MutationResource, QueryError}; use crate::hook::*; use crate::tests::test_support::*; -// Stored task is cancelled when its entity is dropped. - #[gpui::test] fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext) { setup_test(cx); - // Counter incremented after the gate is released: stays at 0 if the task - // is correctly aborted on entity drop. let landed = Arc::new(Mutex::new(0u32)); let landed_clone = landed.clone(); @@ -51,8 +34,6 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext let gate_clone = gate_clone.clone(); let executor = executor.clone(); async move { - // Park here. If the task is aborted (entity dropped), - // this future is dropped and the line below never runs. gate_clone.wait(&executor).await; *landed_clone.lock().unwrap() += 1; Ok::<_, QueryError>("done".to_string()) @@ -67,7 +48,6 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext H { _mutation: entity } }); - // Sanity: still loading before we drop the harness. cx.update(|cx| { assert!( harness.read(cx)._mutation.read(cx).is_loading(), @@ -75,19 +55,11 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext ); }); - // Dropping `harness` drops the entity and its CurrentTask, which - // aborts the gated future. drop(harness); } - // GPUI releases dropped entities at the end of `App::update`, not during - // `run_until_parked`. Force that flush so the entity is gone before the - // gate opens, otherwise the parked future would still run its post-gate - // side effect and `landed` would read 1. cx.update(|_| {}); - // If the task had not been aborted, the mutator would proceed past the - // gate and increment `landed`. gate.release(); cx.run_until_parked(); @@ -99,18 +71,6 @@ fn test_stored_mutation_task_aborted_when_entity_dropped(cx: &mut TestAppContext ); } -// Two mutate() calls from different spawn contexts. -// -// The second `mutate()` fires from an independent `Context::spawn` task that -// re-enters the harness entity via `AsyncApp`: the real cross-context race -// shape. We assert the rejection contract directly (second fetcher never -// runs, first stays in-flight and uncorrupted). We deliberately do NOT assert -// post-release completion of the gated first mutate: a completed -// `Context::spawn` task interacts with the `TestAppContext` executor such -// that later `run_until_parked` calls stop draining the background-timer -// wake-up chain `Gate::wait` relies on. Gated-mutation completion is covered -// by `retry_reset_tests`. - #[gpui::test] fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) { setup_test(cx); @@ -118,13 +78,10 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) let first_call_count = Arc::new(Mutex::new(0u32)); let second_call_count = Arc::new(Mutex::new(0u32)); - // Keeps the first mutate's fetcher in flight while the second mutate is - // issued from a different spawn context. let gate = Gate::new(); let gate_for_first = gate.clone(); let executor = cx.background_executor.clone(); - // The first fetcher parks on the gate so the mutation stays Loading. #[allow(dead_code)] struct H { mutation: Entity<MutationResource<String, String, QueryError>>, @@ -158,12 +115,7 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) ); }); - // Second mutate, issued from a different async spawn context: spawn on the - // harness `Context<H>` and re-enter the entity via `AsyncApp` to call - // mutate. An independent task racing the in-flight one. let sc = second_call_count.clone(); - // A Gate signals that the spawned task ran its context; the main task - // drains once. let second_ran = Gate::new(); let second_ran_clone = second_ran.clone(); let _second_task = harness.update(cx, |_this, cx| { @@ -189,8 +141,6 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) }) }); - // Drain so the spawned second mutate runs (and, because the first is - // still Loading, is rejected). cx.run_until_parked(); assert!( second_ran.is_released(), @@ -198,14 +148,12 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) actually exercised" ); - // The second mutate's fetcher never ran (rejected by the is_loading guard). assert_eq!( *second_call_count.lock().unwrap(), 0, "second mutate from a different spawn context must be rejected while \ the first is Loading" ); - // The first mutate is still in-flight and uncorrupted. assert_eq!( *first_call_count.lock().unwrap(), 1, @@ -219,8 +167,6 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) ); }); - // Hygiene: release the gate so the parked first fetcher can progress - // (not asserted; see the note above the test). gate.release(); cx.run_until_parked(); } diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs index eae03cb..3eb3e6d 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs @@ -1,14 +1,9 @@ -//! Client construction, resource creation, type erasure, query data, -//! and infinite query resource tests (tests 1–17). - use gpui::{BorrowAppContext as _, TestAppContext}; use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -// -- 1. QueryClient::new() vs Default ---------------------------------------- - #[gpui::test] fn test_client_new_equals_default(cx: &mut TestAppContext) { cx.update(|cx| { @@ -23,8 +18,6 @@ fn test_client_new_equals_default(cx: &mut TestAppContext) { }); } -// -- 2. with_policies + with_gc_time builder chaining ------------------------- - #[gpui::test] fn test_builder_chaining_with_policies_and_gc(cx: &mut TestAppContext) { cx.update(|cx| { @@ -53,21 +46,17 @@ fn test_builder_chaining_with_policies_and_gc(cx: &mut TestAppContext) { }); } -// -- 3. resource_with_policies updates existing entity policies --------------- - #[gpui::test] fn test_resource_with_policies_updates_existing_entity(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("policy_update"); - // Create with default TTL let e1 = client.resource::<String, QueryError>(key.clone(), cx); e1.read_with(cx, |r, _| { assert_eq!(r.cache_policy(), CachePolicy::Ttl { ttl_ms: 60_000 }); }); - // Same key, different policies — should update in place let e2 = client.resource_with_policies::<String, QueryError>( key.clone(), CachePolicy::NoCache, @@ -83,27 +72,20 @@ fn test_resource_with_policies_updates_existing_entity(cx: &mut TestAppContext) }); } -// -- 4. all_queries returns empty for unregistered types ---------------------- - #[gpui::test] fn test_all_queries_empty_for_unregistered_type(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create String resources let _s = client.resource::<String, QueryError>("s", cx); - // Ask for u32 queries — should be empty let u32s = client.all_queries::<u32, QueryError>(); assert!(u32s.is_empty(), "no u32 queries registered"); - // String queries should have 1 let strings = client.all_queries::<String, QueryError>(); assert_eq!(strings.len(), 1); }); }); } -// -- 5. query() returns None after remove_queries ----------------------------- - #[gpui::test] fn test_query_returns_none_after_remove_queries(cx: &mut TestAppContext) { setup_query_client(cx); @@ -122,8 +104,6 @@ fn test_query_returns_none_after_remove_queries(cx: &mut TestAppContext) { }); } -// -- 6. Multiple type erasure: 4 different (T, E) pairs in same client ------- - #[gpui::test] fn test_four_distinct_type_pairs_in_same_client(cx: &mut TestAppContext) { setup_query_client(cx); @@ -134,7 +114,6 @@ fn test_four_distinct_type_pairs_in_same_client(cx: &mut TestAppContext) { let e3 = client.resource::<User, QueryError>("data", cx); let e4 = client.resource::<Post, QueryError>("data", cx); - // All four must be distinct entities let ids = [ e1.entity_id(), e2.entity_id(), @@ -147,7 +126,6 @@ fn test_four_distinct_type_pairs_in_same_client(cx: &mut TestAppContext) { } } - // all_queries for each type returns exactly 1 assert_eq!(client.all_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_queries::<u32, QueryError>().len(), 1); assert_eq!(client.all_queries::<User, QueryError>().len(), 1); @@ -159,8 +137,6 @@ fn test_four_distinct_type_pairs_in_same_client(cx: &mut TestAppContext) { }); } -// -- 7. Same T different E: full lifecycle isolation -------------------------- - #[gpui::test] fn test_different_error_types_full_isolation(cx: &mut TestAppContext) { setup_query_client(cx); @@ -170,18 +146,13 @@ fn test_different_error_types_full_isolation(cx: &mut TestAppContext) { let e1 = client.resource::<String, QueryError>(key.clone(), cx); let e2 = client.resource::<String, String>(key.clone(), cx); - // Set data on e1 only e1.update(cx, |r, _| r.apply_success("v1".to_string(), 1_000)); - // e2 should not have data assert!(e2.read(cx).data().is_none()); - // e1 should have data assert_eq!(e1.read(cx).data().unwrap(), "v1"); }); }); } -// -- 8. set_query_data + get_query_data round-trip with typed data ----------- - #[gpui::test] fn test_set_and_get_query_data_with_user_type(cx: &mut TestAppContext) { setup_query_client(cx); @@ -196,8 +167,6 @@ fn test_set_and_get_query_data_with_user_type(cx: &mut TestAppContext) { }); } -// -- 9. set_query_data preserves previous_data for rollback ------------------ - #[gpui::test] fn test_set_query_data_multiple_times_preserves_rollback_chain(cx: &mut TestAppContext) { setup_query_client(cx); @@ -205,7 +174,6 @@ fn test_set_query_data_multiple_times_preserves_rollback_chain(cx: &mut TestAppC cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("chain"); - // First set — no previous data client.set_query_data::<String, QueryError>(key.clone(), "v1".to_string(), cx); let e = client.query::<String, QueryError>(&key).unwrap(); assert!( @@ -213,12 +181,10 @@ fn test_set_query_data_multiple_times_preserves_rollback_chain(cx: &mut TestAppC "first set has no previous" ); - // Second set — previous should be v1 client.set_query_data::<String, QueryError>(key.clone(), "v2".to_string(), cx); assert_eq!(e.read(cx).data().unwrap(), "v2"); assert_eq!(e.read(cx).previous_data().unwrap(), "v1"); - // Third set — previous should be v2 (only one level of rollback) client.set_query_data::<String, QueryError>(key.clone(), "v3".to_string(), cx); assert_eq!(e.read(cx).data().unwrap(), "v3"); assert_eq!(e.read(cx).previous_data().unwrap(), "v2"); @@ -226,14 +192,11 @@ fn test_set_query_data_multiple_times_preserves_rollback_chain(cx: &mut TestAppC }); } -// -- 10. get_query_data returns None for idle resource ------------------------ - #[gpui::test] fn test_get_query_data_none_for_idle_resource(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create resource but never set data let _entity = client.resource::<String, QueryError>("idle_data", cx); let data = client.get_query_data::<String, QueryError>(&QueryKey::from("idle_data"), cx); @@ -242,8 +205,6 @@ fn test_get_query_data_none_for_idle_resource(cx: &mut TestAppContext) { }); } -// -- 11. rollback_to_previous returns false when no previous data ------------ - #[gpui::test] fn test_rollback_returns_false_without_previous_data(cx: &mut TestAppContext) { setup_query_client(cx); @@ -252,7 +213,6 @@ fn test_rollback_returns_false_without_previous_data(cx: &mut TestAppContext) { let key = QueryKey::from("no_prev"); client.set_query_data::<String, QueryError>(key.clone(), "only".to_string(), cx); let entity = client.query::<String, QueryError>(&key).unwrap(); - // No previous_data was set (first set_query_data) let rolled_back = entity.update(cx, |r, _| r.rollback_to_previous()); assert!( !rolled_back, @@ -262,8 +222,6 @@ fn test_rollback_returns_false_without_previous_data(cx: &mut TestAppContext) { }); } -// -- 12. Infinite query resource creation and retrieval ----------------------- - #[gpui::test] fn test_infinite_resource_creates_and_deduplicates(cx: &mut TestAppContext) { setup_query_client(cx); @@ -284,8 +242,6 @@ fn test_infinite_resource_creates_and_deduplicates(cx: &mut TestAppContext) { }); } -// -- 13. infinite_query() retrieval ------------------------------------------- - #[gpui::test] fn test_infinite_query_retrieves_existing(cx: &mut TestAppContext) { setup_query_client(cx); @@ -303,8 +259,6 @@ fn test_infinite_query_retrieves_existing(cx: &mut TestAppContext) { }); } -// -- 14. all_infinite_queries returns typed results --------------------------- - #[gpui::test] fn test_all_infinite_queries_typed(cx: &mut TestAppContext) { setup_query_client(cx); @@ -322,8 +276,6 @@ fn test_all_infinite_queries_typed(cx: &mut TestAppContext) { }); } -// -- 15. infinite_resource_with_policies updates policies --------------------- - #[gpui::test] fn test_infinite_resource_with_policies(cx: &mut TestAppContext) { setup_query_client(cx); @@ -343,8 +295,6 @@ fn test_infinite_resource_with_policies(cx: &mut TestAppContext) { }); } -// -- 16. next_request_id_for_infinite_key monotonic sequence ----------------- - #[gpui::test] fn test_next_request_id_for_infinite_key_monotonic(cx: &mut TestAppContext) { setup_query_client(cx); @@ -362,8 +312,6 @@ fn test_next_request_id_for_infinite_key_monotonic(cx: &mut TestAppContext) { }); } -// -- 17. next_request_id_for_infinite_key returns None for missing key ------ - #[gpui::test] fn test_next_request_id_for_infinite_key_returns_none_for_missing(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/client_bucket_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/client_bucket_coverage.rs index a919f39..1a693a8 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/client_bucket_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/client_bucket_coverage.rs @@ -1,8 +1,3 @@ -//! Client bucket coverage tests — Gaps 15, 16, 16b. -//! -//! Tests for mutation bucket type-mismatch downcast recovery and hook fallback -//! paths when QueryClient global is not registered. - use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; use crate::client::QueryClient; @@ -11,17 +6,11 @@ use crate::core::*; use crate::hook::{InfiniteQueryOptions, mutate, use_infinite_query, use_mutation}; use crate::tests::test_support::*; -// -- Gap 15: MutationBucket type mismatch downcast recovery ------------------ -// -// Verify that accessing the same key with different (V, T, E) types produces -// separate mutation buckets (no collision). - #[gpui::test] fn test_mutation_bucket_type_mismatch_creates_separate_buckets(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Register mutations with different type triples let m1 = cx.new(|_| { MutationResource::<String, String, QueryError>::new(RetryPolicy::no_retries()) }); @@ -46,17 +35,8 @@ fn test_mutation_bucket_type_mismatch_creates_separate_buckets(cx: &mut TestAppC }); } -// -- Gap 16: use_infinite_query without QueryClient global (fallback path) --- -// -// The code has a fallback that creates a standalone entity, but no test -// exercises this path. - #[gpui::test] fn test_use_infinite_query_without_query_client(cx: &mut TestAppContext) { - // Do NOT call setup_query_client — exercise the fallback path. - // In debug builds, use_infinite_query prints a warning but still creates - // a standalone entity. - struct H { entity: Entity<InfiniteQueryResource<Vec<i32>, QueryError>>, } @@ -80,11 +60,8 @@ fn test_use_infinite_query_without_query_client(cx: &mut TestAppContext) { }); } -// -- Gap 16b: use_mutation without QueryClient (still works) ---------------- - #[gpui::test] fn test_use_mutation_without_query_client(cx: &mut TestAppContext) { - // Do NOT call setup_query_client. struct H { mutation: Entity<MutationResource<String, String, QueryError>>, } @@ -95,7 +72,6 @@ fn test_use_mutation_without_query_client(cx: &mut TestAppContext) { H { mutation: entity } }); - // Mutate should still work harness.update(cx, |this, cx| { mutate( &this.mutation, diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index 31b4531..0505c01 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -1,24 +1,11 @@ -//! GC and observer coverage tests — Gaps 1, 2, 3, 3b, 4, 4b, 5, 6, 6b, 6c, 7, 8. -//! -//! Tests for garbage collection behavior across query, mutation, and infinite -//! query buckets including observer retention, SWR window protection, loading -//! state preservation, max-entries eviction, and observer configuration. - use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; use crate::client::{QueryClient, QueryObserver}; use crate::core::*; use crate::tests::test_support::*; -// The buckets expose no observer_count-based GC protection to test: the -// count was never incremented from production hooks (GPUI Drop has no cx). -// GC relies solely on WeakEntity::upgrade() liveness plus age/status. - -// SWR resources within the stale window are not evicted by GC. - #[gpui::test] fn test_gc_preserves_swr_resources_within_stale_window(cx: &mut TestAppContext) { - // Use a small gc_time so success_threshold = 2*500 = 1000ms is small setup_query_client_with_gc(cx, 500); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -34,19 +21,13 @@ fn test_gc_preserves_swr_resources_within_stale_window(cx: &mut TestAppContext) cx, ); entity.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // GC reads live entity state: Success + swr policy + - // last_updated_at=1000 are all set by apply_success above. - // GC at t=3000: age=2000, ttl expired (2000 > 1000), but within - // stale window (2000 <= 6000). SWR protection should prevent eviction. client.gc_with_time(3_000, cx); assert!( client.query::<String, QueryError>(&key).is_some(), "SWR resource within stale window must survive GC" ); - // GC at t=8000: age=7000 > total_valid(6000), AND age > success_threshold(1000). - // SWR resource past total valid window should be evicted. client.gc_with_time(8_000, cx); assert!( client.query::<String, QueryError>(&key).is_none(), @@ -56,8 +37,6 @@ fn test_gc_preserves_swr_resources_within_stale_window(cx: &mut TestAppContext) }); } -// -- Gap 3b: SWR resource within TTL (fresh) is also preserved --------------- - #[gpui::test] fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 5_000); @@ -75,10 +54,7 @@ fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { cx, ); entity.update(cx, |r, _| r.apply_success("fresh".to_string(), 1_000)); - // GC reads live entity state: Success + swr policy + - // last_updated_at=1000 are all set by apply_success above. - // GC at t=3000: age=2000 < ttl(5000), still fresh client.gc_with_time(3_000, cx); assert!( client.query::<String, QueryError>(&key).is_some(), @@ -88,8 +64,6 @@ fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { }); } -// A completed mutation ages past gc_time and is evicted. - #[gpui::test] fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); @@ -100,21 +74,15 @@ fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { }); client.register_mutation::<String, String, QueryError>(&entity, cx); - // Complete the mutation with success entity.update(cx, |m, _| { m.begin("vars".to_string()); m.complete_success("done".to_string()); }); assert!(entity.read(cx).is_success()); - // GC at far-future: the mutation's updated_at is set to now() on insert. - // Since the mutation is in Success state (not Idle/Failure), GC should - // NOT evict it — Success mutations are kept by the MutationBucket GC. client.gc_with_time(1_000_000, cx); let mutations = client.all_mutations::<String, String, QueryError>(); - // Success mutations are NOT in the evictable set (Idle | Failure only), - // so they survive GC regardless of age. assert!( !mutations.is_empty(), "Success mutation should survive GC — only Idle/Failure are evictable" @@ -123,9 +91,6 @@ fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { }); } -// InfiniteQueryBucket GC reads entity state via cx (no cached snapshot): -// evicts idle infinite queries, preserves loading ones. - #[gpui::test] fn test_gc_evicts_idle_infinite_query_with_realistic_timing(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); @@ -134,7 +99,6 @@ fn test_gc_evicts_idle_infinite_query_with_realistic_timing(cx: &mut TestAppCont let key = QueryKey::from("inf_gc_idle"); let _entity = client.infinite_resource::<String, QueryError>(key.clone(), cx); - // Entity is Idle with no data — GC should evict it client.gc_with_time(100_000, cx); assert!( @@ -145,8 +109,6 @@ fn test_gc_evicts_idle_infinite_query_with_realistic_timing(cx: &mut TestAppCont }); } -// -- Gap 6b: InfiniteQueryBucket GC preserves loading infinite query ---------- - #[gpui::test] fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); @@ -155,7 +117,6 @@ fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { let key = QueryKey::from("inf_gc_loading"); let entity = client.infinite_resource::<String, QueryError>(key.clone(), cx); - // Transition to loading by starting a request let _rid = client .next_request_id_for_infinite_key::<String, QueryError>(&key) .expect("request id"); @@ -165,8 +126,6 @@ fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { }); assert!(entity.read(cx).status().is_loading()); - // GC reads the live LoadingEmpty status; no snapshot - // update is needed; loading resources survive regardless of age. client.gc_with_time(1_000_000, cx); assert!( @@ -177,9 +136,6 @@ fn test_gc_preserves_loading_infinite_query(cx: &mut TestAppContext) { }); } -// InfiniteQueryBucket evicts successful resources once their age exceeds -// SUCCESS_GC_MULTIPLIER * gc_time_ms (pure age-based eviction). - #[gpui::test] fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); @@ -188,7 +144,6 @@ fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { let key = QueryKey::from("inf_gc_success"); let entity = client.infinite_resource::<String, QueryError>(key.clone(), cx); - // Load one page successfully at t=1_000 (sets last_updated_at=1_000). entity.update(cx, |r, _| { let mut seq = RequestSequencer::new(); let id = r.begin_fetch_next(&mut seq, 1_000).expect("begin fetch"); @@ -196,7 +151,6 @@ fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { }); assert_eq!(entity.read(cx).status(), QueryStatus::Success); - // success_threshold = 2 * 1000 = 2000. GC at t=3500 -> age=2500 > 2000 -> evicted. client.gc_with_time(3_500, cx); assert!( client.infinite_query::<String, QueryError>(&key).is_none(), @@ -206,16 +160,11 @@ fn test_gc_evicts_aged_successful_infinite_query(cx: &mut TestAppContext) { }); } -// Bucket max_entries: with_max_entries() is pub(crate), so eviction is tested -// indirectly through the client. The default limit (10_000) admits every -// resource created here. - #[gpui::test] fn test_bucket_default_max_entries_allows_many_resources(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create 100 resources — well within the default 10_000 limit for i in 0..100 { let key = format!("max_{}", i); let _entity = client.resource::<String, QueryError>(key, cx); @@ -231,12 +180,6 @@ fn test_bucket_default_max_entries_allows_many_resources(cx: &mut TestAppContext }); } -// -- Gap 4: MutationBucket touch/set_loading/set_not_loading ----------------- -// -// These methods are marked #[allow(dead_code)] with zero tests. We test them -// indirectly by verifying that a loading mutation survives GC (the loading -// flag on the entry prevents mid-flight eviction). - #[gpui::test] fn test_loading_mutation_survives_gc(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); @@ -247,13 +190,11 @@ fn test_loading_mutation_survives_gc(cx: &mut TestAppContext) { }); client.register_mutation::<String, String, QueryError>(&entity, cx); - // Begin mutation — transitions to Loading entity.update(cx, |m, _| { m.begin("vars".to_string()); }); assert!(entity.read(cx).is_loading()); - // GC at far-future — loading mutation should survive client.gc_with_time(1_000_000, cx); let mutations = client.all_mutations::<String, String, QueryError>(); @@ -266,13 +207,8 @@ fn test_loading_mutation_survives_gc(cx: &mut TestAppContext) { }); } -// -- Gap 4b: Idle mutation is evicted by GC when age exceeds gc_time --------- - #[gpui::test] fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut TestAppContext) { - // MutationBucket GC uses real wall-clock time for updated_at (set on insert). - // gc_time is clamped to MIN_GC_TIME_MS (1000ms). We use gc_with_time with - // a far-future now_ms to guarantee the mutation's age exceeds gc_threshold. setup_query_client_with_gc(cx, 1); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -281,7 +217,6 @@ fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut Test }); client.register_mutation::<String, String, QueryError>(&entity, cx); - // Pre-condition: mutation is idle (evictable status set). assert!( entity.read(cx).is_idle(), "mutation should be idle before any operation" @@ -292,10 +227,7 @@ fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut Test "mutation should exist before GC" ); - // Use a far-future timestamp so age = now_ms - updated_at >> gc_threshold. - // updated_at is ~current_time_ms() at insert, so 100 years from now - // guarantees the age exceeds the clamped gc_threshold (1000ms). - let far_future = crate::client::current_time_ms() + 3_600_000; // +1 hour + let far_future = crate::client::current_time_ms() + 3_600_000; client.gc_with_time(far_future, cx); assert_eq!( @@ -307,10 +239,6 @@ fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut Test }); } -// QueryObserver::observe() returns Some for a live entity. GPUI can't truly -// drop an entity within a single cx.update scope, so the None path is -// untestable here. - #[gpui::test] fn test_query_observer_observe_returns_some_for_live_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -318,28 +246,17 @@ fn test_query_observer_observe_returns_some_for_live_entity(cx: &mut TestAppCont cx.update_global::<QueryClient, _>(|client, cx| { let entity = client.resource::<String, QueryError>("obs_live", cx); - // Create an observer and verify it can observe a live entity let mut observer = QueryObserver::new(&entity); - // instead of defining a local `struct DummyView;` + manual view dance. let sub = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); assert!( sub.is_some(), "observe should return Some(Subscription) for a live entity" ); - - // The observer stores a WeakEntity internally. If the entity were - // dropped (which can't happen in this scope), observe() would - // return None — this is the v2 safety improvement. }); }); } -// -- Gap 8: Observer status deduplication ------------------------------------ -// -// Verifies that ObserverConfig { notify_on_status_change_only: true } is the -// default and that the observer is properly created with this config. - #[gpui::test] fn test_observer_status_dedup_default_config_is_status_change_only(_cx: &mut TestAppContext) { let config = crate::client::ObserverConfig::default(); @@ -348,7 +265,6 @@ fn test_observer_status_dedup_default_config_is_status_change_only(_cx: &mut Tes "default ObserverConfig should notify on status change only" ); - // Create a config that always notifies let always_notify = crate::client::ObserverConfig { notify_on_status_change_only: false, }; @@ -358,30 +274,16 @@ fn test_observer_status_dedup_default_config_is_status_change_only(_cx: &mut Tes ); } -// MutationBucket's max_entries cap binds growth: inserting past the cap -// evicts the oldest non-loading entry. DEFAULT_MAX_ENTRIES is private, so the -// documented value (10_000) is mirrored here; update it if the constant -// changes. - #[gpui::test] fn test_mutation_bucket_evict_oldest_keeps_count_bounded(cx: &mut TestAppContext) { - // Mirrors `crate::client::bucket::types::DEFAULT_MAX_ENTRIES` (pub(crate), - // not nameable from the tests module). const MAX_ENTRIES: usize = 10_000; setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Hold strong refs to every created entity for the duration of the - // test. The bucket stores only WeakEntity handles; `evict_oldest` - // and `all_entities` skip dead weak refs, so the entities must stay - // alive for the count assertions below to be meaningful. let mut live: Vec<gpui::Entity<MutationResource<String, String, QueryError>>> = Vec::with_capacity(MAX_ENTRIES + 2); - // Insert MAX_ENTRIES + 2 Idle mutations — crossing the cap by 2 - // is enough to trigger evict_oldest and prove the count stays - // bounded (audit T14: avoid constructing 10 005 entities). for _ in 0..(MAX_ENTRIES + 2) { let entity = cx.new(|_| { MutationResource::<String, String, QueryError>::new(RetryPolicy::no_retries()) @@ -399,14 +301,12 @@ fn test_mutation_bucket_evict_oldest_keeps_count_bounded(cx: &mut TestAppContext MAX_ENTRIES ); - // Diagnostics should agree with the bounded bucket size. let diag = client.diagnostics(cx); assert_eq!( diag.mutation_count, MAX_ENTRIES, "diagnostics.mutation_count must match the bounded bucket size" ); - // Hold `live` to the end so the strong refs outlive the assertions. drop(live); }); }); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs index cacafb8..68ccc5e 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs @@ -1,9 +1,3 @@ -//! Hook coverage tests — Gaps 9, 10, 11, 12, 13, 17. -//! -//! Tests for deprecated hook APIs, mutation callbacks, fetch retry cancellation, -//! signal-based fetch, infinite query retry stop, and use_query_select observer -//! propagation. - use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; @@ -16,9 +10,6 @@ use crate::hook::{ }; use crate::tests::test_support::*; -// use_mutation accepts MutationOptions via Into; the default-options path -// must still register and produce an Idle mutation. - #[gpui::test] fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext) { setup_query_client(cx); @@ -35,7 +26,6 @@ fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext H { mutation: entity } }); - // Verify the mutation entity is usable cx.update(|cx| { let resource = harness.read(cx).mutation.read(cx); assert_eq!(resource.status(), MutationStatus::Idle); @@ -43,11 +33,6 @@ fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext }); } -// -- Gap 11: Mutation callbacks fire when entity is dropped mid-flight ------- -// -// When weak.upgrade() returns None inside run_mutation_loop_with_callbacks, -// on_error and on_settled should still fire. - #[gpui::test] fn test_mutation_callbacks_fire_on_entity_drop_during_retry_delay(cx: &mut TestAppContext) { setup_query_client(cx); @@ -57,10 +42,6 @@ fn test_mutation_callbacks_fire_on_entity_drop_during_retry_delay(cx: &mut TestA let ec = error_called.clone(); let sc = settled_called.clone(); - // A GPUI entity can't be truly dropped while a spawned task holds a weak - // ref (the harness keeps it alive), so the drop-during-retry callback path - // is untestable here; the success case confirms the callback mechanism. - #[allow(dead_code)] struct H { mutation: Entity<MutationResource<String, String, QueryError>>, @@ -97,11 +78,6 @@ fn test_mutation_callbacks_fire_on_entity_drop_during_retry_delay(cx: &mut TestA ); } -// -- Gap 13: fetch_with_retry stops after request replaced (LatestWins) ------ -// -// When a new request replaces the current one during retry delay, the old -// fetch loop should exit cleanly. - #[gpui::test] fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { setup_test(cx); @@ -127,7 +103,6 @@ fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { r.set_retry_policy(RetryPolicy::new(5).with_delay(0)) }); - // First fetch: always fails, blocks on gate before returning let executor = executor.clone(); fetch_query( &entity, @@ -139,9 +114,7 @@ fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { { let mut n = cc.lock().unwrap(); *n += 1; - } // drop MutexGuard before await - // Wait for gate via the shared helper — this keeps the first - // fetch "in flight" while the second is issued. + } gate_clone.wait(&executor).await; Err::<_, QueryError>(QueryError::response("fail")) } @@ -151,17 +124,14 @@ fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { H { entity } }); - // Issue a second fetch_query — LatestWins replaces the first harness.update(cx, |this, cx| { fetch_query(&this.entity, || async { Ok::<_, QueryError>("new") }, cx); }); - // Release the gate so the first fetch can return its error gate.release(); cx.run_until_parked(); - // The second fetch should have won cx.update(|cx| { let data = harness.read(cx).entity.read(cx).data(); assert_eq!( @@ -172,11 +142,6 @@ fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { }); } -// -- Gap 17: use_query_select observer propagation on refetch ---------------- -// -// Verify that the mapped entity data updates when the underlying query -// is refetched through the observer path. - #[gpui::test] fn test_use_query_select_observer_updates_on_refetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -227,7 +192,6 @@ fn test_use_query_select_observer_updates_on_refetch(cx: &mut TestAppContext) { assert_eq!(mapped_data, Some(2), "first fetch 'hi' has length 2"); }); - // Refetch — produces "hello world" (length 11) harness.update(cx, |this, cx| { fetch_query( &this.query, @@ -262,11 +226,6 @@ fn test_use_query_select_observer_updates_on_refetch(cx: &mut TestAppContext) { }); } -// -- Gap 10: fetch_query_with_signal FnOnce — no retry on failure ------------ -// -// The FnOnce constraint means no retries. Verify that when the single fetcher -// fails, the resource ends in Failure with exactly 1 call. - #[gpui::test] fn test_fetch_query_with_signal_no_retry_on_failure(cx: &mut TestAppContext) { setup_query_client(cx); @@ -285,7 +244,6 @@ fn test_fetch_query_with_signal_no_retry_on_failure(cx: &mut TestAppContext) { RequestPolicy::LatestWins, cx, ); - // Set retry policy that would allow retries if the fetcher were Fn entity.update(cx, |r, _| r.set_retry_policy(RetryPolicy::new(3))); fetch_query_with_signal( &entity, @@ -317,10 +275,6 @@ fn test_fetch_query_with_signal_no_retry_on_failure(cx: &mut TestAppContext) { ); } -// -- Gap 12: Infinite query stops retry after signal cancelled --------------- -// -// No test verifies that a cancelled infinite query stops retrying mid-loop. - #[gpui::test] fn test_infinite_query_stops_retry_after_signal_cancelled(cx: &mut TestAppContext) { setup_query_client(cx); @@ -352,7 +306,6 @@ fn test_infinite_query_stops_retry_after_signal_cancelled(cx: &mut TestAppContex cx.run_until_parked(); - // The initial fetch failed. Now cancel the signal let entity_ref = cx.update(|cx| harness.read(cx).entity.clone()); cx.update(|cx| { entity_ref.update(cx, |r, _| { @@ -362,7 +315,6 @@ fn test_infinite_query_stops_retry_after_signal_cancelled(cx: &mut TestAppContex }); }); - // Try to fetch next page — signal is cancelled so retries should stop immediately harness.update(cx, |this, cx| { fetch_next_page_infinite( &this.entity, @@ -373,7 +325,6 @@ fn test_infinite_query_stops_retry_after_signal_cancelled(cx: &mut TestAppContex cx.run_until_parked(); - // Verify call count is bounded — the initial fetch + possibly one more attempt let count = *call_count.lock().unwrap(); assert!( count <= 7, diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/mod.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/mod.rs index ddf51f5..a55b7d1 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/mod.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/mod.rs @@ -1,5 +1,3 @@ -//! Gap coverage tests — fill remaining CLIENT and HOOK layer gaps (Gap 1–17). - mod client_bucket_coverage; mod gc_coverage; #[cfg(feature = "hook")] diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs index e300492..c126acf 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs @@ -1,13 +1,9 @@ -//! Mutation registration, lifecycle, cancel, and diagnostics tests (tests 18–23, 56). - use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; use crate::client::{MutationObserver, ObserverConfig, QueryClient, QueryObserver}; use crate::core::*; use crate::tests::test_support::*; -// -- 18. Mutation registration with key -------------------------------------- - #[gpui::test] fn test_mutation_with_key_registration(cx: &mut TestAppContext) { setup_test(cx); @@ -25,28 +21,22 @@ fn test_mutation_with_key_registration(cx: &mut TestAppContext) { }); } -// -- 19. all_mutations returns empty for unregistered type -------------------- - #[gpui::test] fn test_all_mutations_empty_for_unregistered_type(cx: &mut TestAppContext) { setup_test(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Register one type let e = cx.new(|_| { MutationResource::<String, User, QueryError>::new(RetryPolicy::no_retries()) }); client.register_mutation::<String, User, QueryError>(&e, cx); - // Ask for different type triple let other = client.all_mutations::<u32, User, QueryError>(); assert!(other.is_empty(), "no u32 mutations registered"); }); }); } -// -- 20. Multiple mutations of same type -------------------------------------- - #[gpui::test] fn test_multiple_mutations_same_type(cx: &mut TestAppContext) { setup_test(cx); @@ -71,8 +61,6 @@ fn test_multiple_mutations_same_type(cx: &mut TestAppContext) { }); } -// -- 21. Mutation full lifecycle via client: begin -> fail -> retry -> success - #[gpui::test] fn test_mutation_full_lifecycle_with_retries(cx: &mut TestAppContext) { setup_query_client(cx); @@ -82,7 +70,6 @@ fn test_mutation_full_lifecycle_with_retries(cx: &mut TestAppContext) { cx.new(|_| MutationResource::<String, User, QueryError>::new(RetryPolicy::new(2))); client.register_mutation::<String, User, QueryError>(&entity, cx); - // First attempt: begin -> fail entity.update(cx, |m, _| { m.begin("create_user".to_string()); }); @@ -94,13 +81,11 @@ fn test_mutation_full_lifecycle_with_retries(cx: &mut TestAppContext) { assert!(entity.read(cx).is_failure()); assert_eq!(entity.read(cx).retry_count(), 1); - // Retry entity.update(cx, |m, _| { assert!(m.retry()); }); assert!(entity.read(cx).is_loading()); - // Retry succeeds entity.update(cx, |m, _| { m.complete_success(User::new(99, "Retry Success")); }); @@ -110,8 +95,6 @@ fn test_mutation_full_lifecycle_with_retries(cx: &mut TestAppContext) { }); } -// -- 22. Mutation cancel through client -------------------------------------- - #[gpui::test] fn test_mutation_cancel_via_resource(cx: &mut TestAppContext) { setup_query_client(cx); @@ -140,8 +123,6 @@ fn test_mutation_cancel_via_resource(cx: &mut TestAppContext) { }); } -// -- 23. Mutation diagnostics populated -------------------------------------- - #[gpui::test] fn test_diagnostics_includes_mutations_with_status(cx: &mut TestAppContext) { setup_query_client(cx); @@ -179,8 +160,6 @@ fn test_diagnostics_includes_mutations_with_status(cx: &mut TestAppContext) { }); } -// -- 50. ObserverConfig default is status_change_only ------------------------- - #[gpui::test] fn test_observer_config_default(_cx: &mut TestAppContext) { let config = ObserverConfig::default(); @@ -190,8 +169,6 @@ fn test_observer_config_default(_cx: &mut TestAppContext) { ); } -// -- 56. Diagnostics: mutation retry_count tracked --------------------------- - #[gpui::test] fn test_diagnostics_mutation_retry_count(cx: &mut TestAppContext) { setup_query_client(cx); @@ -216,8 +193,6 @@ fn test_diagnostics_mutation_retry_count(cx: &mut TestAppContext) { }); } -// -- Mutation observer tests (originally 45-48) -------------------------------- - #[gpui::test] fn test_query_observer_observe_succeeds_for_live_entity(cx: &mut TestAppContext) { setup_query_client(cx); @@ -226,7 +201,6 @@ fn test_query_observer_observe_succeeds_for_live_entity(cx: &mut TestAppContext) let entity = client.resource::<String, QueryError>("live_obs", cx); let mut observer = QueryObserver::new(&entity); - // instead of a local `struct DummyView;` + manual view dance. let result = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); assert!( result.is_some(), @@ -272,8 +246,6 @@ fn test_mutation_observer_weak_entity_pattern(cx: &mut TestAppContext) { }); } -// -- 51. QueryObserver with_config custom settings --------------------------- - #[gpui::test] fn test_query_observer_with_config_always_notify(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs index 1299e34..b500639 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs @@ -1,13 +1,9 @@ -//! Fetch, prefetch, and cancel query tests (tests 31–38). - use gpui::{BorrowAppContext as _, TestAppContext}; use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -// -- 31. prepare_fetch_query always starts (uses Force mode) ----------------- - #[gpui::test] fn test_prepare_fetch_query_uses_force_mode_always_starts(cx: &mut TestAppContext) { cx.update(|cx| { @@ -18,22 +14,17 @@ fn test_prepare_fetch_query_uses_force_mode_always_starts(cx: &mut TestAppContex }); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // First fetch succeeds let prepared = client .prepare_fetch_query::<String, QueryError>("cached_key", cx) .expect("first fetch should start"); prepared.complete_success("data".to_string(), cx); - // prepare_fetch_query uses QueryFetchMode::Force, so it always - // starts a new request even when the cache is fresh. This matches - // TanStack Query's fetchQuery behavior. let second = client.prepare_fetch_query::<String, QueryError>("cached_key", cx); assert!( second.is_some(), "prepare_fetch_query uses Force mode, always starts" ); - // Data should still be accessible from the first fetch let data = client.get_query_data::<String, QueryError>(&QueryKey::from("cached_key"), cx); assert_eq!(data, Some("data".to_string())); @@ -41,10 +32,6 @@ fn test_prepare_fetch_query_uses_force_mode_always_starts(cx: &mut TestAppContex }); } -// prepare_fetch_query returns Some on the initial call and on a Force-mode -// call. Full TTL expiry lives in core_cache.rs, where timestamps are -// controllable via apply_success(data, now_ms). - #[gpui::test] fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { cx.update(|cx| { @@ -55,19 +42,14 @@ fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { }); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // First fetch let prepared = client .prepare_fetch_query::<String, QueryError>("ttl_key", cx) .expect("first fetch should start"); prepared.complete_success("old".to_string(), cx); - // Data should be present after first fetch let data = client.get_query_data::<String, QueryError>(&QueryKey::from("ttl_key"), cx); assert_eq!(data, Some("old".to_string())); - // prepare_fetch_query always returns Some (Force mode), even when - // cache is fresh. This is the core guarantee: it always initiates - // a fetch, unlike prepare_prefetch_query which respects freshness. let second = client.prepare_fetch_query::<String, QueryError>("ttl_key", cx); assert!( second.is_some(), @@ -78,10 +60,6 @@ fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { }); } -// prepare_prefetch_query returns None for fresh data. The 60s TTL makes -// this deterministic: one captured `now` drives both apply_success and the -// freshness check unless the wall clock jumps a full minute between them. - #[gpui::test] fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { cx.update(|cx| { @@ -94,16 +72,9 @@ fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("prefresh_fresh"); let entity = client.resource::<String, QueryError>(key.clone(), cx); - // Capture `now` once and use it for the data timestamp so the - // freshness check inside prepare_prefetch_query (which calls - // current_time_ms() again nanoseconds later) sees age ~0ms. let now = crate::client::current_time_ms(); entity.update(cx, |r, _| r.apply_success("fresh_data".to_string(), now)); - // Explicit precondition: verify the age is well within the 60s - // TTL before asserting on prepare_prefetch_query's result. This - // makes any wall-clock discontinuity surface as a clear - // precondition failure rather than a silent flake. let age_ms = crate::client::current_time_ms().saturating_sub(now); assert!( age_ms < 60_000, @@ -111,9 +82,6 @@ fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { age_ms, ); - // prepare_prefetch_query uses Normal mode. Since the data was set - // at ~now, age is ~0ms, which is within the 60s TTL, so the cache - // is fresh and prefetch should return None (no fetch needed). let result = client.prepare_prefetch_query::<String, QueryError>( key.clone(), CachePolicy::Ttl { ttl_ms: 60_000 }, @@ -129,8 +97,6 @@ fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { }); } -// -- 34. PreparedFetch complete_failure stores error ------------------------- - #[gpui::test] fn test_prepared_fetch_complete_failure_stores_error(cx: &mut TestAppContext) { setup_query_client(cx); @@ -152,8 +118,6 @@ fn test_prepared_fetch_complete_failure_stores_error(cx: &mut TestAppContext) { }); } -// -- 35. PreparedFetch signal starts uncancelled ----------------------------- - #[gpui::test] fn test_prepared_fetch_signal_properties(cx: &mut TestAppContext) { setup_query_client(cx); @@ -172,20 +136,16 @@ fn test_prepared_fetch_signal_properties(cx: &mut TestAppContext) { "request_id should have a positive value" ); - // Complete to clean up prepared.complete_success("data".to_string(), cx); }); }); } -// -- 36. cancel_queries cancels resources across multiple type buckets ------- - #[gpui::test] fn test_cancel_queries_across_type_buckets(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create and start requests for two different types with the same string key let key_s = QueryKey::from("target"); let entity_s = client.resource::<String, QueryError>(key_s.clone(), cx); let rid_s = client @@ -207,7 +167,6 @@ fn test_cancel_queries_across_type_buckets(cx: &mut TestAppContext) { let sig_s = entity_s.read(cx).signal().unwrap().clone(); let sig_u = entity_u.read(cx).signal().unwrap().clone(); - // Cancel all queries with key "target" (Exact filter) client.cancel_queries(&QueryKeyFilter::Exact(&key_s), cx); assert!(sig_s.is_cancelled(), "String query should be cancelled"); @@ -216,14 +175,11 @@ fn test_cancel_queries_across_type_buckets(cx: &mut TestAppContext) { }); } -// -- 37. cancel_queries with All filter cancels everything ------------------- - #[gpui::test] fn test_cancel_queries_all_filter(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Start two loading queries let key1 = QueryKey::from("a1"); let key2 = QueryKey::from("a2"); let e1 = client.resource::<String, QueryError>(key1.clone(), cx); @@ -253,8 +209,6 @@ fn test_cancel_queries_all_filter(cx: &mut TestAppContext) { }); } -// -- 38. cancel_queries does not affect idle infinite queries ---------------- - #[gpui::test] fn test_cancel_queries_skips_idle_infinite_queries(cx: &mut TestAppContext) { setup_query_client(cx); @@ -263,7 +217,6 @@ fn test_cancel_queries_skips_idle_infinite_queries(cx: &mut TestAppContext) { let key = QueryKey::from("inf_idle"); let _entity = client.infinite_resource::<String, QueryError>(key.clone(), cx); - // Should not panic or affect the idle infinite query client.cancel_queries(&QueryKeyFilter::Exact(&key), cx); let retrieved = client.infinite_query::<String, QueryError>(&key); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs index 9c08b61..7fec77b 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs @@ -1,16 +1,9 @@ -//! GC, query operations (remove/invalidate/reset), observer, and misc tests -//! (tests 39–44, 49, 52–55, 57–61). - use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; use crate::client::{InfiniteQueryObserver, QueryClient}; use crate::core::*; use crate::tests::test_support::*; -// GC clamps gc_time=0 to 1000ms. An Idle resource with no snapshot timestamp -// (never fetched) counts as "age == gc_threshold", so it is evicted at any -// gc_with_time value. - #[gpui::test] fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 0); @@ -19,9 +12,6 @@ fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { let _entity = client.resource::<String, QueryError>("gc_zero", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); - // gc_time_ms=0 is clamped to 1000ms. The resource is Idle with no - // snapshot timestamp, so its age defaults to gc_threshold (1000ms), - // meaning age >= threshold and it gets evicted. client.gc_with_time(0, cx); assert_eq!( client.all_queries::<String, QueryError>().len(), @@ -33,23 +23,15 @@ fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { }); } -// gc_with_time reads live entity state directly (no cached snapshot), so -// resources created via client.resource() appear Idle with last_updated_ms -// None and are evicted at any gc_with_time value. - #[gpui::test] fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create two resources: one to be evicted, one to verify removal let _e1 = client.resource::<String, QueryError>("gc_evict", cx); let _e2 = client.resource::<String, QueryError>("gc_evict2", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 2); - // First GC at small time: both Idle resources with no snapshot - // timestamp have age == gc_threshold (clamped to 1000ms), so - // age < gc_threshold is false and they get evicted. client.gc_with_time(500, cx); assert_eq!( client.all_queries::<String, QueryError>().len(), @@ -58,7 +40,6 @@ fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { at gc_with_time(500) — their age defaults to the clamped gc_threshold" ); - // Create another resource and run GC at a very large time. let _e3 = client.resource::<String, QueryError>("gc_big_time", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); @@ -69,7 +50,6 @@ fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { "Idle resource should also be evicted at gc_with_time(100_000)" ); - // After eviction, diagnostics should report zero queries. let diag = client.diagnostics(cx); assert_eq!( diag.query_count, 0, @@ -79,23 +59,15 @@ fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { }); } -// -- 41. GC runs across all bucket types (query, infinite, mutation) --------- -// -// Finding 3 fix: After GC, assert that idle resources with no snapshot -// updates ARE evicted, and loading resources are preserved. - #[gpui::test] fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create idle query (no fetch) — should be evicted by GC let _q = client.resource::<String, QueryError>("q_gc", cx); - // Create idle infinite query (no fetch) — should be evicted by GC let _iq = client.infinite_resource::<String, QueryError>("iq_gc", cx); - // Create a loading mutation — loading resources should survive GC let loading_mut = cx.new(|_| { MutationResource::<String, User, QueryError>::new(RetryPolicy::no_retries()) }); @@ -107,15 +79,12 @@ fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { }); client.register_mutation::<String, User, QueryError>(&idle_mut, cx); - // Pre-GC counts assert_eq!(client.all_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_infinite_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_mutations::<String, User, QueryError>().len(), 2); - // GC at far future time client.gc_with_time(100_000, cx); - // Post-GC: idle resources with no snapshot timestamp are evicted assert!( client.all_queries::<String, QueryError>().is_empty(), "idle query with no snapshot should be evicted by GC" @@ -125,7 +94,6 @@ fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { "idle infinite query with no snapshot should be evicted by GC" ); - // Loading mutation should survive GC (loading resources are never evicted) let mutations = client.all_mutations::<String, User, QueryError>(); assert_eq!( mutations.len(), @@ -136,8 +104,6 @@ fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { }); } -// -- 42. remove_queries removes from infinite buckets too -------------------- - #[gpui::test] fn test_remove_queries_affects_infinite_queries(cx: &mut TestAppContext) { setup_query_client(cx); @@ -167,16 +133,12 @@ fn test_remove_queries_affects_infinite_queries(cx: &mut TestAppContext) { }); } -// -- 43. invalidate_queries affects infinite queries too --------------------- - #[gpui::test] fn test_invalidate_queries_affects_infinite_queries(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { let _iq = client.infinite_resource::<String, QueryError>("inf_inv", cx); - // Note: InfiniteQueryResource doesn't have apply_success in the same way, - // but we can still verify invalidate doesn't panic and the entity remains. client.invalidate_queries(&QueryKeyFilter::All, cx); let retrieved = client.infinite_query::<String, QueryError>(&QueryKey::from("inf_inv")); @@ -188,8 +150,6 @@ fn test_invalidate_queries_affects_infinite_queries(cx: &mut TestAppContext) { }); } -// -- 44. reset_queries affects infinite queries ------------------------------ - #[gpui::test] fn test_reset_queries_affects_infinite_queries(cx: &mut TestAppContext) { setup_query_client(cx); @@ -204,14 +164,11 @@ fn test_reset_queries_affects_infinite_queries(cx: &mut TestAppContext) { retrieved.is_some(), "infinite query should exist after reset" ); - // InfiniteQueryResource in idle state assert_eq!(retrieved.unwrap().read(cx).status(), QueryStatus::Idle); }); }); } -// -- 49. Infinite query observer creation and observe ------------------------ - #[gpui::test] fn test_infinite_query_observer_creation_and_observe(cx: &mut TestAppContext) { setup_query_client(cx); @@ -244,14 +201,9 @@ fn test_infinite_query_observer_weak_entity_pattern(cx: &mut TestAppContext) { }); } -// -- 52. current_time_ms is reasonable --------------------------------------- - #[gpui::test] fn test_current_time_ms_is_reasonable(_cx: &mut TestAppContext) { let now = crate::client::current_time_ms(); - // Should be > 1_700_000_000_000 (after 2023). Upper bound widened to - // 4_000_000_000_000 (pre-year-2128) so the test doesn't fail - // once wall-clock crosses the old 2_000_000_000_000 (2033) threshold. assert!( now > 1_700_000_000_000, "current_time_ms should be post-2023" @@ -262,14 +214,11 @@ fn test_current_time_ms_is_reasonable(_cx: &mut TestAppContext) { ); } -// -- 53. Multiple resources share same type bucket --------------------------- - #[gpui::test] fn test_multiple_resources_same_type_share_bucket(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Create 10 resources of same type for i in 0..10 { let key = format!("multi_{i}"); let _e = client.resource::<String, QueryError>(key, cx); @@ -285,12 +234,6 @@ fn test_multiple_resources_same_type_share_bucket(cx: &mut TestAppContext) { }); } -// -- 54. invalidate then re-fetch lifecycle ---------------------------------- -// -// Finding 6 fix: Assert that prepare_fetch_query always returns Some after -// invalidation (since the cache is invalidated, Force mode should start a -// new fetch regardless). - #[gpui::test] fn test_invalidate_then_refetch_lifecycle(cx: &mut TestAppContext) { setup_query_client(cx); @@ -298,7 +241,6 @@ fn test_invalidate_then_refetch_lifecycle(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { let key = QueryKey::from("inv_refetch"); - // Fetch and succeed let p1 = client .prepare_fetch_query::<String, QueryError>(key.clone(), cx) .expect("first fetch"); @@ -309,12 +251,8 @@ fn test_invalidate_then_refetch_lifecycle(cx: &mut TestAppContext) { Some("v1".to_string()) ); - // Invalidate — marks the cache as stale client.invalidate_queries(&QueryKeyFilter::Exact(&key), cx); - // After invalidation, prepare_fetch_query (Force mode) must always - // start a new request. This is a guaranteed contract: Force mode - // ignores cache freshness, so the result is always Some. let p2 = client.prepare_fetch_query::<String, QueryError>(key.clone(), cx); assert!( p2.is_some(), @@ -331,8 +269,6 @@ fn test_invalidate_then_refetch_lifecycle(cx: &mut TestAppContext) { }); } -// -- 55. Reset clears data then set_query_data re-populates ------------------ - #[gpui::test] fn test_reset_then_set_query_data(cx: &mut TestAppContext) { setup_query_client(cx); @@ -342,20 +278,16 @@ fn test_reset_then_set_query_data(cx: &mut TestAppContext) { let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| r.apply_success("original".to_string(), 1_000)); - // Reset clears everything client.reset_queries(&QueryKeyFilter::Exact(&key), cx); assert!(entity.read(cx).data().is_none()); assert_eq!(entity.read(cx).status(), QueryStatus::Idle); - // Re-populate via set_query_data client.set_query_data::<String, QueryError>(key, "restored".to_string(), cx); assert_eq!(entity.read(cx).data().unwrap(), "restored"); }); }); } -// -- 57. Large number of resources creation ---------------------------------- - #[gpui::test] fn test_large_number_of_resources_creation(cx: &mut TestAppContext) { setup_query_client(cx); @@ -371,8 +303,6 @@ fn test_large_number_of_resources_creation(cx: &mut TestAppContext) { }); } -// -- 58. QueryKey with multiple segments in client operations ---------------- - #[gpui::test] fn test_multi_segment_key_in_client_operations(cx: &mut TestAppContext) { setup_query_client(cx); @@ -384,11 +314,9 @@ fn test_multi_segment_key_in_client_operations(cx: &mut TestAppContext) { r.apply_success("deep_key_data".to_string(), 1_000) }); - // Query by the full key let found = client.query::<String, QueryError>(&key); assert!(found.is_some()); - // Invalidate by prefix "org/team" let prefix = QueryKey::from(["org", "team"]); client.invalidate_queries(&QueryKeyFilter::Prefix(&prefix), cx); assert!( @@ -396,10 +324,8 @@ fn test_multi_segment_key_in_client_operations(cx: &mut TestAppContext) { "should be stale after prefix invalidate" ); - // Data should survive invalidation assert_eq!(entity.read(cx).data().unwrap(), "deep_key_data"); - // Reset by prefix client.reset_queries(&QueryKeyFilter::Prefix(&prefix), cx); assert!( entity.read(cx).data().is_none(), @@ -409,14 +335,11 @@ fn test_multi_segment_key_in_client_operations(cx: &mut TestAppContext) { }); } -// -- 59. set_query_data with different T types doesn't conflict -------------- - #[gpui::test] fn test_set_query_data_different_types_no_conflict(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Same key, different types client.set_query_data::<String, QueryError>("shared_key", "string_val".to_string(), cx); client.set_query_data::<u32, QueryError>("shared_key", 42_u32, cx); @@ -429,8 +352,6 @@ fn test_set_query_data_different_types_no_conflict(cx: &mut TestAppContext) { }); } -// -- 60. clear_data on resource via client context --------------------------- - #[gpui::test] fn test_clear_data_via_resource(cx: &mut TestAppContext) { setup_query_client(cx); @@ -444,19 +365,12 @@ fn test_clear_data_via_resource(cx: &mut TestAppContext) { entity.update(cx, |r, _| r.clear_data()); assert!(entity.read(cx).data().is_none(), "data should be cleared"); - // get_query_data should now return None let data = client.get_query_data::<String, QueryError>(&key, cx); assert!(data.is_none()); }); }); } -// -- 61. prepare_prefetch_query returns Some for stale data ----------------- -// -// Finding 4/7 fix: This test asserts the actual return value. When data -// was set at t=0 (long ago) and prefetch checks at current_time_ms, -// the data is stale, so prefetch should return Some. - #[gpui::test] fn test_prepare_prefetch_query_returns_some_for_stale(cx: &mut TestAppContext) { cx.update(|cx| { @@ -467,15 +381,10 @@ fn test_prepare_prefetch_query_returns_some_for_stale(cx: &mut TestAppContext) { }); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // Populate with data at t=0 (epoch) — guaranteed stale now let key = QueryKey::from("prefresh_stale"); let entity = client.resource::<String, QueryError>(key.clone(), cx); entity.update(cx, |r, _| r.apply_success("stale_data".to_string(), 0)); - // Data is from t=0 and current_time_ms() is ~1.7 trillion ms, - // so the resource is definitely stale (age >> 60_000ms TTL). - // prepare_prefetch_query uses Normal mode, which respects freshness, - // so it should start a fetch for stale data. let result = client.prepare_prefetch_query::<String, QueryError>( key.clone(), CachePolicy::Ttl { ttl_ms: 60_000 }, diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/mod.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/mod.rs index b6d24dd..ddcfcaf 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/mod.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/mod.rs @@ -1,6 +1,3 @@ -//! Diagnostics, dehydrate/hydrate, persister, fetch/prefetch, cancel, GC, -//! query operations, and observer tests (tests 24–44, 49, 52–55, 57–61). - #[cfg(feature = "persist")] mod diagnostics_dehydrate_persister; mod fetch_prefetch_cancel; diff --git a/crates/gpui-query/src/tests/integration_client_coverage/mod.rs b/crates/gpui-query/src/tests/integration_client_coverage/mod.rs index ea5abad..0698471 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/mod.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/mod.rs @@ -1,8 +1,3 @@ -//! Additional coverage tests for the QueryClient client layer (v2). -//! -//! Fills gaps not covered by `integration_client.rs`. Tests use `#[gpui::test]` -//! with `TestAppContext` and the `test_support` helpers. - mod client_basics; mod client_gap_coverage; mod client_mutations; diff --git a/crates/gpui-query/src/tests/property_tests/cache_policy_retry.rs b/crates/gpui-query/src/tests/property_tests/cache_policy_retry.rs index a2fa045..1d0f06d 100644 --- a/crates/gpui-query/src/tests/property_tests/cache_policy_retry.rs +++ b/crates/gpui-query/src/tests/property_tests/cache_policy_retry.rs @@ -1,15 +1,7 @@ -//! Property-based tests for CachePolicy and RetryPolicy. -//! -//! Uses proptest to verify structural properties hold for all possible inputs, -//! including edge cases like u64::MAX, zero values, and overflow scenarios. - use proptest::prelude::*; use crate::core::*; -// ── Helpers ───────────────────────────────────────────────────────────── - -/// Arbitrary CachePolicy strategy covering all three variants with wide value ranges. fn arb_cache_policy() -> impl Strategy<Value = CachePolicy> { prop_oneof![ Just(CachePolicy::NoCache), @@ -20,8 +12,6 @@ fn arb_cache_policy() -> impl Strategy<Value = CachePolicy> { ] } -/// Arbitrary RetryPolicy strategy with physically plausible values. -/// Ensures max_retry_delay_ms >= retry_delay_ms so the max-delay cap is meaningful. fn arb_retry_policy() -> impl Strategy<Value = RetryPolicy> { (any::<u32>(), 1u64..=60_000, any::<bool>()) .prop_flat_map(|(max_retries, retry_delay_ms, exponential_backoff)| { @@ -43,7 +33,6 @@ fn arb_retry_policy() -> impl Strategy<Value = RetryPolicy> { ) } -/// RetryPolicy strategy that always has exponential backoff enabled. fn arb_exponential_retry_policy() -> impl Strategy<Value = RetryPolicy> { (any::<u32>(), 1u64..=60_000) .prop_flat_map(|(max_retries, retry_delay_ms)| { @@ -63,7 +52,6 @@ fn arb_exponential_retry_policy() -> impl Strategy<Value = RetryPolicy> { ) } -/// RetryPolicy strategy that always has linear (non-exponential) backoff. fn arb_linear_retry_policy() -> impl Strategy<Value = RetryPolicy> { (any::<u32>(), 1u64..=60_000) .prop_flat_map(|(max_retries, retry_delay_ms)| { @@ -83,19 +71,15 @@ fn arb_linear_retry_policy() -> impl Strategy<Value = RetryPolicy> { ) } -// ── CachePolicy::NoCache invariants ───────────────────────────────────── - proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// NoCache: is_fresh() is ALWAYS false regardless of age. #[test] fn nocache_is_fresh_always_false(age_ms in any::<u64>()) { let policy = CachePolicy::NoCache; prop_assert!(!policy.is_fresh(age_ms)); } - /// NoCache: is_expired() is ALWAYS true regardless of age. #[test] fn nocache_is_expired_always_true(age_ms in any::<u64>()) { let policy = CachePolicy::NoCache; @@ -118,12 +102,9 @@ fn nocache_ttl_ms_is_none() { assert_eq!(CachePolicy::NoCache.ttl_ms(), None); } -// ── CachePolicy::Ttl invariants ───────────────────────────────────────── - proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// Ttl: is_fresh(age) == (age <= ttl_ms) for ALL u64 value pairs. #[test] fn ttl_is_fresh_matches_comparison(ttl_ms in any::<u64>(), age_ms in any::<u64>()) { let policy = CachePolicy::Ttl { ttl_ms }; @@ -131,28 +112,24 @@ proptest! { prop_assert_eq!(policy.is_fresh(age_ms), expected); } - /// Ttl: can_short_circuit() is ALWAYS true. #[test] fn ttl_can_short_circuit_always_true(ttl_ms in any::<u64>()) { let policy = CachePolicy::Ttl { ttl_ms }; prop_assert!(policy.can_short_circuit()); } - /// Ttl: can_serve_stale() is ALWAYS false (no stale window in pure TTL). #[test] fn ttl_can_serve_stale_always_false(ttl_ms in any::<u64>()) { let policy = CachePolicy::Ttl { ttl_ms }; prop_assert!(!policy.can_serve_stale()); } - /// Ttl: is_stale_but_serveable() is ALWAYS false (no stale window). #[test] fn ttl_is_stale_but_serveable_always_false(ttl_ms in any::<u64>(), age_ms in any::<u64>()) { let policy = CachePolicy::Ttl { ttl_ms }; prop_assert!(!policy.is_stale_but_serveable(age_ms)); } - /// Ttl: total_valid_ms() == Some(ttl_ms). #[test] fn ttl_total_valid_ms_equals_ttl(ttl_ms in any::<u64>()) { let policy = CachePolicy::Ttl { ttl_ms }; @@ -160,12 +137,9 @@ proptest! { } } -// ── CachePolicy::StaleWhileRevalidate invariants ──────────────────────── - proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// SWR: is_fresh(age) == (age <= ttl_ms). #[test] fn swr_is_fresh_uses_ttl_only( ttl_ms in any::<u64>(), @@ -177,22 +151,18 @@ proptest! { prop_assert_eq!(policy.is_fresh(age_ms), expected); } - /// SWR: can_short_circuit() is ALWAYS true. #[test] fn swr_can_short_circuit_always_true(ttl_ms in any::<u64>(), stale_ms in any::<u64>()) { let policy = CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }; prop_assert!(policy.can_short_circuit()); } - /// SWR: can_serve_stale() is ALWAYS true. #[test] fn swr_can_serve_stale_always_true(ttl_ms in any::<u64>(), stale_ms in any::<u64>()) { let policy = CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }; prop_assert!(policy.can_serve_stale()); } - /// SWR: is_stale_but_serveable(age) is true exactly when - /// age > ttl_ms AND age <= ttl_ms + stale_ms. #[test] fn swr_stale_serveable_window( ttl_ms in any::<u64>(), @@ -205,7 +175,6 @@ proptest! { prop_assert_eq!(policy.is_stale_but_serveable(age_ms), expected); } - /// SWR: total_valid_ms() saturates at u64::MAX on overflow. #[test] fn swr_total_valid_ms_saturates(ttl_ms in any::<u64>(), stale_ms in any::<u64>()) { let policy = CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms }; @@ -214,12 +183,9 @@ proptest! { } } -// ── CachePolicy cross-variant invariants ──────────────────────────────── - proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// For any CachePolicy, data that is fresh is never expired. #[test] fn fresh_implies_not_expired(policy in arb_cache_policy(), age_ms in any::<u64>()) { if policy.is_fresh(age_ms) { @@ -227,7 +193,6 @@ proptest! { } } - /// For any CachePolicy, data that is stale-but-serveable is never expired. #[test] fn stale_serveable_implies_not_expired(policy in arb_cache_policy(), age_ms in any::<u64>()) { if policy.is_stale_but_serveable(age_ms) { @@ -235,7 +200,6 @@ proptest! { } } - /// For any CachePolicy, data is either fresh, stale-but-serveable, or expired. #[test] fn fresh_stale_expired_covers_all_states( policy in arb_cache_policy(), @@ -251,12 +215,9 @@ proptest! { } } -// ── RetryPolicy invariants ───────────────────────────────────────────── - proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// should_retry(n) == (n < max_retries) for all valid n. #[test] fn should_retry_matches_count( policy in arb_retry_policy(), @@ -266,13 +227,11 @@ proptest! { prop_assert_eq!(policy.should_retry(current_retries), expected); } - /// delay_for_attempt(0) always equals retry_delay_ms. #[test] fn delay_for_attempt_zero_is_base_delay(policy in arb_retry_policy()) { prop_assert_eq!(policy.delay_for_attempt(0), policy.retry_delay_ms); } - /// delay_for_attempt never exceeds max_retry_delay_ms. #[test] fn delay_never_exceeds_max(policy in arb_retry_policy(), attempt in any::<u32>()) { let delay = policy.delay_for_attempt(attempt); @@ -285,7 +244,6 @@ proptest! { ); } - /// delay_for_attempt never exceeds the absolute ceiling of 1 hour. #[test] fn delay_never_exceeds_absolute_ceiling( policy in arb_retry_policy(), @@ -300,7 +258,6 @@ proptest! { ); } - /// With exponential backoff, delays are monotonically non-decreasing. #[test] fn exponential_delays_monotonically_non_decreasing(policy in arb_exponential_retry_policy()) { let mut prev = policy.delay_for_attempt(0); @@ -317,13 +274,11 @@ proptest! { } } - /// Without exponential backoff, delay_for_attempt returns the same value for all attempts. #[test] fn linear_delays_are_constant(policy in arb_linear_retry_policy(), attempt in any::<u32>()) { prop_assert_eq!(policy.delay_for_attempt(attempt), policy.retry_delay_ms); } - /// delay_for_attempt never panics, even at u32::MAX attempt values. #[test] fn delay_does_not_panic_on_large_attempt(policy in arb_retry_policy()) { let _ = policy.delay_for_attempt(u32::MAX); diff --git a/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs b/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs index 05383f8..f119f82 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs @@ -1,15 +1,7 @@ -//! Deterministic edge-case tests for QueryKey. -//! -//! Covers codepoints that proptest regex strategies (\p{L}, \p{N}, etc.) -//! do not reliably generate: zero-width characters, combining marks, RTL -//! overrides, BOM, replacement characters, and mixed normalization forms. - use crate::core::*; use super::strategies::*; -// Standalone deterministic edge-case tests (no proptest parameters needed). - #[test] fn key_empty_string_segment_distinguishes_from_multi() { let single_empty = QueryKey::from([""]); @@ -30,35 +22,30 @@ fn key_single_empty_segment_properties() { #[test] fn key_zero_width_space_segment() { - // U+200B Zero Width Space let key = QueryKey::from(["\u{200B}"]); assert_key_invariants(&key, "\u{200B}"); } #[test] fn key_zero_width_joiner_segment() { - // U+200D ZWJ (used in emoji sequences like family emoji) let key = QueryKey::from(["\u{200D}"]); assert_key_invariants(&key, "\u{200D}"); } #[test] fn key_zwnj_segment() { - // U+200C Zero Width Non-Joiner let key = QueryKey::from(["\u{200C}"]); assert_key_invariants(&key, "\u{200C}"); } #[test] fn key_bom_segment() { - // U+FEFF Byte Order Mark / Zero Width No-Break Space let key = QueryKey::from(["\u{FEFF}"]); assert_key_invariants(&key, "\u{FEFF}"); } #[test] fn key_combining_acute_accent_nfd() { - // "e" + U+0301 combining acute accent = é in NFD form let nfd = "e\u{0301}"; let key = QueryKey::from([nfd]); assert_key_invariants(&key, nfd); @@ -66,12 +53,10 @@ fn key_combining_acute_accent_nfd() { #[test] fn key_combining_precomposed_nfc() { - // U+00E9 é (precomposed, NFC form) — must differ from NFD form let nfc = "\u{00E9}"; let nfd = "e\u{0301}"; let key_nfc = QueryKey::from([nfc]); let key_nfd = QueryKey::from([nfd]); - // NFC and NFD are different byte sequences so keys must differ assert_ne!(key_nfc, key_nfd, "NFC and NFD keys should be distinct"); assert_key_invariants(&key_nfc, nfc); assert_key_invariants(&key_nfd, nfd); @@ -79,14 +64,12 @@ fn key_combining_precomposed_nfc() { #[test] fn key_standalone_combining_mark() { - // Combining grave accent with no base character let key = QueryKey::from(["\u{0300}"]); assert_key_invariants(&key, "\u{0300}"); } #[test] fn key_long_combining_chain() { - // Base char followed by many combining marks let segment = format!("a{}", "\u{0301}".repeat(50)); let key = QueryKey::from([&*segment]); assert_key_invariants(&key, &segment); @@ -94,49 +77,42 @@ fn key_long_combining_chain() { #[test] fn key_rtl_override() { - // U+202E Right-to-Left Override let key = QueryKey::from(["\u{202E}hello\u{202C}"]); assert_key_invariants(&key, "\u{202E}hello\u{202C}"); } #[test] fn key_bidi_isolates() { - // U+2066 LRI, U+2067 RLI, U+2068 FSI, U+2069 PDI let key = QueryKey::from(["\u{2066}\u{2067}\u{2068}\u{2069}"]); assert_key_invariants(&key, "\u{2066}\u{2067}\u{2068}\u{2069}"); } #[test] fn key_replacement_character() { - // U+FFFD replacement character let key = QueryKey::from(["\u{FFFD}"]); assert_key_invariants(&key, "\u{FFFD}"); } #[test] fn key_soft_hyphen() { - // U+00AD soft hyphen let key = QueryKey::from(["\u{00AD}"]); assert_key_invariants(&key, "\u{00AD}"); } #[test] fn key_non_breaking_space() { - // U+00A0 non-breaking space let key = QueryKey::from(["\u{00A0}"]); assert_key_invariants(&key, "\u{00A0}"); } #[test] fn key_ideographic_space() { - // U+3000 ideographic space let key = QueryKey::from(["\u{3000}"]); assert_key_invariants(&key, "\u{3000}"); } #[test] fn key_mixed_rtl_and_ltr() { - // Mixed Arabic and Latin text let segment = "hello\u{0627}\u{0628}\u{062A}world"; let key = QueryKey::from([segment]); assert_key_invariants(&key, segment); @@ -144,7 +120,6 @@ fn key_mixed_rtl_and_ltr() { #[test] fn key_only_zero_width_chars_segment() { - // String composed entirely of zero-width characters let segment = "\u{200B}\u{200C}\u{200D}\u{FEFF}"; let key = QueryKey::from([segment]); assert_key_invariants(&key, segment); @@ -153,7 +128,6 @@ fn key_only_zero_width_chars_segment() { #[test] #[ignore = "stress: 2000-char single segment — run with --ignored"] fn key_very_long_single_segment() { - // 2000-character single segment to stress allocation and hashing let segment = "x".repeat(2000); let key = QueryKey::from([&*segment]); assert_key_invariants(&key, &segment); @@ -162,11 +136,9 @@ fn key_very_long_single_segment() { #[test] fn key_unicode_edge_case_in_multi_segment_key() { - // Ensure edge-case segments interact correctly with the "::" separator let key = QueryKey::from(["\u{200B}", "\u{FEFF}", "\u{FFFD}"]); let expected_path = "\u{200B}::\u{FEFF}::\u{FFFD}"; assert_key_invariants(&key, expected_path); - // Verify prefix matching works with these segments let prefix = QueryKey::from(["\u{200B}"]); assert!(key.starts_with(&prefix)); let prefix2 = QueryKey::from(["\u{200B}", "\u{FEFF}"]); @@ -177,11 +149,9 @@ fn key_unicode_edge_case_in_multi_segment_key() { #[test] #[ignore = "stress: 200-segment key — run with --ignored"] fn key_deeply_nested_200_segments() { - // Stress-test: 200-segment key should still satisfy all invariants let segments: Vec<String> = (0..200).map(|i| format!("seg{}", i)).collect(); let key = make_key(&segments); assert_key_invariants(&key, &segments.join("::")); - // Prefix matching at various depths for depth in [1, 50, 100, 199] { let prefix_segs: Vec<String> = segments[..depth].to_vec(); let prefix = make_key(&prefix_segs); @@ -195,7 +165,6 @@ fn key_deeply_nested_200_segments() { #[test] fn key_unicode_edge_cases_distinct_keys() { - // Different zero-width characters must produce distinct keys let zwsp = QueryKey::from(["\u{200B}"]); let zwnj = QueryKey::from(["\u{200C}"]); let zwj = QueryKey::from(["\u{200D}"]); @@ -208,7 +177,6 @@ fn key_unicode_edge_cases_distinct_keys() { assert_ne!(zwnj, bom); assert_ne!(zwj, bom); - // They should also have distinct hashes (not guaranteed but very likely) let hashes: Vec<u64> = [&zwsp, &zwnj, &zwj, &bom] .iter() .map(|k| hash_of(k)) diff --git a/crates/gpui-query/src/tests/property_tests/query_key/mod.rs b/crates/gpui-query/src/tests/property_tests/query_key/mod.rs index fbff01a..320240a 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/mod.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/mod.rs @@ -1,5 +1,3 @@ -//! Property-based tests for QueryKey and QueryKeyFilter. - mod deterministic_tests; mod proptests; mod strategies; diff --git a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs index d7e882d..02fb088 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs @@ -1,16 +1,9 @@ -//! Property-based tests for QueryKey and QueryKeyFilter. -//! -//! Uses proptest to verify structural properties hold for all possible inputs, -//! including edge cases like unicode, zero-width characters, and long keys. - use proptest::prelude::*; use crate::core::*; use super::strategies::*; -// 64 cases (down from the default 256) keeps the default `cargo test` run -// cheap; the heavyweight long-key invariants live in deterministic_tests. fn test_config() -> ProptestConfig { ProptestConfig { cases: 64, @@ -18,12 +11,9 @@ fn test_config() -> ProptestConfig { } } -// ── 1. Equality ───────────────────────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// key1 == key2 iff all segments match. #[test] fn key_equality_same_segments(segments in arb_key()) { let k1 = make_key(&segments); @@ -31,7 +21,6 @@ proptest! { prop_assert!(k1 == k2); } - /// Different segment lists produce unequal keys. #[test] fn key_equality_different_segments(a in arb_segments(), b in arb_segments()) { prop_assume!(a != b); @@ -41,12 +30,9 @@ proptest! { } } -// ── 2. Hash consistency ───────────────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// Equal keys must produce equal hashes. #[test] fn key_hash_consistency(segments in arb_key()) { let k1 = make_key(&segments); @@ -55,30 +41,23 @@ proptest! { } } -// ── 3. Clone ──────────────────────────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// Cloning produces an equal key backed by the same Arc allocation. #[test] fn key_clone_equality(segments in arb_key()) { let key = make_key(&segments); let cloned = key.clone(); prop_assert!(key == cloned); - // Verify cheap cloning: both keys deref to the same slice pointer let key_ptr: *const [std::sync::Arc<str>] = &*key; let cloned_ptr: *const [std::sync::Arc<str>] = &*cloned; prop_assert_eq!(key_ptr, cloned_ptr); } } -// ── 4. Serde roundtrip ────────────────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// deserialize(serialize(key)) == key for multi-segment keys. #[test] fn key_serde_roundtrip(segments in arb_key()) { let key = make_key(&segments); @@ -87,7 +66,6 @@ proptest! { prop_assert!(key == back); } - /// deserialize(serialize(key)) == key for single-string keys. #[test] fn key_serde_single_string_roundtrip(s in any::<String>()) { let key = QueryKey::from_single(&s); @@ -97,19 +75,15 @@ proptest! { } } -// ── 5. Prefix matching (starts_with) ──────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// Every key is a prefix of itself. #[test] fn key_prefix_self_match(segments in arb_key()) { let key = make_key(&segments); prop_assert!(key.starts_with(&key)); } - /// A proper prefix always matches. #[test] fn key_proper_prefix_always_matches( prefix in arb_segments(), @@ -122,7 +96,6 @@ proptest! { prop_assert!(full.starts_with(&prefix_key)); } - /// Keys that diverge at the tail are not prefixes of each other. #[test] fn key_different_tail_does_not_match( common in arb_segments(), @@ -143,7 +116,6 @@ proptest! { prop_assert!(key_b.starts_with(&common_key)); } - /// A longer key is never a prefix of a shorter key. #[test] fn key_longer_never_prefix_of_shorter( short in arb_segments(), @@ -158,12 +130,9 @@ proptest! { } } -// ── 6. to_path format ─────────────────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// to_path joins segments with "::" separator. #[test] fn key_to_path_format(segments in arb_key()) { let key = make_key(&segments); @@ -173,12 +142,9 @@ proptest! { } } -// ── 7. QueryKeyFilter semantics ───────────────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// Exact filter matches only the identical key. #[test] fn filter_exact_matches_only_identical( target in arb_key(), @@ -194,7 +160,6 @@ proptest! { } } - /// Prefix filter matches child keys and the prefix itself. #[test] fn filter_prefix_matches_children( prefix in arb_segments(), @@ -209,7 +174,6 @@ proptest! { prop_assert!(filter.matches(&prefix_key)); } - /// Prefix filter rejects keys that differ from the prefix. #[test] fn filter_prefix_rejects_non_prefix( prefix in arb_segments(), @@ -226,7 +190,6 @@ proptest! { } } - /// All filter matches every possible key. #[test] fn filter_all_matches_everything(segments in arb_key()) { let key = make_key(&segments); @@ -234,12 +197,9 @@ proptest! { } } -// ── 8. Edge cases: unicode and long keys ──────────────────────────────── - proptest! { #![proptest_config(test_config())] - /// Unicode segments survive clone, hash, serde, and to_path. #[test] fn key_unicode_roundtrip( segments in prop::collection::vec("[\\p{L}\\p{N}]{1,10}", 1..5), @@ -254,8 +214,6 @@ proptest! { prop_assert_eq!(key.to_path(), segments.join("::")); } - /// Longer keys still satisfy all invariants. Segment bound kept modest - /// so the multi-segment case stays within the default test budget. #[test] fn key_long_key_correctness( segments in prop::collection::vec(any::<String>(), 20..40), diff --git a/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs b/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs index c018e7d..91ce930 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/strategies.rs @@ -1,5 +1,3 @@ -//! Proptest strategies and shared helpers for QueryKey property tests. - use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -7,94 +5,61 @@ use proptest::prelude::*; use crate::core::*; -// ── Strategies ─────────────────────────────────────────────────────────── - -/// Strategy that produces an arbitrary non-empty Vec<String>. -/// QueryKey requires at least one segment, so we guarantee non-emptiness. pub fn arb_segments() -> impl Strategy<Value = Vec<String>> { prop_oneof![ - // Normal depth prop::collection::vec(any::<String>(), 1..10), - // Deep nesting (up to 100 segments) prop::collection::vec(any::<String>(), 10..100), ] } -/// Strategy covering special cases: single-segment, unicode, long, and -/// keys containing the separator string "::". pub fn arb_key_special() -> impl Strategy<Value = Vec<String>> { prop_oneof![ - // Single segment with arbitrary content any::<String>().prop_map(|s| vec![s]), - // Unicode-heavy segments (letters, numbers, punctuation, symbols) prop::collection::vec("[\\p{L}\\p{N}\\p{P}\\p{S}]{1,20}", 1..5), - // Keys containing the "::" separator any::<String>().prop_map(|s| vec![format!("{}::{}", s, s)]), - // Longer keys (up to 50 segments for deeper nesting) prop::collection::vec(any::<String>(), 5..50), - // Unicode edge-case segments: zero-width joiners, combining marks, - // RTL overrides, surrogates-replacement, and other tricky codepoints - // that regex classes like \p{L} do not cover. prop::collection::vec(arb_unicode_edge_case_string(), 1..5), - // Very long single segment (100-256 chars) to stress allocation - // paths. The 2000-char case is covered by the `#[ignore]`-gated - // deterministic test. ".{100,256}".prop_map(|s| vec![s]), ] } -/// Generates strings containing unicode edge cases that regex classes miss: -/// zero-width characters, combining marks, RTL/LTR overrides, BOM, -/// replacement characters, and mixed-direction text. pub fn arb_unicode_edge_case_string() -> impl Strategy<Value = String> { use std::sync::LazyLock; - // Codepoints that stress string handling: zero-width, combining, bidi, BOM, - // replacement char, soft hyphen, non-breaking space, and normal mixed text. static EDGE_CASES: LazyLock<Vec<String>> = LazyLock::new(|| { vec![ - // Zero-width characters - "\u{200B}".to_string(), // ZWSP - "\u{200C}".to_string(), // ZWNJ - "\u{200D}".to_string(), // ZWJ - "\u{FEFF}".to_string(), // BOM - // Combining characters (base + combining marks) - "e\u{0301}".to_string(), // e + combining acute accent → é (NFD) - "a\u{0308}\u{0301}".to_string(), // a + combining diaeresis + acute - "\u{0300}".to_string(), // standalone combining grave accent - // BiDi overrides - "\u{202A}".to_string(), // LRE - "\u{202B}".to_string(), // RLE - "\u{202C}".to_string(), // PDF - "\u{202D}".to_string(), // LRO - "\u{202E}".to_string(), // RLO - "\u{2066}".to_string(), // LRI - "\u{2067}".to_string(), // RLI - "\u{2068}".to_string(), // FSI - "\u{2069}".to_string(), // PDI - // Tricky whitespace / control-like - "\u{00A0}".to_string(), // non-breaking space - "\u{FEFF}".to_string(), // BOM (zero-width no-break space) - "\u{2000}".to_string(), // en quad - "\u{3000}".to_string(), // ideographic space - // Replacement and special - "\u{FFFD}".to_string(), // replacement character - "\u{00AD}".to_string(), // soft hyphen - // Mixed-direction strings + "\u{200B}".to_string(), + "\u{200C}".to_string(), + "\u{200D}".to_string(), + "\u{FEFF}".to_string(), + "e\u{0301}".to_string(), + "a\u{0308}\u{0301}".to_string(), + "\u{0300}".to_string(), + "\u{202A}".to_string(), + "\u{202B}".to_string(), + "\u{202C}".to_string(), + "\u{202D}".to_string(), + "\u{202E}".to_string(), + "\u{2066}".to_string(), + "\u{2067}".to_string(), + "\u{2068}".to_string(), + "\u{2069}".to_string(), + "\u{00A0}".to_string(), + "\u{FEFF}".to_string(), + "\u{2000}".to_string(), + "\u{3000}".to_string(), + "\u{FFFD}".to_string(), + "\u{00AD}".to_string(), "hello\u{202E}world\u{202C}".to_string(), - "\u{0627}\u{0628}\u{062A}".to_string(), // Arabic ا ب ت - "\u{05D0}\u{05D1}\u{05D2}".to_string(), // Hebrew א ב ג - // Strings with mixed normalization forms - "\u{00E9}".to_string(), // é (NFC, single codepoint) - "e\u{0301}".to_string(), // é (NFD, base + combining) - // Very long combining chain + "\u{0627}\u{0628}\u{062A}".to_string(), + "\u{05D0}\u{05D1}\u{05D2}".to_string(), + "\u{00E9}".to_string(), + "e\u{0301}".to_string(), format!("x{}", "\u{0301}".repeat(50)), - // String of only zero-width characters "\u{200B}\u{200C}\u{200D}\u{FEFF}".to_string(), ] }); let cases = EDGE_CASES.clone(); - // Pick a random edge-case string, possibly concatenated with arbitrary text (any::<bool>(), any::<String>()).prop_map(move |(prefix, extra)| { let idx = (extra.len()) % cases.len(); let edge = cases[idx].clone(); @@ -106,13 +71,10 @@ pub fn arb_unicode_edge_case_string() -> impl Strategy<Value = String> { }) } -/// Combined strategy that mixes normal and special cases. pub fn arb_key() -> impl Strategy<Value = Vec<String>> { prop_oneof![arb_segments(), arb_key_special()] } -// ── Helpers ────────────────────────────────────────────────────────────── - pub fn make_key(segments: &[String]) -> QueryKey { QueryKey::new(segments.iter().map(|s| s.as_str())) } @@ -123,10 +85,6 @@ pub fn hash_of(key: &QueryKey) -> u64 { hasher.finish() } -/// Assert clone, hash, serde roundtrip, and to_path consistency for a key. -/// -/// Shared between `deterministic_tests.rs` and `proptests.rs` so both test -/// modules exercise the same invariant set without duplicating the helper. pub fn assert_key_invariants(key: &QueryKey, expected_path: &str) { let cloned = key.clone(); assert_eq!(key, &cloned, "clone should be equal"); From 56f1e06e2786dd4a4b0cb92807d6cf9b25b5a950 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 19:48:06 +0200 Subject: [PATCH 039/111] refactor: purge persist comments to one-line-or-examples and strip test comments --- crates/gpui-query/src/client/persist.rs | 239 +++++------------- .../diagnostics_dehydrate_persister.rs | 4 - .../client_operations/persist_with_hydrate.rs | 73 ------ 3 files changed, 58 insertions(+), 258 deletions(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index f839fff..1bbdb54 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -1,11 +1,6 @@ //! Async, value-carrying persistence for [`QueryClient`](super::QueryClient): //! the [`Persister`] trait, the debounced [`QueryClient::persist_with`] driver, //! [`hydrate`], and the typed serializer/deserializer registries. -//! -//! This layer trusts a persister's `load` output beyond a version check, but -//! never panics on it: unrecognized versions error out and values no -//! deserializer accepts are skipped. A persister reading untrusted storage -//! should validate and size-limit payloads itself. use std::any::TypeId; use std::collections::HashMap; @@ -22,81 +17,54 @@ use crate::core::{CachePolicy, QueryKey}; use super::QueryClient; -/// Current on-disk snapshot format version. Bumped when the serialized shape -/// of [`PersistSnapshot`] changes incompatibly; loaders reject mismatches with -/// [`PersistError::VersionMismatch`]. +/// Bumped when the serialized shape changes incompatibly; loaders reject +/// mismatches with [`PersistError::VersionMismatch`]. pub const PERSIST_VERSION: u32 = 1; -// ── Errors ─────────────────────────────────────────────────────────────── - -/// Errors produced by the persistence layer. -/// -/// Every IO failure from a [`Persister`] implementation maps to a variant -/// here rather than panicking; tolerant persisters degrade a corrupt store to -/// an empty snapshot. #[derive(Debug, Error)] pub enum PersistError { - /// An underlying IO error (read or write) failed. #[error("persistence io error: {0}")] Io(#[from] std::io::Error), - /// Serializing the snapshot (or an entry) to the persister's format failed. #[error("persistence serialize error: {0}")] Serialize(#[from] serde_json::Error), - /// The on-disk snapshot could not be parsed. Reserved for persisters that - /// surface (rather than tolerate) parse failures; core never constructs - /// this variant. + /// Surfaced by persisters that decline to tolerate a parse failure; core + /// itself never constructs this variant. #[error("persistence deserialize error: {0}")] Deserialize(String), - /// The on-disk snapshot's `version` does not match [`PERSIST_VERSION`], - /// so the file was written by a format we cannot read. #[error("persistence version mismatch: expected {expected}, found {found}")] VersionMismatch { - /// The version this loader understands ([`PERSIST_VERSION`]). expected: u32, - /// The version actually found on disk. found: u32, }, - /// The requested path was unusable (e.g. the OS returned no cache dir). #[error("persistence bad path: {0}")] BadPath(String), - /// The persister could not acquire a required resource (e.g. file lock). + /// A required resource could not be acquired (e.g. a file lock). #[error("persistence permission denied: {0}")] Permission(String), } -// ── Snapshot types ─────────────────────────────────────────────────────── - -/// One persisted cache entry: the typed data as an opaque JSON value plus the -/// metadata needed to re-prime and re-validate it. -/// -/// `value` is opaque to core; the typed round-trip is driven by the -/// serializer/deserializer registries on [`QueryClient`]. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct PersistedEntry { - /// The serialized data value. Opaque to core. + /// Opaque to core; typed round-trips go through the registries. pub value: JsonValue, - /// Wall-clock ms (since UNIX epoch) the entry was cached. + /// Wall-clock ms since the UNIX epoch when the entry was cached. pub cached_at: u64, /// The cache policy in force when the entry was cached. pub cache_policy: CachePolicy, - /// Optional opaque metadata (e.g. ETag/Last-Modified for HTTP), captured - /// from `Fetched::meta` at fetch completion. Reserved for the - /// `gpui-query-http` companion crate. + /// Opaque metadata captured from `Fetched::meta` (e.g. HTTP ETags), + /// read by the `gpui-query-http` crate. pub meta: Option<JsonValue>, } -/// A full snapshot of the persistable cache, ready to hand to a [`Persister`]. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct PersistSnapshot { - /// The persistable entries, keyed by [`QueryKey`] path string so the - /// snapshot is self-contained and serializable. + /// Keyed by [`QueryKey`] path string so the snapshot is serializable. pub entries: HashMap<String, PersistedEntry>, - /// Format version; see [`PERSIST_VERSION`]. + /// See [`PERSIST_VERSION`]. pub version: u32, } impl PersistSnapshot { - /// Construct an empty snapshot at the current version. pub fn new() -> Self { Self { entries: HashMap::new(), @@ -105,23 +73,16 @@ impl PersistSnapshot { } } -// ── Owned filter (vs core's borrowing QueryKeyFilter<'a>) ──────────────── - /// Owned counterpart to [`QueryKeyFilter`](crate::core::QueryKeyFilter), so a -/// filter can be pinned inside long-lived structures like -/// [`PersistOptions`]. +/// filter can be pinned inside [`PersistOptions`] and other long-lived values. #[derive(Clone, Debug)] pub enum PersistFilter { - /// Persist only the entry matching exactly this key. Exact(QueryKey), - /// Persist every entry whose key starts with this prefix. Prefix(QueryKey), - /// Persist every persistable entry. All, } impl PersistFilter { - /// Returns `true` if `key` should be included under this filter. pub fn matches(&self, key: &QueryKey) -> bool { match self { PersistFilter::Exact(target) => key == target, @@ -131,18 +92,15 @@ impl PersistFilter { } } -/// Tuning knobs for [`QueryClient::persist_with`]. -/// -/// `Default` is: every entry, max age 24 hours, 500 ms debounce. +/// Defaults: every entry, max age 24 hours, 500 ms debounce. #[derive(Clone, Debug)] pub struct PersistOptions { - /// Which entries to include. pub filter: PersistFilter, - /// Skip entries older than this at save time. + /// Entries older than this are skipped at save time; zero disables the + /// check. pub max_age: Duration, - /// Coalesce bursts of [`CacheMutation`](super::CacheMutation) into one - /// save per window. [`Duration::ZERO`] skips the delay: a bump arriving - /// while no save is pending saves immediately. + /// Coalesces bursts of [`CacheMutation`](super::CacheMutation) into one + /// save per window; [`Duration::ZERO`] skips the delay entirely. pub debounce: Duration, } @@ -156,68 +114,48 @@ impl Default for PersistOptions { } } -// ── Serializer / deserializer registries ───────────────────────────────── - -/// Type-erased serializer closure: `&dyn Any -> Option<serde_json::Value>`. -/// -/// `None` means the downcast failed; the caller then skips the entry rather -/// than persisting junk. +/// `None` means the downcast failed; the caller skips the entry. type SerializeFn = Box<dyn Fn(&dyn std::any::Any) -> Option<JsonValue> + Send + Sync>; -/// Registry of `T -> serde_json::Value` serializers, keyed by `TypeId` of -/// the resource's data type `T`. -/// -/// Keyed on `T` alone, matching the bucket lookup: serialization depends only -/// on the data type, so registering for the same `T` under two error types -/// overwrites (last write wins), and the surviving closure applies to every -/// `(T, E)` bucket. That is correct because the value is that `T`. +/// Keyed on `T` alone (matching the bucket lookup): registering the same `T` +/// under two error types overwrites, and the surviving closure applies to +/// every `(T, E)` bucket. #[derive(Default)] pub struct SerializerRegistry { serializers: HashMap<TypeId, SerializeFn>, } impl SerializerRegistry { - /// Register a serializer for `T`. `f` is a plain `fn` pointer (no - /// captures) so it is `Send + Sync + 'static` without boxing. pub fn register<T: 'static>(&mut self, f: fn(&T) -> JsonValue) { let wrap = move |any: &dyn std::any::Any| -> Option<JsonValue> { - // Downcast failure is unreachable (buckets look the closure up by - // `TypeId::of::<T>()`), but degrade to None so this path can - // never panic. + // Unreachable (lookup is by TypeId::of::<T>()); degrade instead of panicking. any.downcast_ref::<T>().map(f) }; self.serializers.insert(TypeId::of::<T>(), Box::new(wrap)); } - /// Look up the serializer registered for `type_id`. pub(crate) fn get(&self, type_id: TypeId) -> Option<&SerializeFn> { self.serializers.get(&type_id) } - /// Returns `true` if a serializer is registered for `type_id`. pub fn contains(&self, type_id: TypeId) -> bool { self.serializers.contains_key(&type_id) } } -/// Type-erased hydrate step: decode a `JsonValue` and prime the live cache -/// via `set_query_data::<T, E>`. The concrete types are captured at the -/// `register` call site, so no cross-type confusion is possible. type HydrateStep = Arc<dyn Fn(&mut QueryClient, &QueryKey, &JsonValue, &mut App) -> bool + Send + Sync>; -/// Registry of `serde_json::Value -> primed cache entry` steps, used by -/// [`hydrate`] to re-prime on-disk values. Each step returns `true` if it -/// decoded and primed the value, `false` to skip the entry. +/// Hydrate steps consumed by [`hydrate`]; a step returns `true` when it +/// decoded and primed the value. #[derive(Default)] pub struct DeserializerRegistry { steps: Vec<HydrateStep>, } impl DeserializerRegistry { - /// Register a deserializer for resources of type `(T, E)`. `deserialize` - /// returns `None` for values it cannot decode; the entry is then skipped. - /// On `Some(t)` the value is primed via `set_query_data::<T, E>`. + /// `None` from `deserialize` skips the entry; `Some(t)` primes the value + /// via `set_query_data::<T, E>`. pub fn register<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>) where T: Clone + Send + Sync + 'static, @@ -242,46 +180,30 @@ impl DeserializerRegistry { } } -// ── Persister trait ────────────────────────────────────────────────────── - -/// Async persistence backend for [`QueryClient::persist_with`]. -/// -/// Non-object-safe (methods return `impl Future`): `persist_with<P>` -/// monomorphizes the driver around the concrete `P`, avoiding -/// `Pin<Box<dyn Future>>` overhead and keeping the `Send + 'static` bounds -/// visible at the call site. The save future runs on GPUI's background -/// executor. -/// -/// See the `FilePersister` adapter in the `gpui-query-persist` satellite -/// crate for a reference disk implementation. +/// Async persistence backend for [`QueryClient::persist_with`]. Not +/// object-safe (methods return `impl Future`): the driver monomorphizes over +/// the concrete `P`, and saves run on GPUI's background executor. See the +/// `gpui-query-persist` satellite crate for a reference disk implementation. pub trait Persister: Send + Sync + 'static { - /// Load the snapshot from storage. Implementations should tolerate a - /// missing or corrupt store by yielding an empty snapshot (or a typed - /// [`PersistError`] for version mismatches). + /// Implementations should tolerate a missing or corrupt store by yielding + /// an empty snapshot. fn load(&self) -> impl Future<Output = Result<PersistSnapshot, PersistError>> + Send; - /// Save `snapshot`, replacing any previously stored data. + /// Replaces any previously stored data. fn save( &self, snapshot: &PersistSnapshot, ) -> impl Future<Output = Result<(), PersistError>> + Send; } -// ── PersistHandle ──────────────────────────────────────────────────────── - -/// Drop-guard returned by [`QueryClient::persist_with`]. -/// -/// Holding the handle keeps the [`CacheMutation`](super::CacheMutation) -/// observation alive; dropping it stops new saves from being scheduled. A -/// task that is already armed still collects and completes its final save, -/// so nothing pending at drop time is lost. +/// Drop guard for [`QueryClient::persist_with`]: dropping it stops new saves, +/// but an already-armed task still collects and completes its final save. pub struct PersistHandle { - // Subscription is dropped when the handle is, ending observation. _subscription: Option<Subscription>, } impl PersistHandle { - /// Construct a handle that does nothing on drop (for tests / no-op). + /// Observes nothing; for tests and no-op setups. pub fn empty() -> Self { Self { _subscription: None, @@ -289,14 +211,9 @@ impl PersistHandle { } } -// ── QueryClient methods ───────────────────────────────────────────────── - impl QueryClient { - /// Register a serializer for resources of data type `T`. - /// /// Only `Success` resources whose `T` has a registered serializer are - /// emitted by [`collect_persist_snapshot`](Self::collect_persist_snapshot); - /// unregistered types are skipped. + /// collected; unregistered types are skipped. pub fn register_serializer<T, E>(&mut self, f: fn(&T) -> JsonValue) where T: Clone + Send + Sync + 'static, @@ -308,14 +225,10 @@ impl QueryClient { registry.register::<T>(f); } - /// Register a deserializer for resources of type `(T, E)`, enabling - /// [`hydrate`] to re-prime on-disk values of this type. - /// /// [`hydrate`] offers every on-disk entry to every registered - /// deserializer (there is no type discriminator on [`PersistedEntry`]). - /// A deserializer MUST return `None` for any JSON shape that is not its - /// own `T`; a lax one can prime a stale or foreign value into a bucket - /// it does not belong to. + /// deserializer (there is no type discriminator on [`PersistedEntry`]): + /// one that accepts a JSON shape that is not its own `T` primes a foreign + /// value into a bucket it does not belong to. pub fn register_deserializer<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>) where T: Clone + Send + Sync + 'static, @@ -327,9 +240,7 @@ impl QueryClient { registry.register::<T, E>(deserialize); } - /// Collect a value-carrying snapshot from the live cache, honoring - /// `filter` and `max_age`. Only `Success` resources with a registered - /// serializer are included. + /// Only `Success` resources with a registered serializer are included. pub fn collect_persist_snapshot( &self, filter: &PersistFilter, @@ -350,8 +261,6 @@ impl QueryClient { bucket.collect_persistable_into(cx, registry, now_ms, &mut out); } - // Attach metadata recorded at fetch completion (record_meta) so HTTP - // CacheMeta and similar round-trip through PersistedEntry.meta. if let Some(meta_map) = &self.persisted_meta { for (key, entry) in &mut out { if let Some(m) = meta_map.get(key) { @@ -373,15 +282,9 @@ impl QueryClient { snapshot } - /// Drive a [`Persister`] from the live cache, debounced on the - /// [`CacheMutation`](super::CacheMutation) dirty signal. - /// - /// Each bump arms at most one main-thread task; after `opts.debounce` - /// the task collects a fresh [`PersistSnapshot`] and runs - /// `persister.save(&snapshot)` on the background executor. Because - /// collection happens at drain time, a burst of bumps coalesces into one - /// save of the latest state. Dropping the returned [`PersistHandle`] - /// stops scheduling new saves; an armed task still finishes. + /// Debounced [`Persister`] driver on the [`CacheMutation`](super::CacheMutation) + /// dirty signal: collection happens at drain time, so a burst of bumps + /// coalesces into one save of the latest state. pub fn persist_with<P: Persister>( &self, persister: P, @@ -391,12 +294,8 @@ impl QueryClient { let persister: Arc<P> = Arc::new(persister); let debounce = opts.debounce; let bg = cx.background_executor().clone(); - // At most one armed task per window; cleared by the task itself just - // before it collects, on every path. let armed = Arc::new(AtomicBool::new(false)); - // Seed the marker so observation is registered against a global that - // already exists; bump sites use the same idempotent seeding. let _ = cx.default_global::<super::CacheMutation>(); let subscription = { @@ -405,8 +304,6 @@ impl QueryClient { let filter = opts.filter; let max_age = opts.max_age; cx.observe_global::<super::CacheMutation>(move |cx| { - // If a task is already armed it will collect after this bump - // when its window elapses; nothing else to do. if armed.swap(true, Ordering::AcqRel) { return; } @@ -418,16 +315,14 @@ impl QueryClient { if !debounce.is_zero() { bg.timer(debounce).await; } - // Disarm before collecting: a bump landing now arms a - // fresh task instead of trusting one about to finish. + // Disarm before collecting: a bump landing now arms a fresh task. armed.store(false, Ordering::Release); let Ok(snapshot) = cx.update_global::<QueryClient, _>(|client, cx| { client.collect_persist_snapshot(&filter, max_age, cx) }) else { return; }; - // Collect on the main thread (entity reads), save on the - // background executor (IO), per the Persister contract. + // Collect on the main thread (entity reads), save on background (IO). bg.spawn(async move { if let Err(err) = persister.save(&snapshot).await { #[cfg(debug_assertions)] @@ -446,12 +341,7 @@ impl QueryClient { } } -// ── NoopPersister ──────────────────────────────────────────────────────── - -/// A [`Persister`] that persists nothing and loads an empty snapshot. -/// -/// Useful as a default, in tests that only exercise the debounce path, or as -/// a base to compose with a real persister behind a feature flag. +/// Persists nothing; loads an empty snapshot. pub struct NoopPersister; impl Persister for NoopPersister { @@ -464,24 +354,16 @@ impl Persister for NoopPersister { } } -// ── hydrate ────────────────────────────────────────────────────────────── - -/// Load a snapshot from `persister` and re-prime the live cache with it: the -/// value-carrying counterpart to the metadata-only -/// [`QueryClient::hydrate`](super::QueryClient::hydrate). -/// -/// Every entry surviving `filter` and `max_age` is offered to every -/// registered deserializer (see [`QueryClient::register_deserializer`]); -/// each one that decodes primes the value via `set_query_data`. Entries no -/// deserializer accepts are skipped. Stored keys are `to_path()` strings; -/// they are split back into segments so `Exact`/`Prefix` filters match the -/// live multi-segment key shapes. The split is lossy: a single-segment key -/// containing `"::"` hydrates as multiple segments (escaping the separator -/// needs a `PERSIST_VERSION` bump). -/// -/// Returns the loaded snapshot (post-filter) so callers can inspect entries -/// or prime types with no registered deserializer themselves. Errors from -/// `load` propagate. +/// Load a snapshot and re-prime the live cache with it: the value-carrying +/// counterpart to the metadata-only +/// [`QueryClient::hydrate`](super::QueryClient::hydrate). Stored `to_path()` +/// keys are split back on `"::"` so `Exact`/`Prefix` filters match live +/// multi-segment keys; the split is lossy (a segment containing `"::"` +/// hydrates as multiple segments, and escaping it needs a `PERSIST_VERSION` +/// bump). Returns the post-filter snapshot so callers can inspect entries or +/// prime types with no registered deserializer. The persister's output is +/// trusted beyond the version check: one reading untrusted storage must +/// validate payloads itself. pub async fn hydrate<P: Persister>( client: &mut QueryClient, persister: &P, @@ -490,8 +372,7 @@ pub async fn hydrate<P: Persister>( cx: &mut App, ) -> Result<PersistSnapshot, PersistError> { let snapshot = persister.load().await?; - // Check even if the persister already enforces the version, so an - // in-memory persister cannot feed a mismatched snapshot through. + // Checked here too so an in-memory persister cannot bypass the version gate. if snapshot.version != PERSIST_VERSION { return Err(PersistError::VersionMismatch { expected: PERSIST_VERSION, @@ -505,12 +386,8 @@ pub async fn hydrate<P: Persister>( return Ok(snapshot); }; - // Clone the steps (cheap Arc bumps) so the immutable borrow on `client` - // ends before each step takes `&mut QueryClient` for set_query_data. let steps: Vec<HydrateStep> = deserializers.iter().cloned().collect(); - // One key reconstruction and filter pass per entry; every step then gets - // a shot at the value. for (key_path, entry) in &snapshot.entries { let key = QueryKey::new(key_path.split("::")); if !filter.matches(&key) { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs index dff27ad..e85694a 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/diagnostics_dehydrate_persister.rs @@ -1,5 +1,3 @@ -//! Diagnostics, dehydrate/hydrate, and legacy persister tests. - use std::sync::Mutex; use gpui::{BorrowAppContext as _, TestAppContext}; @@ -71,7 +69,6 @@ fn test_dehydrate_includes_infinite_query_success(cx: &mut TestAppContext) { let q = client.resource::<String, QueryError>("q1", cx); q.update(cx, |r, _| r.apply_success("data".to_string(), 1_000)); - // Idle infinite query: created, never completed. let _iq = client.infinite_resource::<String, QueryError>("iq1", cx); let state = client.dehydrate(cx); @@ -157,7 +154,6 @@ fn test_persister_records_multiple_entries(cx: &mut TestAppContext) { let e = client.resource::<String, QueryError>(key.clone(), cx); e.update(cx, |r, _| r.apply_success(format!("val_{i}"), 1_000)); } - // Idle resources are never persisted. let _idle = client.resource::<String, QueryError>("idle_persist", cx); struct CapturePersister { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index 908cd22..9444850 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -1,8 +1,3 @@ -//! Integration tests for the value-carrying persistence layer: -//! `persist_with` debounce/coalescing, the serializer/deserializer registries, -//! `hydrate` round-trip, the `CacheMutation` dirty signal, and -//! `PersistFilter`/`max_age` behavior. - use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -21,8 +16,6 @@ use crate::hook::{ }; use crate::tests::test_support::*; -/// In-memory persister for asserting on saved payloads. `save_count` counts -/// `save` calls so coalescing tests can assert exactly how many fired. #[derive(Default, Clone)] struct MemPersister { last_saved: Arc<StdMutex<Option<PersistSnapshot>>>, @@ -50,14 +43,10 @@ impl Persister for MemPersister { } } -/// Serializer for the `String`-typed fixtures. fn ser_string(s: &String) -> serde_json::Value { serde_json::to_value(s).expect("serialize") } -/// Debounce disabled: the TestAppContext mock clock never advances wall-clock -/// timers on its own, so a non-zero debounce would leave the save un-fired -/// unless the test calls `advance_clock`. fn zero_debounce() -> PersistOptions { PersistOptions { debounce: Duration::ZERO, @@ -71,7 +60,6 @@ const DAY: Duration = Duration::from_secs(24 * 60 * 60); fn test_set_query_data_bumps_cache_mutation(cx: &mut TestAppContext) { setup_query_client(cx); cx.update(|cx| { - // The observation must be live when the bump fires, and must not panic. let _handle = cx.update_global::<QueryClient, _>(|client, cx| { client.persist_with(NoopPersister, PersistOptions::default(), cx) }); @@ -107,7 +95,6 @@ fn test_collect_persist_snapshot_skips_unregistered_types(cx: &mut TestAppContex setup_query_client(cx); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // No serializer registered: the entry is skipped entirely. let e = client.resource::<String, QueryError>(QueryKey::from("unreg"), cx); e.update(cx, |r, _| { r.apply_success("data".to_string(), crate::client::current_time_ms()) @@ -129,12 +116,10 @@ fn test_collect_persist_snapshot_filter_and_max_age(cx: &mut TestAppContext) { cx.update_global::<QueryClient, _>(|client, cx| { client.register_serializer::<String, QueryError>(ser_string); let now = crate::client::current_time_ms(); - // Two recent entries under the "users" prefix. for parts in [["users", "1"], ["users", "2"]] { let e = client.resource::<String, QueryError>(QueryKey::from(parts), cx); e.update(cx, |r, _| r.apply_success("v".to_string(), now)); } - // One entry ~2.8 h in the past under "posts". let e = client.resource::<String, QueryError>(QueryKey::from(["posts", "9"]), cx); e.update(cx, |r, _| { r.apply_success("old".to_string(), now.saturating_sub(10_000_000)) @@ -147,8 +132,6 @@ fn test_collect_persist_snapshot_filter_and_max_age(cx: &mut TestAppContext) { ); assert_eq!(snap.entries.len(), 2); - // max_age = 0 means "disabled", not "everything is too old", so - // a small positive value is what filters the old entry out here. let snap_all = client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(1), cx); assert_eq!(snap_all.entries.len(), 2); @@ -163,8 +146,6 @@ fn test_persist_with_saves_on_mutation(cx: &mut TestAppContext) { let persister = MemPersister::default(); let captured = persister.last_saved.clone(); - // The bucket stores only WeakEntity, so a live owner must hold the - // Success entry or it dies before the observer collects the snapshot. struct H { _entity: Entity<QueryResource<String, QueryError>>, _handle: PersistHandle, @@ -185,8 +166,6 @@ fn test_persist_with_saves_on_mutation(cx: &mut TestAppContext) { } }); - // A mutation on another key bumps the dirty signal; persist_with collects - // the live cache (including the retained Success entry) and saves it. cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>("trigger", "x".to_string(), cx); @@ -227,8 +206,6 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { ); *persister.load_value.lock().unwrap() = Some(snap); - // Hold the "hydrate_k" entity alive so hydrate's set_query_data reuses it - // instead of creating an entity that is dropped immediately (WeakEntity). struct H { _entity: Entity<QueryResource<String, QueryError>>, } @@ -247,8 +224,6 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { let max_age = DAY; let outcome = cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { - // The MemPersister load resolves immediately, so the hydrate - // future is Ready on first poll and can be driven synchronously. block_on_ready(hydrate(client, &persister, &filter, max_age, cx)) }) }); @@ -273,9 +248,6 @@ fn test_hydrate_primes_via_deserializer_registry(cx: &mut TestAppContext) { let _ = harness; } -// A stored multi-segment path must hydrate as a segmented key; priming it -// as one flat segment would hide it from Exact/Prefix filters. - #[gpui::test] fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { setup_query_client(cx); @@ -296,8 +268,6 @@ fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { ); *persister.load_value.lock().unwrap() = Some(snap); - // Retain the multi-segment entity so hydrate's set_query_data reuses it - // instead of creating one that dies immediately (WeakEntity). struct H { _entity: Entity<QueryResource<String, QueryError>>, } @@ -332,7 +302,6 @@ fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { }); }); - // Overwrite with a sentinel so the Exact pass has to re-prime to pass. cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { client.set_query_data::<String, QueryError>(key.clone(), "sentinel".to_string(), cx); @@ -392,10 +361,6 @@ fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { } } -// A real fetch completion (not set_query_data) drives persist_with: the -// success arm of the hook retry loop bumps CacheMutation, which the -// persist_with observer collects and saves. - #[gpui::test] fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { setup_query_client(cx); @@ -411,9 +376,6 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { client.register_serializer::<String, QueryError>(ser_string); client.persist_with(persister.clone(), zero_debounce(), cx) }); - // The real hook path keys the bucket under "fetched"; - // use_query_manual needs an entity Context, so it cannot run inside - // update_global (which only offers &mut App). let (entity, _sub) = use_query_manual::<String, QueryError, _>( QueryKey::from("fetched"), crate::core::CachePolicy::NoCache, @@ -423,8 +385,6 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { H { entity, _handle } }); - // Resolve a real fetch; the success path bumps the dirty signal, waking - // the persist_with observer. harness.update(cx, |this, cx| { fetch_query( &this.entity, @@ -461,10 +421,6 @@ fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { let _ = harness; } -// A real mutation completion bumps the dirty signal the same way. Mutation -// buckets are never collected into the snapshot; what gets saved is the -// retained Success query entry the bump wakes the observer for. - #[gpui::test] fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) { setup_query_client(cx); @@ -481,8 +437,6 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) client.register_serializer::<String, QueryError>(ser_string); client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Prime a retained Success query entry; apply_success does NOT bump - // CacheMutation, so no save fires from the priming itself. let query = cx.update_global::<QueryClient, _>(|client, cx| { let e = client.resource::<String, QueryError>(QueryKey::from("retained"), cx); e.update(cx, |r, _| { @@ -530,10 +484,6 @@ fn test_persist_with_driven_by_real_mutation_completion(cx: &mut TestAppContext) let _ = harness; } -// The infinite family: use_infinite_query auto-fetches the first page, and -// its success arm bumps CacheMutation. Infinite buckets are collected (first -// page only), so the page lands in the snapshot. - #[gpui::test] fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) { setup_query_client(cx); @@ -546,7 +496,6 @@ fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) } let harness = cx.new(|cx| { let _handle = cx.update_global::<QueryClient, _>(|client, cx| { - // The page type is Vec<String>; the serializer is keyed on it. client.register_serializer::<Vec<String>, QueryError>(|v| { serde_json::to_value(v).expect("serialize") }); @@ -589,10 +538,6 @@ fn test_persist_with_driven_by_real_infinite_completion(cx: &mut TestAppContext) let _ = harness; } -// The imperative escape hatch (prepare_fetch_query, the fetchQuery -// equivalent) also bumps CacheMutation on completion, so imperative results -// are not invisible to persist_with. - #[gpui::test] fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -608,9 +553,6 @@ fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext client.register_serializer::<String, QueryError>(ser_string); client.persist_with(persister.clone(), zero_debounce(), cx) }); - // Retain the query via the real hook path so the bucket's WeakEntity - // survives until the observer collects (prepare_fetch_query reuses - // this same entity via resource()). let (query, _qsub) = use_query_manual::<String, QueryError, _>( QueryKey::from("imperative"), crate::core::CachePolicy::NoCache, @@ -656,10 +598,6 @@ fn test_persist_with_driven_by_imperative_prepared_fetch(cx: &mut TestAppContext let _ = harness; } -// Non-zero debounce with a deterministic clock: fire several rapid bumps, -// advance the mock clock past the window, and expect exactly one save -// containing the latest state. - #[gpui::test] fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { setup_query_client(cx); @@ -669,8 +607,6 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { let debounce = Duration::from_millis(50); - // The bucket stores only WeakEntity; the harness keeps the Success entry - // alive so the snapshot has something to save. struct H { _entity: Entity<QueryResource<String, QueryError>>, _handle: PersistHandle, @@ -698,8 +634,6 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { } }); - // Each bump needs its own cx.update: GPUI coalesces notifications raised - // within a single update, which would deliver only one observer call. for i in 0..5_u32 { cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -714,8 +648,6 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { "no save should fire before the debounce window elapses" ); - // advance_clock matures the pending timer; exactly one task collects and - // saves, any others wake to find the window already drained. cx.background_executor .advance_clock(debounce + Duration::from_millis(1)); cx.run_until_parked(); @@ -730,8 +662,6 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { .unwrap() .clone() .expect("the single coalesced save should have produced a snapshot"); - // The "trigger" entities die inside each update, so only the - // harness-retained "coalesced" entry can appear. assert!( saved.entries.contains_key("coalesced"), "the coalesced save should include the retained Success entry: {:?}", @@ -740,8 +670,6 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { let _ = harness; } -/// Poll a future that is always immediately Ready (MemPersister's load is a -/// plain clone, no real async work) without pulling in an executor crate. fn block_on_ready<R>(fut: impl std::future::Future<Output = R>) -> R { use std::future::Future; use std::pin::Pin; @@ -749,7 +677,6 @@ fn block_on_ready<R>(fut: impl std::future::Future<Output = R>) -> R { let mut cx = Context::from_waker(Waker::noop()); let mut fut = Box::pin(fut); - // SAFETY: pinned on the heap; we hold the only reference. let mut pinned: Pin<&mut dyn Future<Output = R>> = Pin::as_mut(&mut fut); loop { match pinned.as_mut().poll(&mut cx) { From 55fbd13dee5ac25fa463fc5dba81e27c06ef5c09 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 20:07:26 +0200 Subject: [PATCH 040/111] refactor: purge http comments to one-line-or-examples and strip test comments --- crates/gpui-query-http/Cargo.toml | 16 ++-- crates/gpui-query-http/src/backend.rs | 72 +++++------------- crates/gpui-query-http/src/cache.rs | 74 +++++-------------- crates/gpui-query-http/src/lib.rs | 73 +++++------------- crates/gpui-query-http/src/reqwest_backend.rs | 23 ++---- 5 files changed, 68 insertions(+), 190 deletions(-) diff --git a/crates/gpui-query-http/Cargo.toml b/crates/gpui-query-http/Cargo.toml index eef1d05..e933ef0 100644 --- a/crates/gpui-query-http/Cargo.toml +++ b/crates/gpui-query-http/Cargo.toml @@ -13,17 +13,12 @@ authors = ["hmziqrs"] [features] default = [] -## Enables the optional `reqwest`-based backend -## ([`crate::reqwest_backend::ReqwestBackend`]). -## -## Off by default so the crate stays usable as a pure, library-agnostic HTTP -## cache: any request library can plug into -## [`crate::backend::HttpBackend`]. +## Optional `reqwest` backend; off by default so any client can plug into +## [`crate::backend::HttpBackend`] instead. reqwest = ["dep:reqwest"] [dependencies] -# `version` is required alongside `path`: `cargo publish` strips the path -# override and the published manifest must reference the crates.io release. +# `version` must stay alongside `path`: `cargo publish` strips the path override. gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["core"] } bytes = "1" http = "1" @@ -35,9 +30,8 @@ thiserror = "2" serde_json = { workspace = true } tokio = { version = "1", features = ["macros", "rt"] } -# Docs.rs convention (see AGENTS.md / main crate): render with every feature so -# the `reqwest`-gated `reqwest_backend` module and its re-export appear; the -# default-features render would leave dead intra-doc links to them. +# All-features render: the reqwest-gated module and re-export would otherwise +# be dead intra-doc links. [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/gpui-query-http/src/backend.rs b/crates/gpui-query-http/src/backend.rs index 4450749..46e4004 100644 --- a/crates/gpui-query-http/src/backend.rs +++ b/crates/gpui-query-http/src/backend.rs @@ -1,14 +1,6 @@ -//! Library-agnostic HTTP backend abstraction. -//! -//! [`HttpBackend`] abstracts a single conditional `GET` so [`crate::HttpCache`] -//! is not tied to one HTTP client. The crate ships -//! [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature; -//! implement this trait to plug in any other client. -//! -//! The trait returns `impl Future + MaybeSend` instead of using `async fn` so -//! the futures are `Send` on native targets (usable from any executor) while -//! `wasm32` still works (see [`MaybeSend`]). That makes it non-object-safe; -//! dispatch is static via `HttpCache<B: HttpBackend>`. +//! A library-agnostic conditional `GET` so [`HttpCache`](crate::HttpCache) +//! stays client-agnostic; the crate ships +//! [`ReqwestBackend`](crate::reqwest_backend::ReqwestBackend) behind `reqwest`. use std::future::Future; @@ -17,22 +9,18 @@ use http::HeaderMap; use crate::CacheMeta; -/// Conditional request headers for a revalidation fetch, mirroring the two -/// validators [`crate::CacheMeta`] tracks. Attach whichever are `Some` to the -/// outgoing request; a server that still matches them answers `304 Not -/// Modified`, which [`crate::HttpCache`] turns into a cheap cache hit. +/// Validators for a revalidation fetch: attach whichever are `Some`, and a +/// server that still matches answers `304`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Conditionals { - /// The `If-None-Match` header value (sourced from a cached `ETag`). + /// `If-None-Match` value, from a cached `ETag`. pub if_none_match: Option<String>, - /// The `If-Modified-Since` header value (sourced from a cached - /// `Last-Modified`). + /// `If-Modified-Since` value, from a cached `Last-Modified`. pub if_modified_since: Option<String>, } impl Conditionals { - /// Validators from cached `meta`, or [`Conditionals::default`] when - /// `meta` is `None` (first fetch, nothing cached yet). + /// Validators from cached `meta`; `None` yields the empty default. pub fn from_meta(meta: Option<&CacheMeta>) -> Self { let Some(meta) = meta else { return Self::default(); @@ -44,58 +32,38 @@ impl Conditionals { } } -/// An owned, library-agnostic HTTP response. -/// -/// Backends translate their native response into this shape so -/// [`crate::HttpCache`] can reason about status, headers, and body without -/// depending on any client crate: `http::HeaderMap` headers and owned -/// [`Bytes`] let the response outlive the underlying connection. +/// An owned, client-agnostic response that outlives the underlying connection. #[derive(Clone, Debug)] pub struct BackendResponse { - /// The HTTP status code (e.g. `200`, `304`). + /// The HTTP status code. pub status: u16, - /// The response headers, as an [`http::HeaderMap`]. + /// The response headers. pub headers: HeaderMap, - /// The response body, owned. + /// The response body. pub body: Bytes, } -/// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. -/// -/// Bounds [`HttpBackend::fetch`]'s future: on native targets the bound is -/// exactly [`Send`] (any executor may move the future across threads), and -/// every `Send` type implements it. +/// [`Send`] alias for [`HttpBackend::fetch`]'s future on native targets. #[cfg(not(target_arch = "wasm32"))] pub trait MaybeSend: Send {} #[cfg(not(target_arch = "wasm32"))] impl<T: ?Sized + Send> MaybeSend for T {} -/// Marker alias for [`Send`], relaxed to a no-op on `wasm32`. -/// -/// On `wasm32` every type implements it: execution is single-threaded and -/// JS interop types (including `reqwest`'s browser-fetch futures) are -/// `!Send` by design, so no `Send` requirement is imposed. +/// No-op on `wasm32`: single-threaded, and JS interop futures are `!Send`. #[cfg(target_arch = "wasm32")] pub trait MaybeSend {} #[cfg(target_arch = "wasm32")] impl<T: ?Sized> MaybeSend for T {} -/// A library-agnostic conditional `GET` backend. -/// -/// Implement this for your HTTP client (the crate ships -/// [`crate::reqwest_backend::ReqwestBackend`] behind the `reqwest` feature) -/// and hand an instance to [`crate::HttpCache::new`]. Implementations must -/// attach the [`Conditionals`] validator headers when present, perform the -/// `GET`, and translate the native response into [`BackendResponse`]. -/// -/// The returned future must be [`MaybeSend`] (`Send` everywhere except -/// `wasm32`), which makes the trait non-object-safe; dispatch is static via -/// `HttpCache<B>`. +/// A conditional `GET` backend: attach the [`Conditionals`] validators when +/// present and translate the native response into [`BackendResponse`]. The +/// [`MaybeSend`] future makes the trait non-object-safe, so dispatch is +/// static via [`HttpCache<B>`](crate::HttpCache). pub trait HttpBackend: Send + Sync { - /// The native error type returned by the underlying client. + /// The underlying client's native error type. type Error: std::error::Error + Send + Sync + 'static; - /// Perform a conditional `GET` against `url`. + /// Performs a conditional `GET` against `url`. fn fetch( &self, url: &str, diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 52c6fef..a259700 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -1,13 +1,6 @@ -//! The URL-keyed HTTP cache, [`HttpCache`]. -//! -//! [`HttpCache`] wraps any [`crate::backend::HttpBackend`] with an in-memory -//! cache keyed by URL string. Fresh entries skip the network; stale entries -//! revalidate with `If-None-Match` / `If-Modified-Since`, and a `304` -//! re-serves the cached body. -//! -//! Concurrency: two [`std::sync::Mutex`]es (meta, bodies), each taken in a -//! short scoped block, never held across an `.await`. The cache is -//! `Send + Sync` and runtime-agnostic. +//! The URL-keyed HTTP cache, [`HttpCache`]: fresh entries skip the network, +//! stale entries revalidate with `If-None-Match` / `If-Modified-Since`, and a +//! `304` re-serves the cached body. use std::collections::HashMap; use std::sync::Mutex; @@ -24,39 +17,30 @@ use crate::{CacheMeta, ParseError, cache_policy_from_headers}; /// Errors raised by [`HttpCache::fetch`]. #[derive(Debug, Error)] pub enum HttpError { - /// The underlying backend failed to perform the request; the source error - /// is preserved for downcasting or cause-chain walks. + /// The backend request failed; the source is kept for cause chains. #[error("backend request failed")] Backend { - /// The source error from the backend. + /// The underlying backend error. #[source] source: Box<dyn std::error::Error + Send + Sync + 'static>, }, - /// Response cache headers could not be parsed into a [`CachePolicy`]. - /// [`HttpCache::fetch`] itself degrades unparseable headers to - /// [`CachePolicy::NoCache`] instead of failing, so this surfaces only for - /// direct users of [`cache_policy_from_headers`]. + /// Unparseable cache headers; [`HttpCache::fetch`] degrades them to + /// [`CachePolicy::NoCache`], so only direct parser callers see this. #[error(transparent)] InvalidPolicy(#[from] ParseError), - /// The server returned `304 Not Modified` but the cache holds no body for - /// this URL to fall back on. + /// `304 Not Modified` arrived with no cached body to fall back on. #[error("received 304 without a cached body for {url:?}")] NotModifiedWithoutCachedBody { /// The URL that produced the spurious `304`. url: String, }, - /// A cache [`Mutex`] was poisoned; surfaced as a typed error so one - /// poisoned cache fails a request instead of panicking the caller. + /// A cache mutex was poisoned (fails the request instead of panicking). #[error("cache mutex poisoned")] Poisoned, } -/// A URL-keyed HTTP cache layered over a [`HttpBackend`]. -/// -/// Generic over the backend so dispatch is static (no `Box<dyn>` overhead). -/// Entries are keyed by the exact URL string: no normalization, and `Vary` is -/// ignored. There is no eviction: the cache grows with every distinct URL, -/// so scope instances accordingly. +/// A URL-keyed cache over a [`HttpBackend`]: exact-string keys (no +/// normalization, `Vary` ignored), no eviction, mutexes never held across `.await`. pub struct HttpCache<B: HttpBackend> { backend: B, meta: Mutex<HashMap<String, CacheMeta>>, @@ -64,7 +48,7 @@ pub struct HttpCache<B: HttpBackend> { } impl<B: HttpBackend> HttpCache<B> { - /// Create a new cache backed by `backend`, starting empty. + /// Creates an empty cache over `backend`. pub fn new(backend: B) -> Self { Self { backend, @@ -73,13 +57,10 @@ impl<B: HttpBackend> HttpCache<B> { } } - /// Fetch `url`: a fresh cached entry skips the network, otherwise the - /// backend revalidates. - /// - /// Returns `(body, policy, meta)`. Only a cacheable `200` populates the - /// cache and yields `meta`; every other status (and any `no-store`, - /// absent, or unparseable `Cache-Control`) returns the body with - /// [`CachePolicy::NoCache`] and `None`. A `304` re-serves the cached body. + /// Fetches `url`: a fresh entry skips the network, a stale one + /// revalidates. Returns `(body, policy, meta)`: only a cacheable `200` + /// stores and yields `meta`, a `304` re-serves the cached body, and + /// everything else is [`CachePolicy::NoCache`] with `None`. pub async fn fetch( &self, url: &str, @@ -89,15 +70,13 @@ impl<B: HttpBackend> HttpCache<B> { guard.get(url).cloned() }; - // Fresh hit: no backend call at all. checked_add: never panic if a - // future serde-hydrated CacheMeta carries an extreme stored_at. + // checked_add: an extreme serde-hydrated stored_at must not panic. if let Some(meta) = cached_meta.as_ref() && meta.stored_at.checked_add(meta.fresh_for).is_none_or(|t| t > SystemTime::now()) && let Some(body) = self.cached_body(url)? { return Ok((body, policy_from_meta(meta), cached_meta.clone())); } - // Not fresh, or meta without a body: revalidate. let conditionals = Conditionals::from_meta(cached_meta.as_ref()); let resp = self @@ -125,7 +104,6 @@ impl<B: HttpBackend> HttpCache<B> { return self.store_fresh(url, resp); } - // No other status is stored (conservative subset of RFC 9111 §3). Ok((resp.body, CachePolicy::NoCache, None)) } @@ -134,7 +112,6 @@ impl<B: HttpBackend> HttpCache<B> { Ok(guard.get(url).cloned()) } - /// Parse the policy from a `200`, store body + meta, return the triple. fn store_fresh( &self, url: &str, @@ -143,8 +120,6 @@ impl<B: HttpBackend> HttpCache<B> { let BackendResponse { headers, body, .. } = resp; - // A malformed cache hint must never fail the data fetch itself: - // serve the body uncacheable. let Ok(policy) = cache_policy_from_headers(&headers) else { return Ok((body, CachePolicy::NoCache, None)); }; @@ -175,8 +150,6 @@ impl<B: HttpBackend> HttpCache<B> { } } -/// Read a single header value as an owned [`String`], or `None` if absent or -/// non-ASCII. fn header_str(headers: &HeaderMap, name: &str) -> Option<String> { headers .get(name) @@ -184,19 +157,14 @@ fn header_str(headers: &HeaderMap, name: &str) -> Option<String> { .map(str::to_string) } -/// `fresh_for` from a policy's TTL window. fn fresh_for_from_policy(policy: CachePolicy) -> Duration { Duration::from_millis(policy.ttl_ms().unwrap_or(0)) } -/// `stale_for` from a policy's SWR window. fn stale_for_from_policy(policy: CachePolicy) -> Duration { Duration::from_millis(policy.stale_ms().unwrap_or(0)) } -/// Invert the two helpers above: non-zero `stale_for` selects -/// [`CachePolicy::StaleWhileRevalidate`], non-zero `fresh_for` selects -/// [`CachePolicy::Ttl`], both-zero collapses to [`CachePolicy::NoCache`]. fn policy_from_meta(meta: &CacheMeta) -> CachePolicy { let ttl_ms = u64::try_from(meta.fresh_for.as_millis()).unwrap_or(0); let stale_ms = u64::try_from(meta.stale_for.as_millis()).unwrap_or(0); @@ -218,8 +186,6 @@ mod tests { use std::collections::VecDeque; use std::future::Future; - /// Mock backend: pops canned responses from a FIFO queue and counts - /// calls so tests can assert short-circuit behavior. struct MockBackend { responses: Mutex<VecDeque<Result<BackendResponse, MockError>>>, calls: Mutex<usize>, @@ -259,7 +225,6 @@ mod tests { *calls += 1; self.responses.lock().unwrap().pop_front() }; - // Queue exhausted -> mock error so the test fails loudly. async move { match next { Some(Ok(r)) => Ok(r), @@ -334,7 +299,6 @@ mod tests { let (body1, _, _) = cache.fetch("https://example.test/c").await.unwrap(); assert_eq!(body1, Bytes::from_static(b"payload")); - // max-age=0 -> not fresh -> conditional refetch -> 304. let (body2, _, meta2) = cache.fetch("https://example.test/c").await.unwrap(); assert_eq!(body2, Bytes::from_static(b"payload"), "304 served cached body"); assert!(meta2.is_some(), "304 still yields cached meta"); @@ -361,7 +325,6 @@ mod tests { #[tokio::test] async fn malformed_cache_control_degrades_to_no_cache() { - // A malformed cache hint must not fail the data fetch. let backend = MockBackend::new(vec![Ok(resp_200("body", "max-age=abc"))]); let cache = HttpCache::new(backend); @@ -404,8 +367,6 @@ mod tests { #[tokio::test] async fn overflow_max_age_caches_saturated() { - // RFC 9111 §1.2.2: over-large delta-seconds saturate; the entry is - // effectively fresh forever and later fetches short-circuit. let backend = MockBackend::new(vec![ Ok(resp_200("big", "max-age=99999999999999999999999")), Ok(resp_200("second", "max-age=1")), @@ -425,7 +386,6 @@ mod tests { #[tokio::test] async fn not_modified_without_cached_body_is_typed_error() { - // A 304 is only meaningful as a revalidation of a cached entry. let backend = MockBackend::new(vec![Ok(resp_304_with_etag("\"v1\""))]); let cache = HttpCache::new(backend); diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index eb05817..e67a130 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -1,18 +1,9 @@ //! HTTP cache-header helpers for [`gpui_query`]: turn server cache headers //! into a [`CachePolicy`] ("server wins") and layer an in-memory [`HttpCache`] -//! over any [`HttpBackend`]. -//! -//! Depends on `gpui-query` core only (no GPUI), so this works from any async -//! runtime. [`HttpCache`] is library-agnostic; the optional `reqwest` feature -//! supplies [`ReqwestBackend`] as one backend. +//! over any [`HttpBackend`]. Core-only dependency, any async runtime. //! //! # Server wins //! -//! Parse the response headers with [`cache_policy_from_headers`], hand the -//! resulting [`CachePolicy`] to -//! [`Fetched::with_policy`](gpui_query::core::Fetched::with_policy), and the -//! resource adopts the server's TTL: -//! //! ```no_run //! # use gpui_query_http::cache_policy_from_headers; //! # use gpui_query::core::{CachePolicy, Fetched}; @@ -24,8 +15,6 @@ //! ``` #![deny(missing_docs)] -// docs.rs renders with `--cfg docsrs` (see [package.metadata.docs.rs]); enable -// `#[doc(cfg(...))]` there so feature-gated items are annotated. #![cfg_attr(docsrs, feature(doc_cfg))] use std::time::Duration; @@ -47,54 +36,42 @@ pub use cache::{HttpCache, HttpError}; #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] pub use reqwest_backend::ReqwestBackend; -/// HTTP cache metadata extracted from a response. -/// -/// Serializable (epoch-based [`SystemTime`](std::time::SystemTime)) so a -/// persistence layer can store it alongside the body and rehydrate a cold -/// start with valid validators for cheap `304` refetches. +/// Cache metadata from a response, serializable (epoch-based) so a +/// persistence layer can rehydrate validators for cheap `304` refetches. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CacheMeta { - /// `ETag` response header, if present (for `If-None-Match` on refetch). + /// Sent as `If-None-Match` on revalidation. pub etag: Option<String>, - /// `Last-Modified` response header, if present (for `If-Modified-Since`). + /// Sent as `If-Modified-Since` on revalidation. pub last_modified: Option<String>, - /// When this cached entry was stored. + /// When the entry was stored; freshness is measured from here. pub stored_at: std::time::SystemTime, - /// How long the entry is considered fresh (the TTL window). + /// Freshness window, from the policy's TTL. pub fresh_for: Duration, - /// How long a stale entry may be served while revalidating (the SWR window). + /// SWR window: how long a stale entry may serve while revalidating. pub stale_for: Duration, } /// Errors parsing cache headers into a [`CachePolicy`]. #[derive(Debug, Error)] pub enum ParseError { - /// `max-age` (or `s-maxage`) directive had a non-integer value. + /// `max-age` (or `s-maxage`) had a non-integer value. #[error("invalid max-age value: {0}")] InvalidMaxAge(String), - /// `stale-while-revalidate` directive had a non-integer value. + /// `stale-while-revalidate` had a non-integer value. #[error("invalid stale-while-revalidate value: {0}")] InvalidStaleWhileRevalidate(String), } -/// Derive a [`CachePolicy`] from response cache headers ("server wins"). -/// -/// - `no-store` / `no-cache` anywhere returns [`CachePolicy::NoCache`], -/// regardless of position or malformed directives elsewhere (RFC 9111 -/// §5.2.2: storing is forbidden outright). -/// - Otherwise the first `max-age` sets the TTL, falling back to `s-maxage` -/// when absent ([`HttpCache`] is a private cache, and RFC 9111 §5.2.2.10 -/// scopes `s-maxage` to shared caches). A `stale-while-revalidate` -/// alongside yields [`CachePolicy::StaleWhileRevalidate`]. Duplicates keep -/// their first occurrence (RFC 9111 §4.2.1), and a delta-seconds too -/// large for `u64` saturates instead of erroring (RFC 9111 §1.2.2). -/// - Anything else returns [`CachePolicy::NoCache`]; malformed values surface -/// as [`ParseError`]. -/// -/// Directive names match case-insensitively; values may be quoted. +/// Derives a [`CachePolicy`] from response `Cache-Control` headers ("server +/// wins"): `no-store`/`no-cache` anywhere wins regardless of position (RFC +/// 9111 §5.2.2); otherwise the first `max-age` sets the TTL, falling back to +/// `s-maxage` only when absent (private cache; §5.2.2.10), and a +/// `stale-while-revalidate` alongside yields the SWR policy. Duplicates keep +/// their first occurrence (§4.2.1); over-large delta-seconds saturate +/// (§1.2.2); malformed values error as [`ParseError`]. pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, ParseError> { - // Slots are Option<Result<..>>: first occurrence wins, and a malformed - // value is only surfaced after the scan so no-store/no-cache dominates. + // Option<Result>: first occurrence wins; errors surface only after the scan. let mut s_maxage: Option<Result<u64, ParseError>> = None; let mut max_age: Option<Result<u64, ParseError>> = None; let mut swr: Option<Result<u64, ParseError>> = None; @@ -150,7 +127,7 @@ pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, Par } } -/// Split a `Cache-Control` value on commas outside quoted-strings, so a +/// Splits a `Cache-Control` value on commas outside quoted-strings, so a /// quoted argument containing `,` cannot smuggle in extra directives. fn split_cache_directives(raw: &str) -> impl Iterator<Item = &str> { let mut pos = 0; @@ -185,8 +162,6 @@ fn split_cache_directives(raw: &str) -> impl Iterator<Item = &str> { }) } -/// Parse a delta-seconds argument. All-digit values that overflow `u64` -/// saturate to `u64::MAX` per RFC 9111 §1.2.2; anything else is malformed. fn parse_secs(is_stale: bool, raw: &str) -> Result<u64, ParseError> { if !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit()) { return Ok(match raw.parse::<u128>() { @@ -208,7 +183,6 @@ mod tests { use gpui_query::core::CachePolicy; use http::HeaderMap; - /// Build a `HeaderMap` from a single `Cache-Control` value. fn cc(value: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert(http::header::CACHE_CONTROL, value.parse().unwrap()); @@ -236,15 +210,12 @@ mod tests { #[test] fn max_age_wins_over_s_maxage() { - // Private cache: only a shared cache may prefer s-maxage - // (RFC 9111 §5.2.2.10), so max-age wins when both are present. let policy = cache_policy_from_headers(&cc("max-age=10, s-maxage=30")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 10_000 }); } #[test] fn s_maxage_alone_sets_ttl() { - // No max-age to shadow it: s-maxage still applies as the fallback. let policy = cache_policy_from_headers(&cc("s-maxage=30")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 30_000 }); } @@ -281,7 +252,6 @@ mod tests { #[test] fn no_store_wins_even_after_malformed_value() { - // Order-independent: a malformed max-age must not mask no-store. let policy = cache_policy_from_headers(&cc("max-age=abc, no-store")).unwrap(); assert_eq!(policy, CachePolicy::NoCache); } @@ -318,7 +288,6 @@ mod tests { #[test] fn quoted_comma_cannot_smuggle_directives() { - // The "max-age" text is inside a quoted argument, not a directive. let policy = cache_policy_from_headers(&cc("private=\"a, max-age=86400\"")).unwrap(); assert_eq!(policy, CachePolicy::NoCache); } @@ -343,16 +312,12 @@ mod tests { #[test] fn duplicate_directives_keep_first_occurrence() { - // RFC 9111 §4.2.1: first occurrence wins, so a trailing injected - // duplicate cannot extend the TTL. let policy = cache_policy_from_headers(&cc("max-age=600, max-age=86400")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 600_000 }); } #[test] fn digit_overflow_saturates_instead_of_erroring() { - // RFC 9111 §1.2.2: values too large to represent are the largest - // representable value, not an error. let policy = cache_policy_from_headers(&cc("max-age=99999999999999999999999")).unwrap(); assert_eq!(policy, CachePolicy::Ttl { ttl_ms: u64::MAX }); } diff --git a/crates/gpui-query-http/src/reqwest_backend.rs b/crates/gpui-query-http/src/reqwest_backend.rs index 4ae772b..04111dc 100644 --- a/crates/gpui-query-http/src/reqwest_backend.rs +++ b/crates/gpui-query-http/src/reqwest_backend.rs @@ -1,24 +1,17 @@ -//! The optional `reqwest`-based [`HttpBackend`] implementation, compiled when -//! the `reqwest` cargo feature is enabled: -//! -//! ```toml -//! [dependencies] -//! gpui-query-http = { version = "0.1", features = ["reqwest"] } -//! ``` -//! -//! `reqwest` is just one backend; any client that can perform a conditional -//! `GET` can implement [`crate::backend::HttpBackend`] instead. +//! A [`HttpBackend`] over `reqwest`, enabled via the crate's `reqwest` +//! feature; any client that can perform a conditional `GET` can implement +//! the trait instead. use std::future::Future; use crate::backend::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; -/// A [`HttpBackend`] backed by a caller-configured [`reqwest::Client`], -/// reused across requests as `reqwest` intends. +/// A [`HttpBackend`] over a caller-configured [`reqwest::Client`], reused +/// across requests as `reqwest` intends. pub struct ReqwestBackend(pub reqwest::Client); impl ReqwestBackend { - /// Wrap a pre-configured client; `ReqwestBackend(client)` works too. + /// Wraps a pre-configured client; `ReqwestBackend(client)` also works. pub fn from_client(client: reqwest::Client) -> Self { Self(client) } @@ -32,8 +25,7 @@ impl HttpBackend for ReqwestBackend { url: &str, conditionals: Conditionals, ) -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + MaybeSend { - // Build eagerly so the returned future stays `Send` on native targets - // even where RequestBuilder is not. + // Build eagerly so the future stays `Send` where RequestBuilder is not. let mut req = self.0.get(url); if let Some(etag) = conditionals.if_none_match { req = req.header(reqwest::header::IF_NONE_MATCH, etag); @@ -44,7 +36,6 @@ impl HttpBackend for ReqwestBackend { async move { let mut resp = req.send().await?; let status = resp.status().as_u16(); - // Take the map: HeaderMap::clone would copy every entry. let headers = std::mem::take(resp.headers_mut()); let body = resp.bytes().await?; Ok(BackendResponse { From adf653ab3dc99d952252898c8b1fcd4e37c9f421 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 21:24:50 +0200 Subject: [PATCH 041/111] refactor: purge file persister comments to one-line-or-examples and strip test comments --- crates/gpui-query-persist/Cargo.toml | 3 +- crates/gpui-query-persist/src/lib.rs | 98 +++---------------- .../tests/file_persister.rs | 12 +-- 3 files changed, 18 insertions(+), 95 deletions(-) diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index d04f34e..2d655b6 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -15,8 +15,7 @@ authors = ["hmziqrs"] default = [] [dependencies] -# `version` is required alongside `path`: `cargo publish` strips the path -# override and the published manifest must reference the crates.io release. +# `version` is required alongside `path`: `cargo publish` strips the path override. gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["persist", "client", "hook"] } dirs = "6" tempfile = "3" diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index fa4101e..3ead3d8 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -1,44 +1,6 @@ //! Reference disk persistence adapter for [`gpui_query`]: [`FilePersister`], //! an atomic, durable [`Persister`] over one JSON or bincode file, plus a //! re-exported [`NoopPersister`] for tests and disabled modes. -//! -//! # Atomic write -//! -//! Each save serializes the snapshot to a sibling [`tempfile::NamedTempFile`] -//! (random name, created `O_EXCL` in the target's own directory, so a shared -//! `/tmp` is never involved and symlink planting fails), fsyncs it -//! (`F_FULLFSYNC` on macOS, plain `fsync` elsewhere), then renames it over -//! the target. On POSIX the parent directory is fsynced after the replace. -//! A crash mid-write therefore leaves the previous file intact plus, at -//! worst, one stray `<name>.<random>.tmp` sibling. The file is created with -//! owner-only permissions (`0o600` on Unix), since a query cache is app- and -//! user-private data. -//! -//! # Tolerant load -//! -//! A missing file yields an empty snapshot; a corrupt or unparseable one is -//! logged and treated as empty; a version mismatch returns -//! [`PersistError::VersionMismatch`] so callers can tell "corrupt" from -//! "wrong format". -//! -//! # Concurrency -//! -//! Saves on one persister are serialized by a `std::sync::Mutex`. Loads skip -//! the lock: the rename is atomic, so a load concurrent with a save sees -//! either the old or the new complete file. Two persister instances on the -//! same path likewise cannot corrupt each other; each save replaces the -//! whole file and the last writer wins, matching the [`Persister`] contract. -//! -//! The async methods do synchronous `std::fs` I/O with no await points, which -//! is what GPUI's blocking-friendly `background_executor` is for. On a tokio -//! runtime, wrap `load`/`save` in `spawn_blocking` to avoid stalling worker -//! threads. -//! -//! On Windows the atomic replace can fail with `ERROR_ACCESS_DENIED` while an -//! antivirus scanner or concurrent reader holds the destination; that is -//! surfaced as the retryable [`PersistError::Permission`] rather than -//! [`PersistError::Io`], which still carries the original error (kind and -//! source chain intact) for every other failure. #![deny(missing_docs)] @@ -62,11 +24,11 @@ pub enum PersistFormat { Bincode, } -/// Atomic, durable disk-backed [`Persister`]. -/// -/// Saves write a sibling [`tempfile::NamedTempFile`], fsync it, and rename it -/// over the target, so a crash never leaves a truncated file. See the -/// [crate docs](crate) for the durability and concurrency story. +/// Atomic, durable [`Persister`]: each save writes a sibling `O_EXCL` temp file, fsyncs it, +/// renames it over the target, then fsyncs the parent directory, so a crash never leaves a +/// truncated file. Owner-only (`0o600` on Unix). A missing or corrupt file loads as empty; a +/// version mismatch returns [`PersistError::VersionMismatch`]; Windows `ERROR_ACCESS_DENIED` +/// (antivirus, concurrent reader) maps to retryable [`PersistError::Permission`]. pub struct FilePersister { path: PathBuf, format: PersistFormat, @@ -93,12 +55,8 @@ impl FilePersister { Self::new(path, PersistFormat::Bincode) } - /// Construct a JSON persister at `<cache_dir>/<app_name>/gpui-query-cache.json`. - /// - /// Returns [`PersistError::BadPath`] when the OS reports no cache dir. - /// The cache dir (rather than Roaming config) is deliberate: the file is - /// a regenerable offline cache, not state worth syncing. `app_name` is - /// joined as-is, so treat it as trusted configuration. + /// JSON persister at `<cache_dir>/<app_name>/gpui-query-cache.json`; [`PersistError::BadPath`] + /// when the OS reports no cache dir. `app_name` is joined as-is, so treat it as trusted. pub fn in_cache_dir(app_name: impl AsRef<str>) -> Result<Self, PersistError> { let app_name = app_name.as_ref(); let dir = dirs::cache_dir().ok_or_else(|| { @@ -112,7 +70,6 @@ impl FilePersister { &self.path } - /// Serialize + atomically write `snapshot` to disk. fn write_atomic(&self, snapshot: &PersistSnapshot) -> Result<(), PersistError> { let _guard = self .write_lock @@ -136,8 +93,6 @@ impl FilePersister { } }; - // Sibling temp file, fsync, rename over the target. The .tmp suffix - // keeps crash orphans identifiable for cleanup. let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); let mut tmp = tempfile::Builder::new() .prefix( @@ -171,9 +126,7 @@ impl FilePersister { Ok(()) } - /// Tolerantly read + deserialize the snapshot from disk. Lock-free: the - /// atomic rename means a concurrent save can only swap in another - /// complete file, never expose a partial one. + /// Lock-free: the atomic rename means a concurrent save only swaps in a complete file. fn read_tolerant(&self) -> Result<PersistSnapshot, PersistError> { let mut file = match File::open(&self.path) { Ok(f) => f, @@ -223,19 +176,10 @@ impl Persister for FilePersister { } } -/// A [`Persister`] that persists nothing and loads an empty snapshot, for -/// tests or disabled modes. Re-exported from `gpui_query::client` so this -/// crate is a one-stop import. pub use gpui_query::client::NoopPersister; -// ── helpers ───────────────────────────────────────────────────────────── - -/// Bincode-safe adapter for [`PersistSnapshot`]. -/// -/// `serde_json::Value` deserializes via `deserialize_any`, which bincode's -/// non-self-describing format cannot drive. The adapter stores each entry's -/// `value` (and `meta`) as a JSON `String`, which bincode carries natively. -/// The conversion is lossless. +/// bincode cannot drive `serde_json::Value`'s `deserialize_any`; `value` and +/// `meta` are carried as JSON strings. Lossless. #[derive(serde::Serialize, serde::Deserialize)] struct BincodeSnapshot { entries: HashMap<String, BincodeEntry>, @@ -244,7 +188,6 @@ struct BincodeSnapshot { #[derive(serde::Serialize, serde::Deserialize)] struct BincodeEntry { - /// The entry's value, JSON-encoded to a String so bincode can carry it. value_json: String, cached_at: u64, cache_policy: CachePolicy, @@ -297,16 +240,13 @@ impl BincodeSnapshot { } } -/// Decode a bincode-format snapshot, flattening both the bincode step and the -/// inner JSON step into one String error (the tolerant path only logs it). fn bincode_load(buf: &[u8]) -> Result<PersistSnapshot, String> { let adapter: BincodeSnapshot = bincode::deserialize(buf).map_err(|e| e.to_string())?; adapter.into_snapshot().map_err(|e| e.to_string()) } -/// Also match raw Windows `ERROR_ACCESS_DENIED` (5); std maps it to -/// `PermissionDenied`, but errors built via `from_raw_os_error` on older -/// toolchains may not be normalized. +/// std maps `ERROR_ACCESS_DENIED` (5) to `PermissionDenied`; errors built +/// via `from_raw_os_error` on older toolchains may not be. #[cfg(windows)] const ERROR_ACCESS_DENIED: i32 = 5; fn is_windows_access_denied(raw: Option<i32>) -> bool { @@ -321,20 +261,17 @@ fn is_windows_access_denied(raw: Option<i32>) -> bool { } } -/// macOS `F_FULLFSYNC`: unlike `fsync`, it also flushes the drive's write -/// cache. Best-effort; failure is logged and the save still succeeds on the -/// strength of the preceding `sync_all`. +/// macOS `F_FULLFSYNC` also flushes the drive's write cache, unlike plain +/// `fsync`. Best-effort: the save already succeeded via the earlier `sync_all`. #[cfg(target_os = "macos")] fn try_fullfsync(file: &File) { - // F_FULLFSYNC = 0x00008027 (fcntl.h on Darwin); extern declared here to - // avoid a libc dependency. + // F_FULLFSYNC = 0x00008027 (fcntl.h on Darwin); extern declared here to avoid a libc dep. unsafe extern "C" { fn fcntl(fd: std::os::fd::RawFd, cmd: std::ffi::c_int, ...) -> std::ffi::c_int; } const F_FULLFSYNC: std::ffi::c_int = 0x00008027; use std::os::fd::AsRawFd; - // SAFETY: F_FULLFSYNC takes no argument (the variadic tail is unused) and - // the fd is the temp file we just wrote. + // SAFETY: no variadic argument is passed and the fd is the temp file we just wrote. let rc = unsafe { fcntl(file.as_raw_fd(), F_FULLFSYNC) }; if rc != 0 { eprintln!("FilePersister: F_FULLFSYNC failed (rc={rc}); relying on fsync"); @@ -366,8 +303,6 @@ fn fsync_parent(parent: &Path) { mod tests { use super::*; - // Valid bincode frame with garbage value_json: the frame itself - // round-trips, so only into_snapshot's JSON step can fail. #[test] fn bincode_corrupt_inner_json_is_tolerated() { let adapter = BincodeSnapshot { @@ -388,7 +323,6 @@ mod tests { "corrupt inner JSON must surface as a load error" ); - // Same bytes on disk go through the tolerant path: logged, empty. let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.bin"); std::fs::write(&path, &bytes).expect("write framed payload"); diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index 29c7d05..16376a7 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -1,6 +1,4 @@ -//! Round-trip, concurrency, and corrupt-file tests for `FilePersister`, plus -//! the `NoopPersister` no-op. Plain `#[test]`s: the futures do no real async -//! work, so `pollster::block_on` suffices. +//! Plain `#[test]`s: the futures do no real async work, so `pollster::block_on` suffices. use std::collections::HashMap; @@ -33,7 +31,6 @@ fn file_persister_round_trip_json() { let path = dir.path().join("cache.json"); let p = FilePersister::json(&path); - // Missing file -> empty snapshot. let loaded = pollster::block_on(p.load()).expect("load missing"); assert!(loaded.entries.is_empty()); assert_eq!(loaded.version, PERSIST_VERSION); @@ -100,8 +97,6 @@ fn file_persister_concurrent_saves_do_not_corrupt() { let path = dir.path().join("cache.json"); let p = std::sync::Arc::new(FilePersister::json(&path)); - // The internal Mutex serializes saves, so the final file is always one - // writer's complete snapshot. let mut handles = Vec::new(); for i in 0..16u64 { let p = std::sync::Arc::clone(&p); @@ -156,8 +151,6 @@ fn file_persister_format_choice_round_trips() { #[test] fn file_persister_large_snapshot_round_trips() { - // Distinct keys with realistic JSON values guard against truncation and - // size-sensitive regressions in the atomic-write and tolerant-load paths. const N: usize = 10_000; let mut entries = HashMap::with_capacity(N); @@ -208,7 +201,6 @@ fn file_persister_large_snapshot_round_trips() { ); assert_eq!(reloaded.version, PERSIST_VERSION); - // Even index -> active + Ttl policy, per the loop's parity rules. let sample_key = "users::9000"; let entry = reloaded .entries @@ -303,8 +295,6 @@ fn file_persister_cache_file_is_owner_only() { #[test] fn file_persister_second_instance_overwrite_stays_parseable() { - // Two instances share no lock; the second save simply replaces the - // first whole-file. Sequential only, no concurrent writer here. let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.json"); let a = FilePersister::json(&path); From 0437569668806be5470807b97a9f79a19d9fb2c0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 21:31:56 +0200 Subject: [PATCH 042/111] chore: sync hard-rules comments bullet to one-line-or-examples standard --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2915b5b..3cee189 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ Manual escape hatches: `just publish <tag>` (Publish Crate Manual), `just deploy Binding on every change; violations are review-blocking. -- Comments: don't comment unless the code can't say it. Inline comments are 1-2 lines max and only for non-obvious constraints. No narration, no change-history notes ("T5:", "fixed:"), no restating what the code already says. Same for test comments. Doc comments stay at 1-3 lines plus `# Examples` blocks that carry doctests. +- Comments: don't comment unless the code can't say it. Inline comments are 1-2 lines max, true invariants only (lock ordering, RFC citations, platform quirks, safety notes). No narration, no change-history notes ("T5:", "fixed:"), no restating what the code already says. Doc comments: one line that adds what the signature cannot (semantics, constraints, gotchas) or an `# Examples` block with doctests, with no summary line above the fence. Docs that restate the item name or narrate the obvious get deleted. `missing_docs`-forced docs use the minimal legal one-liner. Module docs max 3 lines. Test files carry no comments; test names carry intent. - Skills: load rust-best-practices and rust-testing before writing or reviewing Rust; add rust-async-patterns for async paths and gpui-kit for GPUI-facing code. Run humanizer and humanize-writing over any prose change before merging. - Copywriting: all prose (comments, READMEs, docs) must read human-written: no em-dash cadence, no rule-of-three padding, no "seamless/robust/leverage" vocabulary. - Gates: `cargo test --all-features`, `cargo clippy --all-features --all-targets -- -D warnings`, and `cargo doc --all-features --no-deps` (zero warnings) all pass before a change is done. Bare `cargo test` skips the hook/persist modules; that is not a green run. From 19f3432a60fdd97f64bfed0a0d51e137a181e341 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sat, 19 Sep 2026 23:52:26 +0200 Subject: [PATCH 043/111] fix: close sanitize redaction gaps and trim duplicated core plumbing Sanitize now redacts underscore local-parts in emails and tokens after bearer: or bearer=, with regression tests for both leaks. The rewrite drops the fake-regex dispatch for self-guarded alloc-free redact_* passes, dedupes MaybeRequestId into core::request, routes complete_page_* through the guard path, and removes the record_stale_cache_hit pass-through. --- crates/gpui-query/src/core/error/convert.rs | 2 - crates/gpui-query/src/core/error/mod.rs | 1 - crates/gpui-query/src/core/error/sanitize.rs | 225 ++++++++---------- crates/gpui-query/src/core/error/serde.rs | 2 - crates/gpui-query/src/core/error/types.rs | 2 - crates/gpui-query/src/core/fetched.rs | 7 +- .../src/core/infinite_query/accessors.rs | 2 - .../src/core/infinite_query/lifecycle.rs | 65 ++--- .../gpui-query/src/core/infinite_query/mod.rs | 8 +- .../core/infinite_query/page_management.rs | 2 - .../src/core/infinite_query/resource.rs | 3 - crates/gpui-query/src/core/mod.rs | 12 +- crates/gpui-query/src/core/mutation.rs | 2 - crates/gpui-query/src/core/network_mode.rs | 2 - crates/gpui-query/src/core/policy.rs | 2 +- crates/gpui-query/src/core/refetch.rs | 3 +- crates/gpui-query/src/core/request.rs | 23 +- .../gpui-query/src/core/resource/accessors.rs | 2 - crates/gpui-query/src/core/resource/cache.rs | 26 +- .../src/core/resource/completion.rs | 2 - .../gpui-query/src/core/resource/lifecycle.rs | 29 +-- crates/gpui-query/src/core/retry.rs | 2 - crates/gpui-query/src/core/select.rs | 52 +--- crates/gpui-query/src/core/signal.rs | 2 - crates/gpui-query/src/core/status.rs | 2 - crates/gpui-query/src/lib.rs | 3 - 26 files changed, 160 insertions(+), 323 deletions(-) diff --git a/crates/gpui-query/src/core/error/convert.rs b/crates/gpui-query/src/core/error/convert.rs index c43ea13..85dcca0 100644 --- a/crates/gpui-query/src/core/error/convert.rs +++ b/crates/gpui-query/src/core/error/convert.rs @@ -1,5 +1,3 @@ -//! Standard trait implementations for [`QueryError`](super::QueryError). - use super::types::QueryError; impl std::fmt::Display for QueryError { diff --git a/crates/gpui-query/src/core/error/mod.rs b/crates/gpui-query/src/core/error/mod.rs index 9c7bc5c..4f85d7a 100644 --- a/crates/gpui-query/src/core/error/mod.rs +++ b/crates/gpui-query/src/core/error/mod.rs @@ -1,5 +1,4 @@ //! Error types for query operations. -//! //! Messages are stored verbatim and may reach logs and serialized output; //! use [`QueryError::sanitized`] on server responses. diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 5cd81e6..5652ee4 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -1,6 +1,8 @@ //! Redaction of sensitive patterns from error messages, without a `regex` //! dependency. +use std::borrow::Cow; + pub const SANITIZE_MAX_LEN: usize = 512; const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; @@ -8,31 +10,11 @@ const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "red const PATH_NEEDLES: [&str; 4] = ["/home/", "/users/", "/etc/", "/var/"]; pub(crate) fn sanitize_message(msg: &str) -> String { - use std::borrow::Cow; - - let mut out: Cow<str> = Cow::Borrowed(msg); - - out = replace_regex( - out, - r"(?i)(postgres|mysql|mongodb|redis)://\S+", - "[REDACTED_CONNECTION]", - ); - out = replace_regex( - out, - r"(?i)(bearer\s+|token[=:]\s*)\S+", - "$1[REDACTED_TOKEN]", - ); - out = replace_regex( - out, - r"(?i)(/home/|/Users/|/etc/|/var/)\S+", - "[REDACTED_PATH]", - ); - out = replace_regex( - out, - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", - "[REDACTED_EMAIL]", - ); - out = replace_regex(out, r"\b[0-9a-fA-F]{16,}\b", "[REDACTED_HEX]"); + let out = redact_connections(Cow::Borrowed(msg)); + let out = redact_tokens(out); + let out = redact_paths(out); + let out = redact_emails(out); + let out = redact_hex_runs(out); let mut s = out.into_owned(); if s.len() > SANITIZE_MAX_LEN { @@ -47,70 +29,29 @@ pub(crate) fn sanitize_message(msg: &str) -> String { s } -/// The `pattern` string selects which rule runs; each rule guards with a cheap -/// `contains` and returns the input still-borrowed when nothing can match. -fn replace_regex<'a>( - input: std::borrow::Cow<'a, str>, - pattern: &str, - replacement: &str, -) -> std::borrow::Cow<'a, str> { - let text: &str = &input; - // ASCII lowercasing preserves byte offsets, so lowercased positions are valid indices into `text`. - let owned = match pattern { - p if p.contains("postgres") => { - let lower = text.to_ascii_lowercase(); - if !SCHEME_NEEDLES.iter().any(|n| lower.contains(n)) { - return input; - } - redact_until_whitespace(text, &lower, &SCHEME_NEEDLES, replacement) - } - p if p.contains("bearer") || p.contains("token") => { - let lower = text.to_ascii_lowercase(); - if !lower.contains("bearer") && !lower.contains("token") { - return input; - } - redact_tokens(text, replacement) - } - p if p.contains("/home/") => { - let lower = text.to_ascii_lowercase(); - if !PATH_NEEDLES.iter().any(|n| lower.contains(n)) { - return input; - } - redact_until_whitespace(text, &lower, &PATH_NEEDLES, replacement) - } - p if p.contains("@") && p.contains(".") => { - if !text.contains('@') { - return input; - } - redact_emails(text, replacement) - } - p if p.contains("0-9a-f") => { - if !has_long_hex_run(text) { - return input; - } - redact_hex(text, replacement) - } - _ => { - debug_assert!(false, "replace_regex: unrecognized pattern {pattern:?}"); - return input; - } - }; - std::borrow::Cow::Owned(owned) +/// ASCII-case-insensitive `contains` without allocating a lowercased copy. +fn contains_ascii_ci(haystack: &str, needle: &str) -> bool { + haystack + .as_bytes() + .windows(needle.len()) + .any(|w| w.eq_ignore_ascii_case(needle.as_bytes())) } -fn has_long_hex_run(text: &str) -> bool { - let mut run = 0usize; - for c in text.chars() { - if c.is_ascii_hexdigit() { - run += 1; - if run >= 16 { - return true; - } - } else { - run = 0; - } +fn redact_connections(input: Cow<'_, str>) -> Cow<'_, str> { + if !SCHEME_NEEDLES.iter().any(|n| contains_ascii_ci(&input, n)) { + return input; } - false + // ASCII lowercasing preserves byte offsets, so `lower` indexes are valid in `input`. + let lower = input.to_ascii_lowercase(); + redact_until_whitespace(&input, &lower, &SCHEME_NEEDLES, "[REDACTED_CONNECTION]").into() +} + +fn redact_paths(input: Cow<'_, str>) -> Cow<'_, str> { + if !PATH_NEEDLES.iter().any(|n| contains_ascii_ci(&input, n)) { + return input; + } + let lower = input.to_ascii_lowercase(); + redact_until_whitespace(&input, &lower, &PATH_NEEDLES, "[REDACTED_PATH]").into() } fn redact_until_whitespace( @@ -147,25 +88,27 @@ fn redact_until_whitespace( result } -fn redact_tokens(text: &str, replacement: &str) -> String { - let mut result = String::with_capacity(text.len()); - let chars: Vec<char> = text.chars().collect(); +fn redact_tokens(input: Cow<'_, str>) -> Cow<'_, str> { + if !contains_ascii_ci(&input, "bearer") && !contains_ascii_ci(&input, "token") { + return input; + } + let chars: Vec<char> = input.chars().collect(); let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect(); let len = chars.len(); + let mut result = String::with_capacity(input.len()); let mut i = 0; - while i < len { - if lower_matches_at(&lower, i, "bearer") - && i + 6 < len - && chars[i + 6].is_ascii_whitespace() - { - for c in &chars[i..i + 7] { - result.push(*c); + if lower_matches_at(&lower, i, "bearer") && i + 6 < len { + let sep = i + 6; + if chars[sep].is_ascii_whitespace() || chars[sep] == ':' || chars[sep] == '=' { + for c in &chars[i..sep + 1] { + result.push(*c); + } + i = sep + 1; + skip_whitespace_and_token(&chars, &mut i, &mut result); + result.push_str("[REDACTED_TOKEN]"); + continue; } - i += 7; - skip_whitespace_and_token(&chars, &mut i, &mut result); - result.push_str(replacement); - continue; } if lower_matches_at(&lower, i, "token=") || lower_matches_at(&lower, i, "token:") { for c in &chars[i..i + 6] { @@ -173,13 +116,13 @@ fn redact_tokens(text: &str, replacement: &str) -> String { } i += 6; skip_whitespace_and_token(&chars, &mut i, &mut result); - result.push_str(replacement); + result.push_str("[REDACTED_TOKEN]"); continue; } result.push(chars[i]); i += 1; } - result + result.into() } fn skip_whitespace_and_token(chars: &[char], i: &mut usize, result: &mut String) { @@ -206,22 +149,24 @@ fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { true } -fn redact_emails(text: &str, replacement: &str) -> String { - let mut result = String::with_capacity(text.len()); - let chars: Vec<char> = text.chars().collect(); +fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { + if !input.contains('@') { + return input; + } + let chars: Vec<char> = input.chars().collect(); let len = chars.len(); + let mut result = String::with_capacity(input.len()); let mut i = 0; - while i < len { if let Some(email_end) = try_match_email(&chars, i) { - result.push_str(replacement); + result.push_str("[REDACTED_EMAIL]"); i = email_end; - continue; + } else { + result.push(chars[i]); + i += 1; } - result.push(chars[i]); - i += 1; } - result + result.into() } fn try_match_email(chars: &[char], start: usize) -> Option<usize> { @@ -231,10 +176,10 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { } let mut i = start; - if !chars[i].is_alphanumeric() { + if !chars[i].is_alphanumeric() && chars[i] != '_' { return None; } - while i < len && (chars[i].is_alphanumeric() || ".%+-".contains(chars[i])) { + while i < len && (chars[i].is_alphanumeric() || "_.%+-".contains(chars[i])) { i += 1; } if i >= len || chars[i] != '@' { @@ -262,12 +207,14 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { } } -fn redact_hex(text: &str, replacement: &str) -> String { - let mut result = String::with_capacity(text.len()); - let chars: Vec<char> = text.chars().collect(); +fn redact_hex_runs(input: Cow<'_, str>) -> Cow<'_, str> { + if !has_long_hex_run(&input) { + return input; + } + let chars: Vec<char> = input.chars().collect(); let len = chars.len(); + let mut result = String::with_capacity(input.len()); let mut i = 0; - while i < len { if chars[i].is_ascii_hexdigit() { let start = i; @@ -275,7 +222,7 @@ fn redact_hex(text: &str, replacement: &str) -> String { i += 1; } if i - start >= 16 { - result.push_str(replacement); + result.push_str("[REDACTED_HEX]"); } else { for c in &chars[start..i] { result.push(*c); @@ -286,7 +233,22 @@ fn redact_hex(text: &str, replacement: &str) -> String { i += 1; } } - result + result.into() +} + +fn has_long_hex_run(text: &str) -> bool { + let mut run = 0usize; + for c in text.chars() { + if c.is_ascii_hexdigit() { + run += 1; + if run >= 16 { + return true; + } + } else { + run = 0; + } + } + false } #[cfg(test)] @@ -295,14 +257,15 @@ mod tests { #[test] fn redact_tokens_handles_non_ascii_without_panic() { - let out = redact_tokens("x café bearer secret", ""); + let out = redact_tokens(Cow::Borrowed("x café bearer secret")); assert!(out.contains("café")); assert!(!out.contains("secret")); + assert!(out.contains("[REDACTED_TOKEN]")); } #[test] fn redact_tokens_preserves_bearer_redaction_on_ascii() { - let out = redact_tokens("auth failed: bearer abc123token", "[REDACTED_TOKEN]"); + let out = redact_tokens(Cow::Borrowed("auth failed: bearer abc123token")); assert!(!out.contains("abc123token")); assert!(out.contains("[REDACTED_TOKEN]")); assert!(out.contains("auth failed: bearer ")); @@ -310,7 +273,7 @@ mod tests { #[test] fn redact_tokens_redacts_token_equals_with_non_ascii_prefix() { - let out = redact_tokens("café token=leak", "[REDACTED_TOKEN]"); + let out = redact_tokens(Cow::Borrowed("café token=leak")); assert!(out.contains("café ")); assert!(out.contains("token=")); assert!(!out.contains("leak")); @@ -319,7 +282,7 @@ mod tests { #[test] fn redact_tokens_tolerates_tab_after_bearer() { - let out = redact_tokens("auth failed: bearer\tabc123", "[REDACTED_TOKEN]"); + let out = redact_tokens(Cow::Borrowed("auth failed: bearer\tabc123")); assert!(!out.contains("abc123")); assert!(out.contains("bearer\t")); assert!(out.contains("[REDACTED_TOKEN]")); @@ -327,12 +290,26 @@ mod tests { #[test] fn redact_tokens_tolerates_space_after_equals() { - let out = redact_tokens("token= abc123", "[REDACTED_TOKEN]"); + let out = redact_tokens(Cow::Borrowed("token= abc123")); assert!(out.contains("token= ")); assert!(!out.contains("abc123")); assert!(out.contains("[REDACTED_TOKEN]")); } + #[test] + fn sanitize_redacts_token_after_bearer_colon() { + let out = sanitize_message("auth failed: bearer: abc123secret"); + assert!(!out.contains("abc123secret")); + assert!(out.contains("[REDACTED_TOKEN]")); + } + + #[test] + fn sanitize_redacts_email_local_part_containing_underscore() { + let out = sanitize_message("login failed for alice_bob@example.com"); + assert!(!out.contains("alice")); + assert!(out.contains("[REDACTED_EMAIL]")); + } + #[test] fn redact_users_path_mixed_case() { let msg = "error in /Users/admin/.env leaked"; diff --git a/crates/gpui-query/src/core/error/serde.rs b/crates/gpui-query/src/core/error/serde.rs index 7cda492..a7a2308 100644 --- a/crates/gpui-query/src/core/error/serde.rs +++ b/crates/gpui-query/src/core/error/serde.rs @@ -1,5 +1,3 @@ -//! Serde serialization/deserialization for [`QueryError`](super::QueryError). - use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::types::{QueryError, QueryErrorKind}; diff --git a/crates/gpui-query/src/core/error/types.rs b/crates/gpui-query/src/core/error/types.rs index f4feabf..f2f8f22 100644 --- a/crates/gpui-query/src/core/error/types.rs +++ b/crates/gpui-query/src/core/error/types.rs @@ -1,5 +1,3 @@ -//! Core error types for query operations. - use std::sync::Arc; use serde::{Deserialize, Serialize}; diff --git a/crates/gpui-query/src/core/fetched.rs b/crates/gpui-query/src/core/fetched.rs index 276a5ed..201f76b 100644 --- a/crates/gpui-query/src/core/fetched.rs +++ b/crates/gpui-query/src/core/fetched.rs @@ -1,7 +1,6 @@ -//! Fetcher result wrapper for "server wins" cache policy. -//! -//! Returned by `*_with_policy` fetchers; a `Some` policy overrides the caller's -//! per-query policy. `meta` exists only under `persist` so core stays serde_json-free. +//! Fetcher result wrapper for "server wins" caching: a `Some` policy +//! overrides the caller's per-query one. `meta` exists only under `persist` +//! so core stays serde_json-free. use crate::core::policy::CachePolicy; #[cfg(feature = "persist")] diff --git a/crates/gpui-query/src/core/infinite_query/accessors.rs b/crates/gpui-query/src/core/infinite_query/accessors.rs index 55906b5..697386b 100644 --- a/crates/gpui-query/src/core/infinite_query/accessors.rs +++ b/crates/gpui-query/src/core/infinite_query/accessors.rs @@ -1,5 +1,3 @@ -//! Accessor (getter / setter) methods for [`InfiniteQueryResource`]. - use std::collections::VecDeque; use std::sync::Arc; diff --git a/crates/gpui-query/src/core/infinite_query/lifecycle.rs b/crates/gpui-query/src/core/infinite_query/lifecycle.rs index 13b71fa..388248f 100644 --- a/crates/gpui-query/src/core/infinite_query/lifecycle.rs +++ b/crates/gpui-query/src/core/infinite_query/lifecycle.rs @@ -1,10 +1,8 @@ -//! Lifecycle methods for [`InfiniteQueryResource`]: fetch, complete, reset, -//! invalidate, and two-phase protocol. - use std::sync::Arc; use crate::core::{ - QuerySignal, QueryStatus, QueryTimestamp, RequestGuard, RequestId, RequestSequencer, + QuerySignal, QueryStatus, QueryTimestamp, RequestGuard, RequestId, RequestPolicy, + RequestSequencer, request::MaybeRequestId, }; use super::FetchDirection; @@ -19,11 +17,6 @@ pub(super) enum PageDirection { Previous, } -enum MaybeRequestId<'a> { - FromSequencer(&'a mut RequestSequencer), - Provided(Option<RequestId>), -} - impl<T, E> InfiniteQueryResource<T, E> { /// Under `LatestWins` this replaces an in-flight request in either /// direction; `IgnoreWhileLoading` only guards within the same direction. @@ -84,7 +77,7 @@ impl<T, E> InfiniteQueryResource<T, E> { fn begin_fetch( &mut self, direction: PageDirection, - id_source: MaybeRequestId, + mut id_source: MaybeRequestId<'_>, now_ms: u64, ) -> Option<RequestId> { let (has_page, is_fetching_same_direction) = match direction { @@ -96,9 +89,7 @@ impl<T, E> InfiniteQueryResource<T, E> { return None; } - if is_fetching_same_direction - && self.request_policy == crate::core::RequestPolicy::IgnoreWhileLoading - { + if is_fetching_same_direction && self.request_policy == RequestPolicy::IgnoreWhileLoading { return None; } @@ -112,12 +103,7 @@ impl<T, E> InfiniteQueryResource<T, E> { self.fetching_direction = Some(direction); - let request_id = match id_source { - MaybeRequestId::FromSequencer(sequencer) => sequencer.next_request(), - MaybeRequestId::Provided(maybe_id) => { - maybe_id.unwrap_or_else(|| self.transient_sequencer.next_request()) - } - }; + let request_id = id_source.next(&mut self.transient_sequencer); self.active_request_id = Some(request_id); self.status = if self.pages.is_empty() { QueryStatus::LoadingEmpty @@ -188,45 +174,22 @@ impl<T, E> InfiniteQueryResource<T, E> { is_next: bool, now_ms: u64, ) -> bool { - if self.active_request_id != Some(request_id) { - self.ignored_results = self.ignored_results.saturating_add(1); - return false; - } - - if is_next { - self.pages.push_back(Arc::new(page)); - self.has_next_page = has_more; - self.enforce_max_pages_remove_front(); + if let Some(guard) = self.accept_current_request(request_id) { + self.complete_success_with_guard(guard, page, has_more, is_next, now_ms); + true } else { - self.pages.push_front(Arc::new(page)); - self.has_previous_page = has_more; - self.enforce_max_pages_remove_back(); + false } - - self.status = QueryStatus::Success; - self.error = None; - self.active_request_id = None; - self.last_updated_at = Some(QueryTimestamp::from(now_ms)); - self.fetching_direction = None; - self.signal = None; - - true } /// Accept-and-complete in one call; loaded pages are NOT cleared. pub fn complete_page_failure(&mut self, request_id: RequestId, error: E) -> bool { - if self.active_request_id != Some(request_id) { - self.ignored_results = self.ignored_results.saturating_add(1); - return false; + if let Some(guard) = self.accept_current_request(request_id) { + self.complete_failure_with_guard(guard, error); + true + } else { + false } - - self.status = QueryStatus::Failure; - self.error = Some(error); - self.active_request_id = None; - self.fetching_direction = None; - self.signal = None; - - true } pub fn is_current_request(&self, request_id: RequestId) -> bool { diff --git a/crates/gpui-query/src/core/infinite_query/mod.rs b/crates/gpui-query/src/core/infinite_query/mod.rs index 6ab6077..c368263 100644 --- a/crates/gpui-query/src/core/infinite_query/mod.rs +++ b/crates/gpui-query/src/core/infinite_query/mod.rs @@ -1,8 +1,6 @@ -//! Infinite query resource for managing paginated data. -//! -//! Pages live in a `VecDeque<Arc<T>>` (O(1) append/prepend); a bounded -//! `max_pages` (default 50) evicts from the opposite side, and -//! `set_max_pages(Some(0))` means unbounded. +//! Infinite query resource for paginated data. Pages live in a +//! `VecDeque<Arc<T>>` (O(1) append/prepend); `max_pages` (default 50) evicts +//! from the opposite side, and `set_max_pages(Some(0))` means unbounded. mod accessors; mod lifecycle; diff --git a/crates/gpui-query/src/core/infinite_query/page_management.rs b/crates/gpui-query/src/core/infinite_query/page_management.rs index 5b43f68..0744f41 100644 --- a/crates/gpui-query/src/core/infinite_query/page_management.rs +++ b/crates/gpui-query/src/core/infinite_query/page_management.rs @@ -1,5 +1,3 @@ -//! Page management methods for [`InfiniteQueryResource`]. - use std::sync::Arc; use super::{FetchDirection, InfiniteQueryResource}; diff --git a/crates/gpui-query/src/core/infinite_query/resource.rs b/crates/gpui-query/src/core/infinite_query/resource.rs index 402e192..eb8de1b 100644 --- a/crates/gpui-query/src/core/infinite_query/resource.rs +++ b/crates/gpui-query/src/core/infinite_query/resource.rs @@ -1,6 +1,3 @@ -//! Struct definition, serde helpers, and constructors for -//! [`InfiniteQueryResource`]. - use std::collections::VecDeque; use std::sync::Arc; diff --git a/crates/gpui-query/src/core/mod.rs b/crates/gpui-query/src/core/mod.rs index 52dac69..8860d10 100644 --- a/crates/gpui-query/src/core/mod.rs +++ b/crates/gpui-query/src/core/mod.rs @@ -1,7 +1,6 @@ //! Layer 0: transport-agnostic query lifecycle primitives, serde-only. -//! -//! Fetch protocol: `begin_request` → `accept_current_request` (returns a -//! single-use `RequestGuard`) → `complete_success`/`complete_failure`. +//! Fetch protocol: `begin_request` → `accept_current_request` (single-use +//! `RequestGuard`) → `complete_success`/`complete_failure`. mod error; mod fetched; @@ -37,10 +36,9 @@ pub use status::QueryStatus; #[cfg(feature = "client")] mod current_task { - //! `gpui::Task<T>` is Debug but not Clone/PartialEq/Eq, and several - //! resource structs derive those. This newtype restores the derives: - //! Clone yields an empty handle, all instances compare equal, and Drop - //! aborts the task (gpui semantics), so `set` replaces and aborts. + //! `gpui::Task<T>` is Debug but not Clone/PartialEq/Eq, and several resource + //! structs derive those: Clone yields an empty handle, all instances compare + //! equal, and Drop aborts the task (gpui semantics), so `set` replaces. use gpui::Task; #[derive(Debug, Default)] diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 51d4734..5f8f98c 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -1,5 +1,3 @@ -//! Mutation resource for tracking async write operations. - use serde::{Deserialize, Serialize}; use super::{QueryError, QueryKey, QuerySignal, RetryPolicy}; diff --git a/crates/gpui-query/src/core/network_mode.rs b/crates/gpui-query/src/core/network_mode.rs index 40f6d96..ddbd223 100644 --- a/crates/gpui-query/src/core/network_mode.rs +++ b/crates/gpui-query/src/core/network_mode.rs @@ -1,5 +1,3 @@ -//! Network mode configuration. - use serde::{Deserialize, Serialize}; /// Forward compatibility only; network detection is not implemented yet. diff --git a/crates/gpui-query/src/core/policy.rs b/crates/gpui-query/src/core/policy.rs index 66ece85..8a1af33 100644 --- a/crates/gpui-query/src/core/policy.rs +++ b/crates/gpui-query/src/core/policy.rs @@ -79,7 +79,7 @@ impl CachePolicy { } pub fn is_fresh(self, age_ms: u64) -> bool { - self.ttl_ms().map(|ttl| age_ms <= ttl).unwrap_or(false) + self.ttl_ms().is_some_and(|ttl| age_ms <= ttl) } pub fn is_stale_but_serveable(self, age_ms: u64) -> bool { diff --git a/crates/gpui-query/src/core/refetch.rs b/crates/gpui-query/src/core/refetch.rs index 96d23d4..2d082f1 100644 --- a/crates/gpui-query/src/core/refetch.rs +++ b/crates/gpui-query/src/core/refetch.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; -/// Parsed and stored, but focus/reconnect event integration is not implemented yet. +/// Inert config: focus/reconnect refetching is not implemented yet, so this +/// is stored but never acted on. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RefetchTrigger { #[default] diff --git a/crates/gpui-query/src/core/request.rs b/crates/gpui-query/src/core/request.rs index 8df1c26..086c54c 100644 --- a/crates/gpui-query/src/core/request.rs +++ b/crates/gpui-query/src/core/request.rs @@ -1,8 +1,3 @@ -//! Request lifecycle primitives: ids, sequencer, guard, timestamps. -//! -//! Two-phase completion: `accept_current_request` returns a single-use -//! [`RequestGuard`] that a `complete_*` method consumes by value. - use serde::{Deserialize, Serialize}; use std::num::NonZero; @@ -70,10 +65,26 @@ impl Default for RequestSequencer { } } +/// Id source for begin-request entry points: an external sequencer, or a +/// caller-provided id falling back to the resource's own sequencer. +pub(crate) enum MaybeRequestId<'a> { + FromSequencer(&'a mut RequestSequencer), + Provided(Option<RequestId>), +} + +impl MaybeRequestId<'_> { + pub(crate) fn next(&mut self, fallback: &mut RequestSequencer) -> RequestId { + match self { + Self::FromSequencer(sequencer) => sequencer.next_request(), + Self::Provided(maybe_id) => maybe_id.unwrap_or_else(|| fallback.next_request()), + } + } +} + impl RequestSequencer { pub fn new() -> Self { Self { - scope_id: NonZero::new(1).unwrap(), + scope_id: NonZero::<u64>::MIN, next_request_id: 1, } } diff --git a/crates/gpui-query/src/core/resource/accessors.rs b/crates/gpui-query/src/core/resource/accessors.rs index a695aae..d06d6d8 100644 --- a/crates/gpui-query/src/core/resource/accessors.rs +++ b/crates/gpui-query/src/core/resource/accessors.rs @@ -1,5 +1,3 @@ -//! Query resource read-only accessors. - use crate::core::{ CachePolicy, QueryKey, QuerySignal, QueryStatus, QueryTimestamp, RequestId, RequestPolicy, RetryPolicy, diff --git a/crates/gpui-query/src/core/resource/cache.rs b/crates/gpui-query/src/core/resource/cache.rs index 2d2d9c2..297f391 100644 --- a/crates/gpui-query/src/core/resource/cache.rs +++ b/crates/gpui-query/src/core/resource/cache.rs @@ -1,5 +1,3 @@ -//! Query resource cache logic. - use crate::core::{QueryStatus, QueryTimestamp}; use super::QueryResource; @@ -14,28 +12,22 @@ impl<T, E> QueryResource<T, E> { pub fn is_cache_fresh(&self, now_ms: u64) -> bool { self.has_data() && self - .cache_policy - .ttl_ms() - .zip(self.cache_age_ms(now_ms)) - .map(|(ttl_ms, age_ms)| age_ms <= ttl_ms) - .unwrap_or(false) + .cache_age_ms(now_ms) + .is_some_and(|age_ms| self.cache_policy.is_fresh(age_ms)) } pub fn is_stale_but_serveable(&self, now_ms: u64) -> bool { self.has_data() && self .cache_age_ms(now_ms) - .map(|age_ms| self.cache_policy.is_stale_but_serveable(age_ms)) - .unwrap_or(false) + .is_some_and(|age_ms| self.cache_policy.is_stale_but_serveable(age_ms)) } pub fn is_cache_expired(&self, now_ms: u64) -> bool { - if !self.has_data() { - return true; - } - self.cache_age_ms(now_ms) - .map(|age_ms| self.cache_policy.is_expired(age_ms)) - .unwrap_or(true) + !self.has_data() + || self + .cache_age_ms(now_ms) + .is_none_or(|age_ms| self.cache_policy.is_expired(age_ms)) } pub fn should_short_circuit_cache(&self, now_ms: u64) -> bool { @@ -58,10 +50,6 @@ impl<T, E> QueryResource<T, E> { } } - pub(crate) fn record_stale_cache_hit(&mut self) { - self.record_cache_hit(); - } - /// Data is retained; only the last-updated timestamp is cleared. pub fn invalidate(&mut self) { self.last_updated_at = None; diff --git a/crates/gpui-query/src/core/resource/completion.rs b/crates/gpui-query/src/core/resource/completion.rs index 4326e87..f463bfd 100644 --- a/crates/gpui-query/src/core/resource/completion.rs +++ b/crates/gpui-query/src/core/resource/completion.rs @@ -1,5 +1,3 @@ -//! Query resource completion methods. - use crate::core::{CachePolicy, QueryStatus, QueryTimestamp, RequestGuard, RequestId}; use super::QueryResource; diff --git a/crates/gpui-query/src/core/resource/lifecycle.rs b/crates/gpui-query/src/core/resource/lifecycle.rs index 22765cc..a1a403b 100644 --- a/crates/gpui-query/src/core/resource/lifecycle.rs +++ b/crates/gpui-query/src/core/resource/lifecycle.rs @@ -1,17 +1,10 @@ -//! Query resource lifecycle: begin, cancel, reset, optimistic updates. - use crate::core::{ QueryBeginResult, QueryFetchMode, QuerySignal, QueryStatus, QueryTimestamp, RequestGuard, - RequestId, RequestPolicy, RequestSequencer, + RequestId, RequestPolicy, RequestSequencer, request::MaybeRequestId, }; use super::QueryResource; -enum MaybeRequestId<'a> { - FromSequencer(&'a mut RequestSequencer), - Provided(Option<RequestId>), -} - impl<T, E> QueryResource<T, E> { /// May short-circuit to `CacheHit` per the cache policy; replacing an /// in-flight request cancels its signal so the old fetcher can abort early. @@ -44,19 +37,9 @@ impl<T, E> QueryResource<T, E> { &mut self, now_ms: u64, fetch_mode: QueryFetchMode, - mut id_source: MaybeRequestId, + mut id_source: MaybeRequestId<'_>, ) -> QueryBeginResult { - // Lazy: early-return guards must not consume a sequence number. - macro_rules! next_id { - () => {{ - match &mut id_source { - MaybeRequestId::FromSequencer(seq) => seq.next_request(), - MaybeRequestId::Provided(maybe_id) => { - maybe_id.unwrap_or_else(|| self.transient_sequencer.next_request()) - } - } - }}; - } + // Early-return guards must not consume a sequence number. if fetch_mode == QueryFetchMode::Normal && self.should_short_circuit_cache(now_ms) { self.record_cache_hit(); @@ -65,7 +48,7 @@ impl<T, E> QueryResource<T, E> { // Checked before the IgnoreWhileLoading guard: stale data is always revalidated. if fetch_mode == QueryFetchMode::Normal && self.should_serve_stale_and_revalidate(now_ms) { - self.record_stale_cache_hit(); + self.record_cache_hit(); if self.request_policy == RequestPolicy::IgnoreWhileLoading && let Some(active_request_id) = self.active_request_id @@ -82,7 +65,7 @@ impl<T, E> QueryResource<T, E> { self.cancelled_count = self.cancelled_count.saturating_add(1); } - let request_id = next_id!(); + let request_id = id_source.next(&mut self.transient_sequencer); let status = self.begin_loading(request_id, now_ms); return QueryBeginResult::StaleCacheHit { request_id, @@ -102,7 +85,7 @@ impl<T, E> QueryResource<T, E> { self.cancelled_count = self.cancelled_count.saturating_add(1); } - let request_id = next_id!(); + let request_id = id_source.next(&mut self.transient_sequencer); let status = self.begin_loading(request_id, now_ms); QueryBeginResult::Started { request_id, diff --git a/crates/gpui-query/src/core/retry.rs b/crates/gpui-query/src/core/retry.rs index 7e1ca82..7559df6 100644 --- a/crates/gpui-query/src/core/retry.rs +++ b/crates/gpui-query/src/core/retry.rs @@ -1,5 +1,3 @@ -//! Retry configuration for failed query and mutation requests. - use serde::{Deserialize, Serialize}; /// Defaults: 3 retries, exponential backoff, 1s base delay, 30s cap. diff --git a/crates/gpui-query/src/core/select.rs b/crates/gpui-query/src/core/select.rs index 9dd3bcf..4f33f16 100644 --- a/crates/gpui-query/src/core/select.rs +++ b/crates/gpui-query/src/core/select.rs @@ -1,55 +1,5 @@ //! Select/transform support: [`SelectTransform`] projects cached `T` into a -//! derived `U` via [`MappedQueryResource`], without duplicating the cache entry. -//! -//! The `use_query_select` hook keeps a `MappedQueryResource` in sync with its -//! source `QueryResource` via an observer; the transform runs on access. -//! -//! # Example -//! -//! ``` -//! use gpui_query::core::{SelectTransform, MappedQueryResource}; -//! -//! // Raw query data: a list of users. -//! let users = vec!["Alice", "Bob", "Carol"]; -//! -//! // Transform: extract just the count. -//! let transform = SelectTransform::new(|users: &Vec<&str>| users.len()); -//! -//! let mapped = MappedQueryResource::<_, usize, ()>::new(Some(std::sync::Arc::new(users)), transform); -//! assert_eq!(mapped.data(), Some(3)); -//! ``` -//! -//! ## Example with the hook -//! -//! ```ignore -//! use gpui_query::hook::{use_query_select, QueryOptions}; -//! use gpui_query::core::SelectTransform; -//! # #[derive(Clone, PartialEq)] -//! # struct User; -//! # #[derive(Clone, Debug)] -//! # struct MyError; -//! -//! struct UserCountView { -//! mapped: gpui::Entity<gpui_query::core::MappedQueryResource<Vec<User>, usize, MyError>>, -//! _subs: (gpui::Subscription, gpui::Subscription), -//! } -//! -//! impl UserCountView { -//! fn new(cx: &mut gpui::Context<Self>) -> Self { -//! let count_transform = SelectTransform::new(|users: &Vec<User>| users.len()); -//! let (mapped, _, _subs) = use_query_select( -//! QueryOptions::new("users"), -//! count_transform, -//! |signal| async move { -//! // Your async fetcher here -//! Ok(vec![]) -//! }, -//! cx, -//! ); -//! Self { mapped, _subs } -//! } -//! } -//! ``` +//! derived `U` via [`MappedQueryResource`] without duplicating the cache entry. use std::sync::Arc; diff --git a/crates/gpui-query/src/core/signal.rs b/crates/gpui-query/src/core/signal.rs index 4f68567..deb5776 100644 --- a/crates/gpui-query/src/core/signal.rs +++ b/crates/gpui-query/src/core/signal.rs @@ -1,5 +1,3 @@ -//! Cooperative cancellation signal for in-flight query requests. - use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/gpui-query/src/core/status.rs b/crates/gpui-query/src/core/status.rs index aeafb51..ec7cce6 100644 --- a/crates/gpui-query/src/core/status.rs +++ b/crates/gpui-query/src/core/status.rs @@ -1,5 +1,3 @@ -//! Query status enum representing the lifecycle states of a query resource. - use serde::{Deserialize, Serialize}; /// `Idle` → `LoadingEmpty` → `Success`/`Failure`; refetch: `Success` → `LoadingWithData` → terminal. diff --git a/crates/gpui-query/src/lib.rs b/crates/gpui-query/src/lib.rs index 3bb2485..cf70279 100644 --- a/crates/gpui-query/src/lib.rs +++ b/crates/gpui-query/src/lib.rs @@ -1,9 +1,6 @@ //! gpui-query: async state management for GPUI, inspired by TanStack Query. -//! //! Layers, strictly additive: `core` (serde-only state machine), `client` //! (GPUI registry), `hook` (`use_query` & friends), `persist` (disk cache). -//! -//! Quick start: `use gpui_query::{use_query, use_mutation, use_infinite_query, QueryClient};` #![cfg_attr(docsrs, feature(doc_cfg))] From d0714276aa367e4b5af4f7f2cac5731845605a45 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 00:05:21 +0200 Subject: [PATCH 044/111] fix: redact whitespace-variant bearer and token separators --- crates/gpui-query/src/core/error/sanitize.rs | 77 ++++++++++--------- crates/gpui-query/src/tests/core_error/mod.rs | 57 ++++++++++++++ crates/gpui-query/src/tests/mod.rs | 1 + 3 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 crates/gpui-query/src/tests/core_error/mod.rs diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 5652ee4..9ce6067 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -98,42 +98,59 @@ fn redact_tokens(input: Cow<'_, str>) -> Cow<'_, str> { let mut result = String::with_capacity(input.len()); let mut i = 0; while i < len { - if lower_matches_at(&lower, i, "bearer") && i + 6 < len { - let sep = i + 6; - if chars[sep].is_ascii_whitespace() || chars[sep] == ':' || chars[sep] == '=' { - for c in &chars[i..sep + 1] { + match try_match_token(&chars, &lower, i) { + Some((verbatim_end, resume)) => { + for c in &chars[i..verbatim_end] { result.push(*c); } - i = sep + 1; - skip_whitespace_and_token(&chars, &mut i, &mut result); result.push_str("[REDACTED_TOKEN]"); - continue; + i = resume; } - } - if lower_matches_at(&lower, i, "token=") || lower_matches_at(&lower, i, "token:") { - for c in &chars[i..i + 6] { - result.push(*c); + None => { + result.push(chars[i]); + i += 1; } - i += 6; - skip_whitespace_and_token(&chars, &mut i, &mut result); - result.push_str("[REDACTED_TOKEN]"); - continue; } - result.push(chars[i]); - i += 1; } result.into() } -fn skip_whitespace_and_token(chars: &[char], i: &mut usize, result: &mut String) { +/// `keyword [ws*] [sep] [ws*] token` with sep `:` or `=`; `bearer` accepts +/// whitespace alone, `token` requires the separator. Returns the end of the +/// verbatim prefix (keyword through separators/whitespace) and the resume +/// index past the redacted token. +fn try_match_token(chars: &[char], lower: &[char], i: usize) -> Option<(usize, usize)> { + let (keyword_len, sep_required) = if lower_matches_at(lower, i, "bearer") { + (6, false) + } else if lower_matches_at(lower, i, "token") { + (5, true) + } else { + return None; + }; let len = chars.len(); - while *i < len && chars[*i].is_ascii_whitespace() { - result.push(chars[*i]); - *i += 1; + let mut j = i + keyword_len; + + let mut saw_ws = false; + while j < len && chars[j].is_ascii_whitespace() { + saw_ws = true; + j += 1; } - while *i < len && !chars[*i].is_ascii_whitespace() { - *i += 1; + let saw_sep = j < len && (chars[j] == ':' || chars[j] == '='); + if saw_sep { + j += 1; } + if !saw_sep && (sep_required || !saw_ws) { + return None; + } + + while j < len && chars[j].is_ascii_whitespace() { + j += 1; + } + let verbatim_end = j; + while j < len && !chars[j].is_ascii_whitespace() { + j += 1; + } + Some((verbatim_end, j)) } fn lower_matches_at(lower: &[char], i: usize, pat: &str) -> bool { @@ -296,20 +313,6 @@ mod tests { assert!(out.contains("[REDACTED_TOKEN]")); } - #[test] - fn sanitize_redacts_token_after_bearer_colon() { - let out = sanitize_message("auth failed: bearer: abc123secret"); - assert!(!out.contains("abc123secret")); - assert!(out.contains("[REDACTED_TOKEN]")); - } - - #[test] - fn sanitize_redacts_email_local_part_containing_underscore() { - let out = sanitize_message("login failed for alice_bob@example.com"); - assert!(!out.contains("alice")); - assert!(out.contains("[REDACTED_EMAIL]")); - } - #[test] fn redact_users_path_mixed_case() { let msg = "error in /Users/admin/.env leaked"; diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs new file mode 100644 index 0000000..df9ee58 --- /dev/null +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -0,0 +1,57 @@ +use crate::core::QueryError; + +#[test] +fn sanitized_redacts_bearer_equals_with_surrounding_whitespace() { + let clean = QueryError::response("auth failed: bearer = s3cr3tval").sanitized(); + assert!(!clean.message().contains("s3cr3tval")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_bearer_colon_mixed_case_with_whitespace() { + let clean = QueryError::response("BEARER : sekret9").sanitized(); + assert!(!clean.message().contains("sekret9")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_bearer_tab_equals_tab_value() { + let clean = QueryError::response("bearer\t=\tval42").sanitized(); + assert!(!clean.message().contains("val42")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_token_equals_with_whitespace() { + let clean = QueryError::response("request rejected: token = leak123").sanitized(); + assert!(!clean.message().contains("leak123")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_token_colon_with_whitespace() { + let clean = QueryError::response("Token : val456").sanitized(); + assert!(!clean.message().contains("val456")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_bearer_token_after_multiple_spaces() { + let clean = QueryError::response("Bearer tokensecret").sanitized(); + assert!(!clean.message().contains("tokensecret")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_token_after_bearer_colon() { + let clean = QueryError::response("auth failed: bearer: abc123secret").sanitized(); + assert!(!clean.message().contains("abc123secret")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_email_local_part_containing_underscore() { + let clean = QueryError::response("login failed for alice_bob@example.com").sanitized(); + assert!(!clean.message().contains("alice")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} diff --git a/crates/gpui-query/src/tests/mod.rs b/crates/gpui-query/src/tests/mod.rs index 53ac744..39ccd3b 100644 --- a/crates/gpui-query/src/tests/mod.rs +++ b/crates/gpui-query/src/tests/mod.rs @@ -1,4 +1,5 @@ mod core_cache; +mod core_error; mod core_infinite_query; mod core_lifecycle; mod core_mutation; From 15ba5ee50429108ebb455c64f8550f0e963ea415 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 00:19:02 +0200 Subject: [PATCH 045/111] fix: redact full separator runs after auth keywords try_match_token consumes the whole [ws|:|=]* run after bearer/token instead of ws* + one separator + ws*, so doubled or mixed runs like 'bearer == s3cr3t' no longer leak the token behind the redaction marker. Compress the helper doc to one line; fail-on-old tests cover the doubled/mixed separator shapes via QueryError::sanitized(). --- crates/gpui-query/src/core/error/sanitize.rs | 25 ++++++------- crates/gpui-query/src/tests/core_error/mod.rs | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 9ce6067..b6f3646 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -115,10 +115,7 @@ fn redact_tokens(input: Cow<'_, str>) -> Cow<'_, str> { result.into() } -/// `keyword [ws*] [sep] [ws*] token` with sep `:` or `=`; `bearer` accepts -/// whitespace alone, `token` requires the separator. Returns the end of the -/// verbatim prefix (keyword through separators/whitespace) and the resume -/// index past the redacted token. +/// Grammar `keyword [ws|:|=]* token`; `token` requires at least one `:`/`=` in the run while `bearer` accepts any, and the tuple is (verbatim prefix end, resume index past the redacted token). fn try_match_token(chars: &[char], lower: &[char], i: usize) -> Option<(usize, usize)> { let (keyword_len, sep_required) = if lower_matches_at(lower, i, "bearer") { (6, false) @@ -129,23 +126,23 @@ fn try_match_token(chars: &[char], lower: &[char], i: usize) -> Option<(usize, u }; let len = chars.len(); let mut j = i + keyword_len; - let mut saw_ws = false; - while j < len && chars[j].is_ascii_whitespace() { - saw_ws = true; - j += 1; - } - let saw_sep = j < len && (chars[j] == ':' || chars[j] == '='); - if saw_sep { + let mut saw_sep = false; + while j < len { + let c = chars[j]; + if c.is_ascii_whitespace() { + saw_ws = true; + } else if c == ':' || c == '=' { + saw_sep = true; + } else { + break; + } j += 1; } if !saw_sep && (sep_required || !saw_ws) { return None; } - while j < len && chars[j].is_ascii_whitespace() { - j += 1; - } let verbatim_end = j; while j < len && !chars[j].is_ascii_whitespace() { j += 1; diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index df9ee58..65daaf5 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -1,5 +1,40 @@ use crate::core::QueryError; +#[test] +fn sanitized_redacts_bearer_doubled_separator_run() { + let clean = QueryError::response("auth failed: bearer == s3cr3t").sanitized(); + assert!(!clean.message().contains("s3cr3t")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_token_doubled_separator_run() { + let clean = QueryError::response("token == x9y8z7").sanitized(); + assert!(!clean.message().contains("x9y8z7")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_bearer_colon_equals_separator_run() { + let clean = QueryError::response("bearer := s3cr3t9").sanitized(); + assert!(!clean.message().contains("s3cr3t9")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_token_equals_colon_separator_run() { + let clean = QueryError::response("token =: leak7").sanitized(); + assert!(!clean.message().contains("leak7")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + +#[test] +fn sanitized_redacts_separator_run_mixed_with_whitespace() { + let clean = QueryError::response("bearer = = val99").sanitized(); + assert!(!clean.message().contains("val99")); + assert!(clean.message().contains("[REDACTED_TOKEN]")); +} + #[test] fn sanitized_redacts_bearer_equals_with_surrounding_whitespace() { let clean = QueryError::response("auth failed: bearer = s3cr3tval").sanitized(); From 869c853ec6952e875ebe475da53ae520810e71e6 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 00:48:01 +0200 Subject: [PATCH 046/111] refactor: dedupe client bucket ops and trim erased plumbing Drop ErasedInfiniteBucket (method-identical to ErasedBucket), hoist the per-bucket-kind bulk-op closures into shared ResourceBucket methods, make bucket_or_recreate generic over both query maps, and check persist filter/max-age before serializing each entry. --- .../src/client/bucket/erased_ops.rs | 67 +------- crates/gpui-query/src/client/bucket/mod.rs | 7 +- crates/gpui-query/src/client/bucket/shared.rs | 153 +++++++++++++++++- crates/gpui-query/src/client/erased.rs | 39 ++--- .../gpui-query/src/client/infinite_bucket.rs | 73 ++------- .../src/client/infinite_mutation_ops.rs | 67 ++------ crates/gpui-query/src/client/mod.rs | 42 ++--- .../gpui-query/src/client/mutation_bucket.rs | 7 +- crates/gpui-query/src/client/persist.rs | 23 +-- .../gpui-query/src/client/prepared_fetch.rs | 25 +-- 10 files changed, 240 insertions(+), 263 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/erased_ops.rs b/crates/gpui-query/src/client/bucket/erased_ops.rs index 36e156d..bcdf566 100644 --- a/crates/gpui-query/src/client/bucket/erased_ops.rs +++ b/crates/gpui-query/src/client/bucket/erased_ops.rs @@ -26,40 +26,19 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB } fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.inner.for_each_matching_entry(filter, cx, |entity, cx| { - // invalidate() only clears last_updated_at; skip the no-op update, which still notifies observers. - let needs_invalidate = - entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); - if needs_invalidate { - entity.update(cx, |resource, _| resource.invalidate()); - } - }); + self.inner.invalidate_matching(filter, cx); } fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.inner.for_each_matching_entry(filter, cx, |entity, cx| { - entity.update(cx, |resource, _| resource.reset()); - }); + self.inner.reset_matching(filter, cx); } fn remove_matching(&mut self, filter: &QueryKeyFilter) { - self.inner.entries.retain(|k, _| !filter.matches(k)); + self.inner.remove_matching(filter); } - /// `entity.update` notifies observers even when the closure mutates - /// nothing, so gate on the authoritative `is_loading()` read (the entry - /// mirror could be stale and skip an in-flight cancel). fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.inner.for_each_matching_entry(filter, cx, |entity, cx| { - if entity.read_with(cx, |r, _| r.is_loading()) { - entity.update(cx, |resource, _| { - if let Some(signal) = resource.signal() { - signal.cancel(); - } - resource.mark_ignored_result(); - }); - } - }); + self.inner.cancel_matching(filter, cx); } fn collect_diagnostics_into(&self, now_ms: u64, cx: &App, out: &mut Vec<QueryDiagnostic>) { @@ -76,50 +55,16 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB self.inner.entries.contains_key(key) } - /// Only `Success` entries whose `T` has a registered serializer are - /// pushed; everything else is skipped. #[cfg(feature = "persist")] fn collect_persistable_into( &self, cx: &App, - serializers: &crate::client::persist::SerializerRegistry, - now_ms: u64, + collect: &crate::client::bucket::shared::PersistCollect<'_>, out: &mut Vec<( crate::core::QueryKey, crate::client::persist::PersistedEntry, )>, ) { - use crate::core::QueryStatus; - - // Serializers are registered by `T` alone, not the `(T, E)` pair. - let type_id = std::any::TypeId::of::<T>(); - let Some(serialize_fn) = serializers.get(type_id) else { - return; - }; - for (key, entry) in self.inner.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - if resource.status() != QueryStatus::Success { - continue; - } - let Some(data) = resource.data() else { - continue; - }; - // Downcast failure is unreachable by construction; skip rather than persist junk. - let Some(value) = serialize_fn(data as &dyn std::any::Any) else { - continue; - }; - out.push(( - key.clone(), - crate::client::persist::PersistedEntry { - value, - cached_at: resource.last_updated_at_ms().unwrap_or(now_ms), - cache_policy: resource.cache_policy(), - meta: None, - }, - )); - } + self.inner.collect_persistable_into(cx, collect, out, |r| r.data()); } } diff --git a/crates/gpui-query/src/client/bucket/mod.rs b/crates/gpui-query/src/client/bucket/mod.rs index 92bebf9..b73fe74 100644 --- a/crates/gpui-query/src/client/bucket/mod.rs +++ b/crates/gpui-query/src/client/bucket/mod.rs @@ -1,7 +1,6 @@ -//! `ResourceBucket` in `shared` holds the machinery shared by -//! [`QueryBucket`] and [`InfiniteQueryBucket`](crate::client::InfiniteQueryBucket): -//! weak-entity entries with co-located sequencers, capacity-bounded eviction, -//! GC, bulk key-filter operations, and diagnostics. +//! `shared` holds the machinery common to [`QueryBucket`] and +//! [`InfiniteQueryBucket`](crate::client::InfiniteQueryBucket): weak-entity +//! entries, sequencers, eviction, GC, and bulk key-filter operations. mod erased_ops; mod ops; diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 7f6e8ac..c343cdb 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -1,12 +1,13 @@ //! `ResourceBucket<R>` holds everything `QueryBucket` and //! `InfiniteQueryBucket` do identically (get-or-create, eviction, GC, bulk -//! matching, diagnostics); the public bucket types only add erased-trait -//! impls and persistence specifics. +//! matching, diagnostics, persistence collection). use ahash::AHashMap; use gpui::{App, AppContext as _, Entity}; use crate::client::devtools::QueryDiagnostic; +#[cfg(feature = "persist")] +use crate::client::persist::{PersistFilter, PersistedEntry, SerializerRegistry}; use crate::core::{ CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, QueryResource, QueryStatus, RequestId, RequestPolicy, @@ -33,6 +34,9 @@ pub(crate) trait BucketResource { fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64>; fn resource_cache_hits(&self) -> u64; fn resource_retry_count(&self) -> u32; + fn resource_invalidate(&mut self); + fn resource_reset(&mut self); + fn resource_cancel_inflight(&mut self); } impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { @@ -73,6 +77,18 @@ impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + fn resource_invalidate(&mut self) { + self.invalidate(); + } + fn resource_reset(&mut self) { + self.reset(); + } + fn resource_cancel_inflight(&mut self) { + if let Some(signal) = self.signal() { + signal.cancel(); + } + self.mark_ignored_result(); + } } impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { @@ -113,6 +129,18 @@ impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + fn resource_invalidate(&mut self) { + self.invalidate(); + } + fn resource_reset(&mut self) { + self.reset(); + } + fn resource_cancel_inflight(&mut self) { + if let Some(signal) = self.signal() { + signal.cancel(); + } + self.mark_ignored_result(); + } } pub(crate) struct ResourceBucket<R> { @@ -285,6 +313,38 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } + /// `entity.update` notifies observers even when the closure mutates + /// nothing, so bulk ops gate on authoritative reads. + pub(crate) fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { + self.for_each_matching_entry(filter, cx, |entity, cx| { + // invalidate() only clears last_updated_at; skip the no-op update. + let needs_invalidate = entity.read_with(cx, |r, _| r.resource_last_updated().is_some()); + if needs_invalidate { + entity.update(cx, |resource, _| resource.resource_invalidate()); + } + }); + } + + pub(crate) fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { + self.for_each_matching_entry(filter, cx, |entity, cx| { + entity.update(cx, |resource, _| resource.resource_reset()); + }); + } + + /// Gates on the authoritative `is_loading()` read: the entry mirror + /// could be stale and skip an in-flight cancel. + pub(crate) fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { + self.for_each_matching_entry(filter, cx, |entity, cx| { + if entity.read_with(cx, |r, _| r.resource_is_loading()) { + entity.update(cx, |resource, _| resource.resource_cancel_inflight()); + } + }); + } + + pub(crate) fn remove_matching(&mut self, filter: &QueryKeyFilter) { + self.entries.retain(|k, _| !filter.matches(k)); + } + /// Loading always survives; `Success` survives while its cache policy /// can still serve it and until `SUCCESS_GC_MULTIPLIER * gc_time_ms`; /// `Idle`/`Failure`/`Cancelled` survive `gc_time_ms`. Entries without a @@ -299,17 +359,16 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { }; let resource = entity.read(cx); - entry.last_updated_ms = resource.resource_last_updated(); + let last_updated = resource.resource_last_updated(); + entry.last_updated_ms = last_updated; entry.loading = resource.resource_is_loading(); - if resource.resource_is_loading() { + if entry.loading { return true; } let status = resource.resource_status(); - - let age_ms = resource - .resource_last_updated() + let age_ms = last_updated .map(|updated| now_ms.saturating_sub(updated)) .unwrap_or(gc_threshold); @@ -366,4 +425,84 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { out.push((key.to_path(), resource.resource_status())); } } + + /// Filter and max-age run before the serializer so skipped entries cost + /// nothing. Only `Success` entries are pushed. + #[cfg(feature = "persist")] + pub(crate) fn collect_persistable_into<S>( + &self, + cx: &App, + collect: &PersistCollect<'_>, + out: &mut Vec<(QueryKey, PersistedEntry)>, + value_of: impl Fn(&R) -> Option<&S>, + ) where + S: 'static, + { + use crate::core::QueryStatus; + + // Serializers are registered by `T` alone, not the `(T, E)` pair. + let Some(serialize_fn) = collect.serializers.get(std::any::TypeId::of::<S>()) else { + return; + }; + for (key, entry) in self.entries.iter() { + if !collect.filter.matches(key) { + continue; + } + let Some(entity) = entry.entity.upgrade() else { + continue; + }; + let resource = entity.read(cx); + if resource.resource_status() != QueryStatus::Success { + continue; + } + let cached_at = resource.resource_last_updated().unwrap_or(collect.now_ms); + if collect.max_age_ms > 0 + && collect.now_ms.saturating_sub(cached_at) > collect.max_age_ms + { + continue; + } + let Some(value_ref) = value_of(resource) else { + continue; + }; + // Downcast failure is unreachable by construction; skip rather than persist junk. + let Some(value) = serialize_fn(value_ref as &dyn std::any::Any) else { + continue; + }; + out.push(( + key.clone(), + PersistedEntry { + value, + cached_at, + cache_policy: resource.resource_cache_policy(), + meta: None, + }, + )); + } + } +} + +/// Per-sweep inputs for [`ResourceBucket::collect_persistable_into`]. +#[cfg(feature = "persist")] +pub(crate) struct PersistCollect<'a> { + serializers: &'a SerializerRegistry, + filter: &'a PersistFilter, + now_ms: u64, + max_age_ms: u64, +} + +#[cfg(feature = "persist")] +impl<'a> PersistCollect<'a> { + pub(crate) fn new( + serializers: &'a SerializerRegistry, + filter: &'a PersistFilter, + now_ms: u64, + max_age_ms: u64, + ) -> Self { + Self { + serializers, + filter, + now_ms, + max_age_ms, + } + } } diff --git a/crates/gpui-query/src/client/erased.rs b/crates/gpui-query/src/client/erased.rs index 9c5304f..2944799 100644 --- a/crates/gpui-query/src/client/erased.rs +++ b/crates/gpui-query/src/client/erased.rs @@ -3,11 +3,13 @@ use crate::client::devtools::{MutationDiagnostic, QueryDiagnostic}; #[cfg(feature = "persist")] -use crate::client::persist::{PersistedEntry, SerializerRegistry}; +use crate::client::persist::PersistedEntry; use crate::core::QueryKeyFilter; #[cfg(feature = "persist")] use crate::core::{MutationStatus, QueryStatus}; +/// Erased surface behind both query maps in `QueryClient`; `TypeId` keys +/// keep the downcast to the concrete bucket sound. pub(crate) trait ErasedBucket { fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; @@ -17,20 +19,21 @@ pub(crate) trait ErasedBucket { fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); fn remove_matching(&mut self, filter: &QueryKeyFilter); fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - /// Push diagnostics into the caller's Vec so `QueryClient::diagnostics` - /// pre-sizes one destination instead of allocating per bucket. + /// Pushes into the caller's Vec so `QueryClient::diagnostics` pre-sizes + /// one destination instead of allocating per bucket. fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); /// Key/status pairs without the per-entry allocations of full /// diagnostics; used by `dehydrate`. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - /// Entries whose `T` has no registered serializer are skipped. + /// Entries whose `T` has no registered serializer are skipped; filter + /// and max-age are checked before serializing so skipped entries cost + /// nothing. #[cfg(feature = "persist")] fn collect_persistable_into( &self, cx: &gpui::App, - serializers: &SerializerRegistry, - now_ms: u64, + collect: &crate::client::bucket::shared::PersistCollect<'_>, out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, ); /// Prunes the persisted-meta map of keys whose entries were evicted. @@ -38,30 +41,6 @@ pub(crate) trait ErasedBucket { fn contains_key(&self, key: &crate::core::QueryKey) -> bool; } -pub(crate) trait ErasedInfiniteBucket { - fn as_any(&self) -> &dyn std::any::Any; - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; - fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &gpui::App); - fn count(&self) -> usize; - fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - fn remove_matching(&mut self, filter: &QueryKeyFilter); - fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut gpui::App); - fn collect_diagnostics_into(&self, now_ms: u64, cx: &gpui::App, out: &mut Vec<QueryDiagnostic>); - #[cfg(feature = "persist")] - fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - #[cfg(feature = "persist")] - fn collect_persistable_into( - &self, - cx: &gpui::App, - serializers: &SerializerRegistry, - now_ms: u64, - out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, - ); - #[cfg(feature = "persist")] - fn contains_key(&self, key: &crate::core::QueryKey) -> bool; -} - pub(crate) trait ErasedMutationBucket { fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; diff --git a/crates/gpui-query/src/client/infinite_bucket.rs b/crates/gpui-query/src/client/infinite_bucket.rs index ce8483f..4e61f60 100644 --- a/crates/gpui-query/src/client/infinite_bucket.rs +++ b/crates/gpui-query/src/client/infinite_bucket.rs @@ -1,17 +1,16 @@ //! Shares its machinery with [`QueryBucket`] through -//! [`ResourceBucket`](super::bucket::shared::ResourceBucket); only the erased -//! trait impl and the first-page persistence path are infinite-specific. +//! [`ResourceBucket`](super::bucket::shared::ResourceBucket); only the first-page +//! persistence hook is infinite-specific. use gpui::{App, Entity}; use crate::core::{ - CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, RequestPolicy, - RequestSequencer, + CachePolicy, InfiniteQueryResource, QueryKey, QueryKeyFilter, RequestPolicy, RequestSequencer, }; use super::bucket::shared::ResourceBucket; use super::devtools::QueryDiagnostic; -use super::ErasedInfiniteBucket; +use super::erased::ErasedBucket; pub struct InfiniteQueryBucket<T, E> { entries: ResourceBucket<InfiniteQueryResource<T, E>>, @@ -47,7 +46,7 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Infinit } } -impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedInfiniteBucket +impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedBucket for InfiniteQueryBucket<T, E> { fn as_any(&self) -> &dyn std::any::Any { @@ -67,38 +66,19 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI } fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.entries.for_each_matching_entry(filter, cx, |entity, cx| { - let needs_invalidate = - entity.read_with(cx, |r, _| r.last_updated_at_ms().is_some()); - if needs_invalidate { - entity.update(cx, |resource, _| resource.invalidate()); - } - }); + self.entries.invalidate_matching(filter, cx); } fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.entries.for_each_matching_entry(filter, cx, |entity, cx| { - entity.update(cx, |resource, _| resource.reset()); - }); + self.entries.reset_matching(filter, cx); } fn remove_matching(&mut self, filter: &QueryKeyFilter) { - self.entries.entries.retain(|k, _| !filter.matches(k)); + self.entries.remove_matching(filter); } - /// Bumps `ignored_results` so cancelled infinite fetches match the - /// regular query path. See `QueryBucket::cancel_matching`. fn cancel_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.entries.for_each_matching_entry(filter, cx, |entity, cx| { - if entity.read_with(cx, |r, _| r.is_loading()) { - entity.update(cx, |resource, _| { - if let Some(signal) = resource.signal() { - signal.cancel(); - } - resource.mark_ignored_result(); - }); - } - }); + self.entries.cancel_matching(filter, cx); } fn collect_diagnostics_into(&self, now_ms: u64, cx: &App, out: &mut Vec<QueryDiagnostic>) { @@ -120,42 +100,13 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedI fn collect_persistable_into( &self, cx: &App, - serializers: &crate::client::persist::SerializerRegistry, - now_ms: u64, + collect: &super::bucket::shared::PersistCollect<'_>, out: &mut Vec<( crate::core::QueryKey, crate::client::persist::PersistedEntry, )>, ) { - use crate::core::QueryStatus; - - let type_id = std::any::TypeId::of::<T>(); - let Some(serialize_fn) = serializers.get(type_id) else { - return; - }; - for (key, entry) in self.entries.entries.iter() { - let Some(entity) = entry.entity.upgrade() else { - continue; - }; - let resource = entity.read(cx); - if resource.status() != QueryStatus::Success { - continue; - } - let Some(page) = resource.first_page_arc() else { - continue; - }; - let Some(value) = serialize_fn(&*page as &dyn std::any::Any) else { - continue; - }; - out.push(( - key.clone(), - crate::client::persist::PersistedEntry { - value, - cached_at: resource.last_updated_at_ms().unwrap_or(now_ms), - cache_policy: resource.cache_policy(), - meta: None, - }, - )); - } + self.entries + .collect_persistable_into(cx, collect, out, |r| r.first_page()); } } diff --git a/crates/gpui-query/src/client/infinite_mutation_ops.rs b/crates/gpui-query/src/client/infinite_mutation_ops.rs index 4fe7fdc..2ad270d 100644 --- a/crates/gpui-query/src/client/infinite_mutation_ops.rs +++ b/crates/gpui-query/src/client/infinite_mutation_ops.rs @@ -4,6 +4,7 @@ use std::any::TypeId; use gpui::{App, Entity}; +use crate::client::erased::{ErasedBucket, ErasedMutationBucket}; use crate::client::infinite_bucket::InfiniteQueryBucket; use crate::client::mutation_bucket::MutationBucket; use crate::core::{ @@ -42,7 +43,7 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(InfiniteQueryBucket::<T, E>::new())); - let typed = Self::infinite_bucket_or_recreate::<T, E>(bucket); + let typed = Self::bucket_or_recreate(bucket, InfiniteQueryBucket::<T, E>::new); let entity = typed.get_or_create(key.into(), cache_policy, request_policy, cx); self.maybe_opportunistic_gc(cx); entity @@ -68,7 +69,7 @@ impl QueryClient { ) -> Option<crate::core::RequestId> { let type_id = TypeId::of::<(T, E)>(); let bucket = self.infinite_buckets.get_mut(&type_id)?; - let typed = Self::infinite_bucket_or_recreate::<T, E>(bucket); + let typed = Self::bucket_or_recreate(bucket, InfiniteQueryBucket::<T, E>::new); typed.sequencer_mut(key).map(|seq| seq.next_request()) } @@ -122,82 +123,45 @@ impl QueryClient { .unwrap_or_default() } - fn for_each_query_bucket_mut<F>(&mut self, mut f: F) - where - F: FnMut(EitherBucket<'_>), - { + fn for_each_query_bucket_mut(&mut self, mut f: impl FnMut(&mut dyn ErasedBucket)) { for bucket in self.buckets.values_mut() { - f(EitherBucket::Query(bucket.as_mut())); + f(bucket.as_mut()); } for bucket in self.infinite_buckets.values_mut() { - f(EitherBucket::Infinite(bucket.as_mut())); + f(bucket.as_mut()); } } /// Data is kept but marked stale. pub fn invalidate_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_query_bucket_mut(|b| match b { - EitherBucket::Query(b) => b.invalidate_matching(filter, cx), - EitherBucket::Infinite(b) => b.invalidate_matching(filter, cx), - }); + self.for_each_query_bucket_mut(|b| b.invalidate_matching(filter, cx)); } /// Data and status are cleared. pub fn reset_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_query_bucket_mut(|b| match b { - EitherBucket::Query(b) => b.reset_matching(filter, cx), - EitherBucket::Infinite(b) => b.reset_matching(filter, cx), - }); + self.for_each_query_bucket_mut(|b| b.reset_matching(filter, cx)); } /// Entries are removed from the cache entirely. pub fn remove_queries(&mut self, filter: &QueryKeyFilter) { - self.for_each_query_bucket_mut(|b| match b { - EitherBucket::Query(b) => b.remove_matching(filter), - EitherBucket::Infinite(b) => b.remove_matching(filter), - }); + self.for_each_query_bucket_mut(|b| b.remove_matching(filter)); } /// Cancels matching in-flight requests with a /// [`QueryError::cancelled`](crate::core::QueryError::cancelled) error; /// TanStack `queryClient.cancelQueries()`. pub fn cancel_queries(&mut self, filter: &QueryKeyFilter, cx: &mut App) { - self.for_each_query_bucket_mut(|b| match b { - EitherBucket::Query(b) => b.cancel_matching(filter, cx), - EitherBucket::Infinite(b) => b.cancel_matching(filter, cx), - }); - } - - fn infinite_bucket_or_recreate< - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + 'static, - >( - bucket: &mut Box<dyn super::erased::ErasedInfiniteBucket>, - ) -> &mut InfiniteQueryBucket<T, E> { - if bucket - .as_any_mut() - .downcast_mut::<InfiniteQueryBucket<T, E>>() - .is_none() - { - eprintln!( - "QueryClient: type mismatch in infinite bucket downcast for {}. \ - Replacing with a fresh bucket.", - std::any::type_name::<(T, E)>() - ); - *bucket = Box::new(InfiniteQueryBucket::<T, E>::new()); - } - bucket - .as_any_mut() - .downcast_mut::<InfiniteQueryBucket<T, E>>() - .expect("InfiniteQueryBucket downcast succeeds after infinite_bucket_or_recreate") + self.for_each_query_bucket_mut(|b| b.cancel_matching(filter, cx)); } + /// Same recovery contract as [`bucket_or_recreate`](Self::bucket_or_recreate), + /// for the mutation map's trait type. fn mutation_bucket_or_recreate< V: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static, >( - bucket: &mut Box<dyn super::erased::ErasedMutationBucket>, + bucket: &mut Box<dyn ErasedMutationBucket>, ) -> &mut MutationBucket<V, T, E> { if bucket .as_any_mut() @@ -217,8 +181,3 @@ impl QueryClient { .expect("MutationBucket downcast succeeds after mutation_bucket_or_recreate") } } - -enum EitherBucket<'a> { - Query(&'a mut dyn crate::client::erased::ErasedBucket), - Infinite(&'a mut dyn crate::client::erased::ErasedInfiniteBucket), -} diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index 298ddf1..e1d401a 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -46,19 +46,19 @@ use gpui::{App, Entity, Global}; use crate::client::bucket::shared::GC_INTERVAL; use crate::client::bucket::types::MIN_GC_TIME_MS; -use crate::client::erased::{ErasedBucket, ErasedInfiniteBucket, ErasedMutationBucket}; +use crate::client::erased::{ErasedBucket, ErasedMutationBucket}; use crate::core::{CachePolicy, QueryKey, QueryResource, RequestPolicy}; -/// Implements [`Global`]: set once with `cx.set_global(QueryClient::default())`, -/// read from any component via `cx.global::<QueryClient>()`. +/// Set once via `cx.set_global(QueryClient::default())`, read from any +/// component via `cx.global::<QueryClient>()`. pub struct QueryClient { pub(crate) buckets: AHashMap<TypeId, Box<dyn ErasedBucket>>, - pub(crate) infinite_buckets: AHashMap<TypeId, Box<dyn ErasedInfiniteBucket>>, + pub(crate) infinite_buckets: AHashMap<TypeId, Box<dyn ErasedBucket>>, pub(crate) mutation_buckets: AHashMap<TypeId, Box<dyn ErasedMutationBucket>>, pub(crate) default_cache_policy: CachePolicy, pub(crate) default_request_policy: RequestPolicy, pub(crate) gc_time_ms: u64, -/// Populated by `register_serializer::<T, E>` (value-carrying persistence path). + /// Populated by `register_serializer::<T, E>` (value-carrying persistence path). #[cfg(feature = "persist")] pub(crate) serializers: Option<crate::client::persist::SerializerRegistry>, /// Populated by `register_deserializer::<T, E>`, consumed by [`hydrate`]. @@ -111,14 +111,14 @@ impl QueryClient { Self { default_cache_policy, default_request_policy, - gc_time_ms: 300_000, ..Default::default() } } /// Values below 1000ms are clamped to 1000ms during GC to prevent /// aggressive eviction of all Idle/Failure resources on every GC pass. - /// A value of 0 disables GC entirely. + /// A value of 0 disables the opportunistic sweep only; an explicit + /// [`gc`](Self::gc) still sweeps with the clamped floor. pub fn with_gc_time(mut self, gc_time_ms: u64) -> Self { self.gc_time_ms = gc_time_ms; self @@ -184,7 +184,7 @@ impl QueryClient { .entry(type_id) .or_insert_with(|| Box::new(QueryBucket::<T, E>::new())); - let typed = Self::bucket_or_recreate::<T, E>(bucket); + let typed = Self::bucket_or_recreate(bucket, QueryBucket::<T, E>::new); let entity = typed.get_or_create(key.into(), cache_policy, request_policy, cx); self.maybe_opportunistic_gc(cx); entity @@ -204,7 +204,7 @@ impl QueryClient { .buckets .entry(type_id) .or_insert_with(|| Box::new(QueryBucket::<T, E>::new())); - let typed = Self::bucket_or_recreate::<T, E>(bucket); + let typed = Self::bucket_or_recreate(bucket, QueryBucket::<T, E>::new); let (entity, request_id) = typed.get_or_create_with_request_id(key.into(), cache_policy, request_policy, cx); self.maybe_opportunistic_gc(cx); @@ -244,31 +244,31 @@ impl QueryClient { ) -> Option<crate::core::RequestId> { let type_id = TypeId::of::<(T, E)>(); let bucket = self.buckets.get_mut(&type_id)?; - let typed = Self::bucket_or_recreate::<T, E>(bucket); + let typed = Self::bucket_or_recreate(bucket, QueryBucket::<T, E>::new); typed.sequencer_mut(key).map(|seq| seq.next_request()) } /// Recreates the bucket in place on a downcast mismatch (unreachable /// while `TypeId` keys are sound) instead of panicking. - fn bucket_or_recreate<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( + fn bucket_or_recreate<B>( bucket: &mut Box<dyn ErasedBucket>, - ) -> &mut QueryBucket<T, E> { - if bucket - .as_any_mut() - .downcast_mut::<QueryBucket<T, E>>() - .is_none() - { + fresh: impl FnOnce() -> B, + ) -> &mut B + where + B: ErasedBucket + 'static, + { + if bucket.as_any_mut().downcast_mut::<B>().is_none() { eprintln!( "QueryClient: type mismatch in bucket downcast for {}. \ Replacing with a fresh bucket.", - std::any::type_name::<(T, E)>() + std::any::type_name::<B>() ); - *bucket = Box::new(QueryBucket::<T, E>::new()); + *bucket = Box::new(fresh()); } bucket .as_any_mut() - .downcast_mut::<QueryBucket<T, E>>() - .expect("QueryBucket downcast succeeds after bucket_or_recreate") + .downcast_mut::<B>() + .expect("downcast succeeds after bucket_or_recreate replaced the box") } /// Returns `None` if no resource exists for the key, the entity was diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 4ce0b04..999caff 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -149,10 +149,11 @@ impl< }; let resource = entity.read(cx); - entry.last_updated_ms = resource.last_updated_at_ms(); + let last_updated = resource.last_updated_at_ms(); + entry.last_updated_ms = last_updated; entry.loading = resource.is_loading(); - if resource.is_loading() { + if entry.loading { return true; } @@ -162,7 +163,7 @@ impl< MutationStatus::Loading => return true, }; - let base = resource.last_updated_at_ms().unwrap_or(entry.updated_at); + let base = last_updated.unwrap_or(entry.updated_at); now_ms.saturating_sub(base) < threshold }); } diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 1bbdb54..9c9f99f 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -252,13 +252,19 @@ impl QueryClient { }; let now_ms = crate::client::time::current_time_ms(); let max_age_ms = max_age.as_millis() as u64; + let collect = crate::client::bucket::shared::PersistCollect::new( + registry, + filter, + now_ms, + max_age_ms, + ); let mut out: Vec<(QueryKey, PersistedEntry)> = Vec::new(); for bucket in self.buckets.values() { - bucket.collect_persistable_into(cx, registry, now_ms, &mut out); + bucket.collect_persistable_into(cx, &collect, &mut out); } for bucket in self.infinite_buckets.values() { - bucket.collect_persistable_into(cx, registry, now_ms, &mut out); + bucket.collect_persistable_into(cx, &collect, &mut out); } if let Some(meta_map) = &self.persisted_meta { @@ -270,15 +276,10 @@ impl QueryClient { } let mut snapshot = PersistSnapshot::new(); - for (key, entry) in out { - if !filter.matches(&key) { - continue; - } - if max_age_ms > 0 && now_ms.saturating_sub(entry.cached_at) > max_age_ms { - continue; - } - snapshot.entries.insert(key.to_path(), entry); - } + snapshot.entries = out + .into_iter() + .map(|(key, entry)| (key.to_path(), entry)) + .collect(); snapshot } diff --git a/crates/gpui-query/src/client/prepared_fetch.rs b/crates/gpui-query/src/client/prepared_fetch.rs index 299a675..b5093aa 100644 --- a/crates/gpui-query/src/client/prepared_fetch.rs +++ b/crates/gpui-query/src/client/prepared_fetch.rs @@ -37,24 +37,27 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare /// A no-op if the request ID is no longer active (replaced by a newer /// request). pub fn complete_success(self, data: T, cx: &mut App) { - self.entity.update(cx, |resource, _cx| { - let accepted = resource.complete_current_success(self.request_id, data, self.now_ms); - // Wake the persistence driver only when accepted; a stale no-op must not schedule a save. - if accepted { - #[cfg(feature = "persist")] - _cx.default_global::<crate::client::CacheMutation>(); - } - }); + self.complete(Ok(data), cx); } /// A no-op if the request ID is no longer active (replaced by a newer /// request). pub fn complete_failure(self, error: E, cx: &mut App) { - self.entity.update(cx, |resource, _cx| { - let accepted = resource.complete_current_failure(self.request_id, error, self.now_ms); + self.complete(Err(error), cx); + } + + fn complete(self, outcome: Result<T, E>, cx: &mut App) { + self.entity.update(cx, |resource, cx| { + let accepted = match outcome { + Ok(data) => resource.complete_current_success(self.request_id, data, self.now_ms), + Err(error) => { + resource.complete_current_failure(self.request_id, error, self.now_ms) + } + }; + // Wake the persistence driver only when accepted; a stale no-op must not schedule a save. if accepted { #[cfg(feature = "persist")] - _cx.default_global::<crate::client::CacheMutation>(); + cx.default_global::<crate::client::CacheMutation>(); } }); } From a8fa1437d51b272f4cdb146164644f1d14c0cb3c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 00:57:01 +0200 Subject: [PATCH 047/111] fix: align maybe_opportunistic_gc doc with gc_time_ms 0 behavior --- crates/gpui-query/src/client/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index e1d401a..8fb729c 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -134,7 +134,7 @@ impl QueryClient { } /// Runs GC every `GC_INTERVAL` operations, at most once per - /// `MIN_GC_TIME_MS`; `gc_time_ms` of 0 disables GC entirely. + /// `MIN_GC_TIME_MS`; `gc_time_ms` of 0 skips the opportunistic sweep. fn maybe_opportunistic_gc(&mut self, cx: &App) { if self.gc_time_ms == 0 { return; From 8b1b70023a9866028f7cb88a822dc08619bd7cb2 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 01:25:01 +0200 Subject: [PATCH 048/111] fix: make retry_count uniform across hook loops and trim plumbing Query, infinite, and mutation retry loops now reset retry_count only inside the accept guard, increment only while the request stays current, and reset on accepted mutation success; the infinite runner now increments at all (counter was stuck at 0 mid-sequence). Three regression tests appended under src/tests/hook_tests/. Fix and refactor committed together: the retry-count hunks interleave with the trace deletions and wrapper removals inside the same loop functions (core r1 precedent). Refactor: single-source spawn_page_fetch and spawn_retry_fetch, drop run_fetch_{next,previous}_page_with_id and run_mutation_loop_by_ref wrappers, hoist the cursor read out of the retry loop, one entity read per notification in use_query_select, impl_key_conversions! for the From impls, options new via Default, delete debug eprintln traces and the duplicated module doctest. --- crates/gpui-query/src/hook/fetch_retry.rs | 46 +--- crates/gpui-query/src/hook/mod.rs | 64 +---- .../src/hook/mutation_hooks/hooks.rs | 36 +-- .../src/hook/mutation_hooks/internals.rs | 58 ++--- crates/gpui-query/src/hook/options.rs | 79 +++---- crates/gpui-query/src/hook/query_hooks.rs | 129 +++++----- .../hook/use_infinite_query/fetch_helpers.rs | 27 +-- .../hook/use_infinite_query/fetch_runners.rs | 74 ++---- .../src/hook/use_infinite_query/hook.rs | 126 ++++------ .../gpui-query/src/hook/use_query_select.rs | 88 +++---- .../src/tests/hook_tests/regression_tests.rs | 222 +++++++++++++++++- 11 files changed, 484 insertions(+), 465 deletions(-) diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index 8971709..fcf9cc0 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -45,11 +45,11 @@ impl<T> FetchedLike<T> for Fetched<T> { } } -/// Runs the freshness check, `Loading` transition, and signal read atomically -/// in one `entity.update`; returns `(None, None)` on `CacheHit` / -/// `IgnoredWhileLoading` (skip the fetch). +/// Freshness check, `Loading` transition, and signal read in one +/// `entity.update`; `(None, None)` means `CacheHit`/`IgnoredWhileLoading`. /// -/// With a [`QueryClient`], the bucket sequencer mints the `RequestId`, shared with `prepare_fetch_query` so the two never collide for the same key. +/// With a [`QueryClient`], the bucket sequencer mints the `RequestId`, shared +/// with `prepare_fetch_query` so the two never collide for the same key. pub(crate) fn begin_request_on_entity<T, E, C>( entity: &Entity<QueryResource<T, E>>, cx: &mut Context<C>, @@ -88,9 +88,8 @@ where /// Shared retry loop; with `signal = Some`, a fresh signal is re-read after /// each delay, and the loop stops once a newer request supersedes this one. -/// -/// `cx.notify()` fires only when a result is accepted; `entity.update` results -/// are discarded (`update` returns `Result<R>` under `AsyncApp`). +/// `cx.notify()` fires only on accepted results; retry counters stay in +/// `Loading` (the observer dedupes on status). async fn run_query_retry_loop<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, @@ -113,15 +112,13 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( match result { Ok(out) => { let parts = out.into_parts(); - #[cfg(feature = "persist")] - let meta = parts.meta; let now_ms = current_time_ms(); let Some(e) = entity.upgrade() else { return; }; let _ = e.update(cx, |resource, cx| { - resource.reset_retry_count(); if let Some(guard) = resource.accept_current_request(request_id) { + resource.reset_retry_count(); resource.complete_success(guard, parts.data, now_ms); if let Some(policy) = parts.server_policy { resource.set_cache_policy(policy); @@ -130,18 +127,12 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); #[cfg(feature = "persist")] - if let Some(meta) = meta { + if let Some(meta) = parts.meta { let key = resource.key().clone(); cx.update_global::<QueryClient, _>(|client, _| { client.record_meta(key, meta); }); } - } else { - #[cfg(debug_assertions)] - eprintln!( - "DEBUG: run_query_retry_loop: request {} no longer active on success, result discarded", - request_id.label() - ); } }); return; @@ -149,11 +140,6 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( Err(error) => { if retry_policy.should_retry(attempt) { let delay_ms = retry_policy.delay_for_attempt(attempt); - let Some(e) = entity.upgrade() else { return }; - // No notify: retry counters keep status Loading; the observer dedupes on status. - let _ = e.update(cx, |resource, _cx| { - resource.increment_retry(); - }); attempt += 1; if delay_ms > 0 { @@ -171,13 +157,11 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( }) .unwrap_or_else(|| (false, QuerySignal::new())); if !request_still_active { - #[cfg(debug_assertions)] - eprintln!( - "DEBUG: run_query_retry_loop: request {} no longer active after retry delay, aborting retry", - request_id.label() - ); return; } + let _ = e.update(cx, |resource, _cx| { + resource.increment_retry(); + }); if let Some(ref mut sig) = signal { *sig = fresh_signal; } @@ -186,17 +170,11 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( let failure_now_ms = current_time_ms(); let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { - resource.complete_failure(guard, error, failure_now_ms); resource.reset_retry_count(); + resource.complete_failure(guard, error, failure_now_ms); cx.notify(); #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - } else { - #[cfg(debug_assertions)] - eprintln!( - "DEBUG: run_query_retry_loop: request {} no longer active on failure, result discarded", - request_id.label() - ); } }); return; diff --git a/crates/gpui-query/src/hook/mod.rs b/crates/gpui-query/src/hook/mod.rs index e42f81b..0f788b0 100644 --- a/crates/gpui-query/src/hook/mod.rs +++ b/crates/gpui-query/src/hook/mod.rs @@ -1,65 +1,5 @@ -//! `use_query`, `use_mutation`, and `use_infinite_query` hooks. -//! -//! # Query usage -//! -//! ```no_run -//! use gpui_query::hook::use_query; -//! use gpui_query::{QueryOptions, CachePolicy, RequestPolicy}; -//! # #[derive(Clone)] -//! # struct User; -//! # #[derive(Clone, Debug)] -//! # struct MyError; -//! -//! struct MyView { -//! users: gpui::Entity<gpui_query::QueryResource<Vec<User>, MyError>>, -//! _subscription: gpui::Subscription, -//! } -//! -//! impl MyView { -//! fn new(cx: &mut gpui::Context<Self>) -> Self { -//! let (users, _subscription) = use_query( -//! QueryOptions::new("users") -//! .cache_policy(CachePolicy::Ttl { ttl_ms: 60_000 }) -//! .request_policy(RequestPolicy::LatestWins), -//! |signal| async move { -//! Ok(vec![]) -//! }, -//! cx, -//! ); -//! Self { users, _subscription } -//! } -//! } -//! ``` -//! -//! # Mutation usage -//! -//! ```no_run -//! use gpui_query::hook::{use_mutation, mutate}; -//! # #[derive(Clone)] -//! # struct NewUser { name: String } -//! # #[derive(Clone)] -//! # struct User; -//! # #[derive(Clone, Debug)] -//! # struct MyError; -//! -//! struct MyView { -//! create_user: gpui::Entity<gpui_query::MutationResource<NewUser, User, MyError>>, -//! _subscription: gpui::Subscription, -//! } -//! -//! impl MyView { -//! fn new(cx: &mut gpui::Context<Self>) -> Self { -//! let (entity, sub) = use_mutation((), cx); -//! Self { create_user: entity, _subscription: sub } -//! } -//! -//! fn handle_submit(&mut self, name: String, cx: &mut gpui::Context<Self>) { -//! mutate(&self.create_user, NewUser { name }, |vars| async move { -//! Ok(User) -//! }, cx); -//! } -//! } -//! ``` +//! `use_query`, `use_mutation`, and `use_infinite_query` hooks; each returns +//! its resource entity plus the observer `Subscription` that keeps it live. mod fetch_retry; mod gpui_compat; diff --git a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs index 2a0e6d1..6801089 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs @@ -7,7 +7,7 @@ use crate::core::MutationResource; use super::super::MutationOptions; use super::super::options::MutationCallbacks; -use super::internals::{run_mutation_loop_by_ref, run_mutation_loop_by_ref_with_callbacks}; +use super::internals::run_mutation_loop; /// The observer dedupes on `MutationStatus` (retry ticks stay in Loading, no re-render); the entity registers with [`QueryClient`] so `use_mutation_state` finds it and GC respects `gc_time_ms`. /// @@ -56,17 +56,12 @@ where let entity = cx.new(|_| MutationResource::new(opts.retry_policy)); let observer = MutationObserver::new(&entity); - let subscription = match observer.observe(cx) { - Some(sub) => sub, - None => { - // Only reachable on a GPUI internal regression; never panic production. - debug_assert!( - false, - "MutationObserver::observe failed: entity was just created and \ - cannot be dropped. This indicates a GPUI internal regression." - ); - Subscription::new(|| {}) - } + let Some(subscription) = observer.observe(cx) else { + debug_assert!( + false, + "MutationObserver::observe failed: entity was just created and cannot be dropped" + ); + return (entity, Subscription::new(|| {})); }; if cx.has_global::<QueryClient>() { @@ -261,21 +256,8 @@ fn begin_and_spawn<V, T, E, C, F, Fut>( let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); let weak = entity.downgrade(); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| match callbacks { - Some(callbacks) => { - run_mutation_loop_by_ref_with_callbacks( - &weak, - variables, - mutator, - &retry_policy, - callbacks, - cx, - ) - .await; - } - None => { - run_mutation_loop_by_ref(&weak, variables, mutator, &retry_policy, cx).await; - } + let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + run_mutation_loop(&weak, variables, mutator, &retry_policy, callbacks, cx).await; }); entity.update(cx, |r, _| { r.set_current_task(task); diff --git a/crates/gpui-query/src/hook/mutation_hooks/internals.rs b/crates/gpui-query/src/hook/mutation_hooks/internals.rs index 6692c5f..a436507 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/internals.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/internals.rs @@ -1,6 +1,6 @@ -//! All entrypoints funnel into [`run_mutation_loop_inner`], whose `Fn(&V)` -//! mutator borrows the variables from the stored `Arc<V>` (no `V::clone` per -//! retry); the `Fn(V)` public entrypoints adapt at the call site. +//! The retry loop's `Fn(&V)` mutator borrows the variables from the stored +//! `Arc<V>` (no `V::clone` per retry); the `Fn(V)` public entrypoints adapt +//! at the call site. use std::sync::Arc; @@ -14,7 +14,7 @@ use crate::hook::read_entity; /// a transient Failure between attempts; only exhausted retries produce a /// terminal `complete_failure()`. Stops once the mutation leaves Loading /// (cancelled or reset); intermediate calls don't notify (observer dedupes). -async fn run_mutation_loop_inner<V, T, E, F, Fut>( +pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( weak: &gpui::WeakEntity<MutationResource<V, T, E>>, variables: Arc<V>, mutator: F, @@ -28,6 +28,8 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( F: Fn(&V) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { + let needs_data = + |cb: &MutationCallbacks<T, E>| cb.on_success.is_some() || cb.on_settled.is_some(); let mut attempt: u32 = 0; loop { @@ -35,7 +37,10 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( match result { Ok(data) => { - let data_for_callback = callbacks.is_some().then(|| data.clone()); + let data_for_callback = callbacks + .as_ref() + .is_some_and(needs_data) + .then(|| data.clone()); let Some(entity) = weak.upgrade() else { if let Some(ref cb) = callbacks @@ -47,6 +52,7 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( }; let _ = entity.update(cx, |resource, cx| { resource.complete_success(data); + resource.reset_retry_count(); cx.notify(); #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); @@ -67,7 +73,10 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( return; } Err(error) => { - let error_for_callback = callbacks.is_some().then(|| error.clone()); + let error_for_callback = callbacks + .as_ref() + .is_some_and(|cb| cb.on_error.is_some() || cb.on_settled.is_some()) + .then(|| error.clone()); if retry_policy.should_retry(attempt) { let delay_ms = retry_policy.delay_for_attempt(attempt); @@ -92,10 +101,6 @@ async fn run_mutation_loop_inner<V, T, E, F, Fut>( }; if !read_entity(&entity, cx, |r, _| r.is_loading()).unwrap_or(false) { fire_error_callbacks(&callbacks, &error_for_callback); - #[cfg(debug_assertions)] - eprintln!( - "DEBUG: run_mutation_loop_inner: mutation no longer Loading after retry delay, aborting" - ); return; } @@ -139,36 +144,3 @@ fn fire_error_callbacks<T, E>( } } } - -pub(super) async fn run_mutation_loop_by_ref<V, T, E, F, Fut>( - weak: &gpui::WeakEntity<MutationResource<V, T, E>>, - variables: Arc<V>, - mutator: F, - retry_policy: &RetryPolicy, - cx: &mut gpui::AsyncApp, -) where - V: Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(&V) -> Fut + Send + 'static, - Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, -{ - run_mutation_loop_inner(weak, variables, mutator, retry_policy, None, cx).await; -} - -pub(super) async fn run_mutation_loop_by_ref_with_callbacks<V, T, E, F, Fut>( - weak: &gpui::WeakEntity<MutationResource<V, T, E>>, - variables: Arc<V>, - mutator: F, - retry_policy: &RetryPolicy, - callbacks: MutationCallbacks<T, E>, - cx: &mut gpui::AsyncApp, -) where - V: Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(&V) -> Fut + Send + 'static, - Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, -{ - run_mutation_loop_inner(weak, variables, mutator, retry_policy, Some(callbacks), cx).await; -} diff --git a/crates/gpui-query/src/hook/options.rs b/crates/gpui-query/src/hook/options.rs index 6bffbbe..9e2ef3d 100644 --- a/crates/gpui-query/src/hook/options.rs +++ b/crates/gpui-query/src/hook/options.rs @@ -17,11 +17,11 @@ use crate::core::{CachePolicy, RefetchTrigger, RequestPolicy, RetryPolicy}; /// # struct MyError; /// # fn _doc(cx: &mut gpui::Context<()>) { /// -/// let result = use_query("users", |signal| async move { +/// let (_entity, _sub) = use_query("users", |signal| async move { /// Ok::<Vec<User>, MyError>(vec![]) /// }, cx); /// -/// let result = use_query( +/// let (_entity, _sub) = use_query( /// QueryOptions::new("users") /// .cache_policy(CachePolicy::Ttl { ttl_ms: 300_000 }) /// .retry_policy(RetryPolicy::new(5)), @@ -100,19 +100,34 @@ macro_rules! impl_query_options_builders { }; } +/// `From` key conversions shared by both option types. +macro_rules! impl_key_conversions { + ($t:ident) => { + impl From<&str> for $t { + fn from(key: &str) -> Self { + Self::new(key) + } + } + + impl From<String> for $t { + fn from(key: String) -> Self { + Self::new(key) + } + } + + impl From<crate::core::QueryKey> for $t { + fn from(key: crate::core::QueryKey) -> Self { + Self::new(key) + } + } + }; +} + impl QueryOptions { pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { Self { key: key.into(), - cache_policy: CachePolicy::default(), - request_policy: RequestPolicy::default(), - retry_policy: RetryPolicy::default(), - gc_time_ms: 300_000, - keep_previous_data: false, - force_fetch: false, - refetch_on_mount: RefetchTrigger::default(), - refetch_on_window_focus: RefetchTrigger::default(), - refetch_on_reconnect: RefetchTrigger::default(), + ..Self::default() } } @@ -129,23 +144,7 @@ impl QueryOptions { impl_query_options_builders!(QueryOptions); -impl From<&str> for QueryOptions { - fn from(key: &str) -> Self { - Self::new(key) - } -} - -impl From<String> for QueryOptions { - fn from(key: String) -> Self { - Self::new(key) - } -} - -impl From<crate::core::QueryKey> for QueryOptions { - fn from(key: crate::core::QueryKey) -> Self { - Self::new(key) - } -} +impl_key_conversions!(QueryOptions); impl From<(crate::core::QueryKey, CachePolicy, RequestPolicy)> for QueryOptions { fn from( @@ -278,11 +277,7 @@ impl InfiniteQueryOptions { pub fn new(key: impl Into<crate::core::QueryKey>) -> Self { Self { key: key.into(), - cache_policy: CachePolicy::default(), - request_policy: RequestPolicy::default(), - max_pages: Some(50), - retry_policy: RetryPolicy::default(), - gc_time_ms: 300_000, + ..Self::default() } } @@ -302,20 +297,4 @@ impl InfiniteQueryOptions { impl_query_options_builders!(InfiniteQueryOptions); -impl From<&str> for InfiniteQueryOptions { - fn from(key: &str) -> Self { - Self::new(key) - } -} - -impl From<String> for InfiniteQueryOptions { - fn from(key: String) -> Self { - Self::new(key) - } -} - -impl From<crate::core::QueryKey> for InfiniteQueryOptions { - fn from(key: crate::core::QueryKey) -> Self { - Self::new(key) - } -} +impl_key_conversions!(InfiniteQueryOptions); diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index e195101..e44a5f8 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -1,21 +1,55 @@ -//! Plain-query fetch tasks are deliberately detached: stale writes are guarded -//! by the two-phase `accept_current_request` protocol, and each task holds -//! only a `WeakEntity`, so it self-terminates on entity drop. +//! Fetch tasks are deliberately detached: stale writes are guarded by the +//! two-phase `accept_current_request` protocol, and each task holds only a +//! `WeakEntity`, so it self-terminates on entity drop. use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; use crate::client::{QueryClient, QueryObserver}; -use crate::core::{Fetched, QueryFetchMode, QueryKey, QueryResource, QuerySignal, QueryStatus}; +use crate::core::{ + CachePolicy, Fetched, QueryFetchMode, QueryKey, QueryResource, QuerySignal, QueryStatus, + RequestPolicy, +}; -use super::current_time_ms; +use super::QueryOptions; use super::fetch_retry::{ FetchedLike, begin_request_on_entity, fetch_signal_with_retry, fetch_with_retry, }; /// Creates or reuses the resource in the global [`QueryClient`] and spawns a /// fetch if it is idle; call it in a constructor, never in `render`. +/// +/// # Example +/// +/// ```no_run +/// use gpui_query::hook::use_query; +/// use gpui_query::{QueryOptions, CachePolicy, RequestPolicy}; +/// # #[derive(Clone)] +/// # struct User; +/// # #[derive(Clone, Debug)] +/// # struct MyError; +/// +/// struct MyView { +/// users: gpui::Entity<gpui_query::QueryResource<Vec<User>, MyError>>, +/// _subscription: gpui::Subscription, +/// } +/// +/// impl MyView { +/// fn new(cx: &mut gpui::Context<Self>) -> Self { +/// let (users, _subscription) = use_query( +/// QueryOptions::new("users") +/// .cache_policy(CachePolicy::Ttl { ttl_ms: 60_000 }) +/// .request_policy(RequestPolicy::LatestWins), +/// |signal| async move { +/// Ok(vec![]) +/// }, +/// cx, +/// ); +/// Self { users, _subscription } +/// } +/// } +/// ``` pub fn use_query<T, E, C, F, Fut>( - options: impl Into<crate::hook::QueryOptions>, + options: impl Into<QueryOptions>, fetcher: F, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) @@ -32,7 +66,7 @@ where /// A fetcher returning [`Fetched::with_policy`](crate::core::Fetched::with_policy) /// overrides the resource's stored policy right after success (server wins). pub fn use_query_with_policy<T, E, C, F, Fut>( - options: impl Into<crate::hook::QueryOptions>, + options: impl Into<QueryOptions>, fetcher: F, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) @@ -47,7 +81,7 @@ where } fn use_query_impl<T, E, C, F, Fut, Out>( - options: crate::hook::QueryOptions, + options: QueryOptions, fetcher: F, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) @@ -59,7 +93,7 @@ where F: Fn(QuerySignal) -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<Out, E>> + Send + 'static, { - let crate::hook::QueryOptions { + let QueryOptions { key, cache_policy, request_policy, @@ -82,11 +116,11 @@ where { let signal = signal.unwrap_or_else(QuerySignal::new); let weak = entity.downgrade(); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + cx.spawn(async move |_this, cx| { fetch_signal_with_retry(fetcher, signal, request_id, &retry_policy, &weak, cx) .await; - }); - task.detach(); + }) + .detach(); } } @@ -96,8 +130,8 @@ where /// Signal-free fetcher variant; prefer the signal-accepting [`use_query`]. pub fn use_query_unsignalled<T, E, C, F, Fut>( key: QueryKey, - cache_policy: crate::core::CachePolicy, - request_policy: crate::core::RequestPolicy, + cache_policy: CachePolicy, + request_policy: RequestPolicy, fetcher: F, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) @@ -108,18 +142,10 @@ where F: Fn() -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - let (entity, subscription) = use_query_manual(key.clone(), cache_policy, request_policy, cx); + let (entity, subscription) = use_query_manual(key, cache_policy, request_policy, cx); - if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) - && let (Some(request_id), _signal) = - begin_request_on_entity(&entity, cx, QueryFetchMode::Normal, Some(key)) - { - let weak = entity.downgrade(); - let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; - }); - task.detach(); + if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { + spawn_retry_fetch(&entity, fetcher, cx); } (entity, subscription) @@ -128,7 +154,7 @@ where /// Consumes only `key`, `cache_policy`, and `request_policy`; use /// [`use_query`] to honor the rest. pub fn use_query_manual_opts<T, E, C>( - options: impl Into<crate::hook::QueryOptions>, + options: impl Into<QueryOptions>, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) where @@ -141,7 +167,7 @@ where } pub fn use_query_unsignalled_opts<T, E, C, F, Fut>( - options: impl Into<crate::hook::QueryOptions>, + options: impl Into<QueryOptions>, fetcher: F, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) @@ -165,8 +191,8 @@ where /// Entity + observer without starting a fetch; panics in debug builds when no [`QueryClient`] global is set (release falls back to a standalone entity). pub fn use_query_manual<T, E, C>( key: QueryKey, - cache_policy: crate::core::CachePolicy, - request_policy: crate::core::RequestPolicy, + cache_policy: CachePolicy, + request_policy: RequestPolicy, cx: &mut Context<C>, ) -> (Entity<QueryResource<T, E>>, Subscription) where @@ -181,11 +207,6 @@ where } else { #[cfg(debug_assertions)] { - eprintln!( - "use_query_manual: no QueryClient set via cx.set_global(). \ - Falling back to standalone entity (no shared caching, no GC). \ - Call cx.set_global(QueryClient::new()) in your app setup." - ); panic!( "use_query_manual: QueryClient is not initialized. \ Call cx.set_global(QueryClient::new()) before using query hooks." @@ -199,15 +220,11 @@ where let observer = QueryObserver::new(&entity); let Some(subscription) = observer.observe(cx) else { - #[cfg(debug_assertions)] - panic!( - "QueryObserver::observe failed: entity was just created and cannot be dropped. \ - This indicates a GPUI internal regression." + debug_assert!( + false, + "QueryObserver::observe failed: entity was just created and cannot be dropped" ); - #[cfg(not(debug_assertions))] - { - return (entity, Subscription::new(|| {})); - } + return (entity, Subscription::new(|| {})); }; (entity, subscription) @@ -226,7 +243,7 @@ pub fn fetch_query<T, E, C, F, Fut>( F: Fn() -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<T, E>> + Send + 'static, { - fetch_query_impl(entity, fetcher, cx); + spawn_retry_fetch(entity, fetcher, cx); } /// [`fetch_query`] whose fetcher may return [`Fetched<T>`](crate::core::Fetched) @@ -242,10 +259,10 @@ pub fn fetch_query_with_policy<T, E, C, F, Fut>( F: Fn() -> Fut + Send + 'static, Fut: std::future::Future<Output = Result<Fetched<T>, E>> + Send + 'static, { - fetch_query_impl(entity, fetcher, cx); + spawn_retry_fetch(entity, fetcher, cx); } -fn fetch_query_impl<T, E, C, F, Fut, Out>( +fn spawn_retry_fetch<T, E, C, F, Fut, Out>( entity: &Entity<QueryResource<T, E>>, fetcher: F, cx: &mut Context<C>, @@ -264,10 +281,10 @@ fn fetch_query_impl<T, E, C, F, Fut, Out>( }; let weak = entity.downgrade(); let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + cx.spawn(async move |_this, cx| { fetch_with_retry(fetcher, request_id, &retry_policy, &weak, cx).await; - }); - task.detach(); + }) + .detach(); } /// `FnOnce` fetcher, so no retries; staleness is guarded by @@ -291,31 +308,27 @@ pub fn fetch_query_with_signal<T, E, C, F, Fut>( let signal = signal.unwrap_or_else(QuerySignal::new); let weak = entity.downgrade(); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + cx.spawn(async move |_this, cx| { let result = fetcher(signal).await; - let now_ms = current_time_ms(); + let now_ms = super::current_time_ms(); let Some(entity) = weak.upgrade() else { return }; let _ = entity.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { match result { Ok(data) => { + resource.reset_retry_count(); resource.complete_success(guard, data, now_ms); } Err(error) => { + resource.reset_retry_count(); resource.complete_failure(guard, error, now_ms); } } cx.notify(); - } else { - #[cfg(debug_assertions)] - eprintln!( - "DEBUG: fetch_query_with_signal: request {} no longer active, result discarded", - request_id.label() - ); } }); - }); - task.detach(); + }) + .detach(); } diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs index 1c0df78..4728860 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs @@ -5,9 +5,7 @@ use gpui::{BorrowAppContext as _, Context, Entity}; use crate::client::QueryClient; use crate::core::InfiniteQueryResource; -use super::fetch_runners::{ - PageDirection, run_fetch_next_page_with_id, run_fetch_previous_page_with_id, -}; +use super::fetch_runners::{PageDirection, run_fetch_page_with_id}; use crate::hook::current_time_ms; /// If a fetch is already in flight, its signal is cancelled and the new request supersedes it. @@ -38,7 +36,7 @@ pub fn fetch_next_page_infinite<T, E, C, FNext, Fut>( FNext: Fn(Option<&T>) -> Fut + 'static, Fut: std::future::Future<Output = Result<(T, bool), E>> + Send + 'static, { - fetch_page_infinite(entity, fetcher, cx, PageDirection::Next); + spawn_page_fetch(entity, fetcher, PageDirection::Next, cx); } /// Backward variant: the fetcher receives the first page (not the last) as its cursor. @@ -53,14 +51,18 @@ pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( FPrev: Fn(Option<&T>) -> Fut + 'static, Fut: std::future::Future<Output = Result<(T, bool), E>> + Send + 'static, { - fetch_page_infinite(entity, fetcher, cx, PageDirection::Previous); + spawn_page_fetch(entity, fetcher, PageDirection::Previous, cx); } -fn fetch_page_infinite<T, E, C, F, Fut>( +/// Mints the `RequestId` from the bucket's sequencer (so ids from +/// `QueryClient` and the resource's own fallback never collide), begins the +/// fetch, and stores the spawned task so a replacement fetch or entity drop +/// aborts the prior one. +pub(super) fn spawn_page_fetch<T, E, C, F, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: F, - cx: &mut Context<C>, direction: PageDirection, + cx: &mut Context<C>, ) where T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + std::fmt::Debug + 'static, @@ -91,15 +93,8 @@ fn fetch_page_infinite<T, E, C, F, Fut>( if let Some(request_id) = request_id { let retry_policy = entity.read_with(cx, |r, _| r.retry_policy().clone()); - // Stored on the resource: a replacement fetch or unmount aborts the prior task. - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| match direction { - PageDirection::Next => { - run_fetch_next_page_with_id(&weak, &fetcher, request_id, &retry_policy, cx).await; - } - PageDirection::Previous => { - run_fetch_previous_page_with_id(&weak, &fetcher, request_id, &retry_policy, cx) - .await; - } + let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { + run_fetch_page_with_id(&weak, &fetcher, request_id, &retry_policy, cx, direction).await; }); entity.update(cx, |r, _| r.set_current_task(task)); } diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs index e4896a6..49b7433 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs @@ -1,4 +1,4 @@ -//! Retry-aware page-fetch runners with two-phase completion. +//! Retry-aware page-fetch runner with two-phase completion. use std::sync::Arc; @@ -6,8 +6,9 @@ use crate::core::{InfiniteQueryResource, RequestId}; use crate::hook::{current_time_ms, read_entity}; -/// The runners differ only in cursor page and the `is_next` flag passed to -/// [`InfiniteQueryResource::complete_success_with_guard`]; this enum carries that. +/// The runner is direction-agnostic; this carries the cursor page and the +/// `is_next` flag passed to +/// [`InfiniteQueryResource::complete_success_with_guard`]. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum PageDirection { Next, @@ -33,8 +34,10 @@ impl PageDirection { /// The `request_id` comes from `begin_fetch_*` (never re-read after the fetcher), /// and completion is two-phase so a superseded request can never write; a -/// cancelled or superseded fetch stops retrying after the delay. -async fn run_fetch_page_with_id<T, E, F, Fut>( +/// cancelled or superseded fetch stops retrying after the delay. The cursor is +/// read once: pages cannot change while this request stays current, and once it +/// is superseded the completion is discarded anyway. +pub(super) async fn run_fetch_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, request_id: RequestId, @@ -47,14 +50,14 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( F: Fn(Option<&T>) -> Fut + 'static, Fut: std::future::Future<Output = Result<(T, bool), E>> + Send + 'static, { + let cursor_page_arc: Option<Arc<T>> = { + let Some(e) = entity.upgrade() else { return }; + read_entity(&e, cx, |r, _| direction.cursor_page_arc(r)).flatten() + }; + let mut attempt: u32 = 0; loop { - let cursor_page_arc: Option<Arc<T>> = { - let Some(e) = entity.upgrade() else { return }; - read_entity(&e, cx, |r, _| direction.cursor_page_arc(r)).flatten() - }; - let result = fetcher(cursor_page_arc.as_ref().map(|a| a.as_ref())).await; let now_ms = current_time_ms(); @@ -65,6 +68,7 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( Ok((page, has_more)) => { let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { + resource.reset_retry_count(); resource.complete_success_with_guard( guard, page, @@ -101,9 +105,13 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( if cancelled || !still_current { return; } + let _ = e.update(cx, |resource, _cx| { + resource.increment_retry(); + }); } else { let _ = e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { + resource.reset_retry_count(); resource.complete_failure_with_guard(guard, error); cx.notify(); #[cfg(feature = "persist")] @@ -116,49 +124,3 @@ async fn run_fetch_page_with_id<T, E, F, Fut>( } } } - -pub(super) async fn run_fetch_next_page_with_id<T, E, F, Fut>( - entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, - fetcher: &F, - request_id: RequestId, - retry_policy: &crate::core::RetryPolicy, - cx: &mut gpui::AsyncApp, -) where - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(Option<&T>) -> Fut + 'static, - Fut: std::future::Future<Output = Result<(T, bool), E>> + Send + 'static, -{ - run_fetch_page_with_id( - entity, - fetcher, - request_id, - retry_policy, - cx, - PageDirection::Next, - ) - .await; -} - -pub(super) async fn run_fetch_previous_page_with_id<T, E, F, Fut>( - entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, - fetcher: &F, - request_id: RequestId, - retry_policy: &crate::core::RetryPolicy, - cx: &mut gpui::AsyncApp, -) where - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + std::fmt::Debug + 'static, - F: Fn(Option<&T>) -> Fut + 'static, - Fut: std::future::Future<Output = Result<(T, bool), E>> + Send + 'static, -{ - run_fetch_page_with_id( - entity, - fetcher, - request_id, - retry_policy, - cx, - PageDirection::Previous, - ) - .await; -} diff --git a/crates/gpui-query/src/hook/use_infinite_query/hook.rs b/crates/gpui-query/src/hook/use_infinite_query/hook.rs index eda8adb..b8f88d8 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/hook.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/hook.rs @@ -1,55 +1,56 @@ -//! The fetcher receives `Option<&T>` (the last page, if any) and returns -//! `(T, bool)`, where the bool says whether more pages exist. -//! -//! # Usage -//! -//! ```no_run -//! use gpui_query::hook::{use_infinite_query, fetch_next_page_infinite, InfiniteQueryOptions}; -//! use gpui_query::QueryKey; -//! # #[derive(Clone)] -//! # struct Post { id: u64 } -//! # #[derive(Clone, Debug)] -//! # struct MyError; -//! -//! struct FeedView { -//! feed: gpui::Entity<gpui_query::InfiniteQueryResource<Vec<Post>, MyError>>, -//! _subscription: gpui::Subscription, -//! } -//! -//! impl FeedView { -//! fn new(cx: &mut gpui::Context<Self>) -> Self { -//! let (entity, _subscription) = use_infinite_query( -//! InfiniteQueryOptions::new(QueryKey::from(["feed"])), -//! |last_page| async move { -//! Ok((vec![], false)) -//! }, -//! cx, -//! ); -//! Self { feed: entity, _subscription } -//! } -//! -//! fn on_scroll_to_bottom(&mut self, cx: &mut gpui::Context<Self>) { -//! fetch_next_page_infinite( -//! &self.feed, -//! |last_page| async move { -//! Ok((vec![], false)) -//! }, -//! cx, -//! ); -//! } -//! } -//! ``` +//! `use_infinite_query` hook; the fetcher receives `Option<&T>` (the last +//! page, if any) and returns `(T, bool)`, where the bool says whether more +//! pages exist. use gpui::{AppContext as _, BorrowAppContext as _, Context, Entity, Subscription}; use crate::client::{InfiniteQueryObserver, QueryClient}; use crate::core::{InfiniteQueryResource, QueryStatus}; -use super::fetch_runners::run_fetch_next_page_with_id; -use crate::hook::current_time_ms; +use super::fetch_helpers::spawn_page_fetch; +use super::fetch_runners::PageDirection; use crate::hook::options::InfiniteQueryOptions; /// The observer dedupes on status, so retry ticks do not re-render; the options' retry policy applies to every page fetch. +/// +/// # Example +/// +/// ```no_run +/// use gpui_query::hook::{use_infinite_query, fetch_next_page_infinite, InfiniteQueryOptions}; +/// use gpui_query::QueryKey; +/// # #[derive(Clone)] +/// # struct Post { id: u64 } +/// # #[derive(Clone, Debug)] +/// # struct MyError; +/// +/// struct FeedView { +/// feed: gpui::Entity<gpui_query::InfiniteQueryResource<Vec<Post>, MyError>>, +/// _subscription: gpui::Subscription, +/// } +/// +/// impl FeedView { +/// fn new(cx: &mut gpui::Context<Self>) -> Self { +/// let (entity, _subscription) = use_infinite_query( +/// InfiniteQueryOptions::new(QueryKey::from(["feed"])), +/// |last_page| async move { +/// Ok((vec![], false)) +/// }, +/// cx, +/// ); +/// Self { feed: entity, _subscription } +/// } +/// +/// fn on_scroll_to_bottom(&mut self, cx: &mut gpui::Context<Self>) { +/// fetch_next_page_infinite( +/// &self.feed, +/// |last_page| async move { +/// Ok((vec![], false)) +/// }, +/// cx, +/// ); +/// } +/// } +/// ``` pub fn use_infinite_query<T, E, C, FNext, Fut>( options: InfiniteQueryOptions, fetch_next: FNext, @@ -91,49 +92,22 @@ where if let Some(max) = max_pages { resource.set_max_pages(Some(max)); } - resource.set_retry_policy(retry_policy.clone()); + resource.set_retry_policy(retry_policy); cx.notify(); }); let observer = InfiniteQueryObserver::new(&entity); let Some(subscription) = observer.observe(cx) else { - #[cfg(debug_assertions)] - panic!( - "InfiniteQueryObserver::observe failed: entity was just created and \ - cannot be dropped. This indicates a GPUI internal regression." + debug_assert!( + false, + "InfiniteQueryObserver::observe failed: entity was just created and cannot be dropped" ); - #[cfg(not(debug_assertions))] - { - return (entity, Subscription::new(|| {})); - } + return (entity, Subscription::new(|| {})); }; if entity.read_with(cx, |r, _| r.status() == QueryStatus::Idle) { - // The initial fetch mints from the bucket's sequencer too, so later page-fetch ids continue the same sequence. - let maybe_request_id = if cx.has_global::<QueryClient>() { - let key = entity.read_with(cx, |r, _| r.key().clone()); - cx.update_global::<QueryClient, _>(|client, _| { - client.next_request_id_for_infinite_key::<T, E>(&key) - }) - } else { - None - }; - - let request_id = entity.update(cx, |resource, _| { - let now_ms = current_time_ms(); - resource.begin_fetch_next_with_id(maybe_request_id, now_ms) - }); - - if let Some(request_id) = request_id { - let weak = entity.downgrade(); - let fetcher = fetch_next; - let retry = retry_policy.clone(); - let task: gpui::Task<()> = cx.spawn(async move |_this, cx| { - run_fetch_next_page_with_id(&weak, &fetcher, request_id, &retry, cx).await; - }); - entity.update(cx, |r, _| r.set_current_task(task)); - } + spawn_page_fetch(&entity, fetch_next, PageDirection::Next, cx); } (entity, subscription) diff --git a/crates/gpui-query/src/hook/use_query_select.rs b/crates/gpui-query/src/hook/use_query_select.rs index 055bcc1..33acd61 100644 --- a/crates/gpui-query/src/hook/use_query_select.rs +++ b/crates/gpui-query/src/hook/use_query_select.rs @@ -1,36 +1,5 @@ //! Cached data projected into a derived shape, re-running only when the data //! changes; the hook returns a `MappedQueryResource<T, U, E>`. -//! -//! # Usage -//! -//! ```no_run -//! use gpui_query::hook::{use_query_select, QueryOptions}; -//! use gpui_query::core::SelectTransform; -//! # #[derive(Clone, PartialEq)] -//! # struct User; -//! # #[derive(Clone, Debug)] -//! # struct MyError; -//! -//! struct UserCountView { -//! mapped: gpui::Entity<gpui_query::core::MappedQueryResource<Vec<User>, usize, MyError>>, -//! _subs: (gpui::Subscription, gpui::Subscription), -//! } -//! -//! impl UserCountView { -//! fn new(cx: &mut gpui::Context<Self>) -> Self { -//! let count_transform = SelectTransform::new(|users: &Vec<User>| users.len()); -//! let (mapped, query_entity, subs) = use_query_select( -//! QueryOptions::new("users"), -//! count_transform, -//! |signal| async move { -//! Ok(vec![]) -//! }, -//! cx, -//! ); -//! Self { mapped, _subs: subs } -//! } -//! } -//! ``` use std::sync::Arc; @@ -55,6 +24,37 @@ pub type QuerySelectResult<T, U, E> = ( /// let count = mapped.read(cx).data(); // transform runs once; reuse `count` /// # } /// ``` +/// +/// # Example +/// +/// ```no_run +/// use gpui_query::hook::{use_query_select, QueryOptions}; +/// use gpui_query::core::SelectTransform; +/// # #[derive(Clone, PartialEq)] +/// # struct User; +/// # #[derive(Clone, Debug)] +/// # struct MyError; +/// +/// struct UserCountView { +/// mapped: gpui::Entity<gpui_query::core::MappedQueryResource<Vec<User>, usize, MyError>>, +/// _subs: (gpui::Subscription, gpui::Subscription), +/// } +/// +/// impl UserCountView { +/// fn new(cx: &mut gpui::Context<Self>) -> Self { +/// let count_transform = SelectTransform::new(|users: &Vec<User>| users.len()); +/// let (mapped, query_entity, subs) = use_query_select( +/// QueryOptions::new("users"), +/// count_transform, +/// |signal| async move { +/// Ok(vec![]) +/// }, +/// cx, +/// ); +/// Self { mapped, _subs: subs } +/// } +/// } +/// ``` pub fn use_query_select<T, U, E, C, F, Fut>( options: impl Into<QueryOptions>, transform: SelectTransform<T, U>, @@ -78,22 +78,26 @@ where let mapped_weak = mapped_entity.downgrade(); let mapped_subscription = cx.observe(&query_entity, move |_, entity, cx| { - if let Some(mapped) = mapped_weak.upgrade() { - let cached: Option<Arc<T>> = mapped.read_with(cx, |m, _| m.source_arc()); + let Some(mapped) = mapped_weak.upgrade() else { + return; + }; + let cached: Option<Arc<T>> = mapped.read_with(cx, |m, _| m.source_arc()); - let changed = entity.read_with(cx, |r, _| match (&cached, r.data()) { + // One read computes both the change verdict and the replacement value. + let update: Option<Option<Arc<T>>> = entity.read_with(cx, |r, _| { + let changed = match (&cached, r.data()) { (Some(c), Some(fresh)) => c.as_ref() != fresh, (None, None) => false, _ => true, - }); + }; + changed.then(|| r.data().map(|d| Arc::new(d.clone()))) + }); - if changed { - let fresh: Option<Arc<T>> = entity.read(cx).data().map(|d| Arc::new(d.clone())); - mapped.update(cx, |m, cx2| { - m.update_source(fresh); - cx2.notify(); - }); - } + if let Some(fresh) = update { + mapped.update(cx, |m, cx| { + m.update_source(fresh); + cx.notify(); + }); } }); diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index c7297ef..deb9ed2 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -2,7 +2,10 @@ use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; -use crate::core::{MutationResource, QueryError}; +use crate::core::{ + CachePolicy, InfiniteQueryResource, MutationResource, MutationStatus, QueryError, QueryKey, + QueryResource, RequestPolicy, RetryPolicy, +}; use crate::hook::*; use crate::tests::test_support::*; @@ -170,3 +173,220 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) gate.release(); cx.run_until_parked(); } + +#[gpui::test] +fn hook_query_discarded_success_keeps_active_retry_count(cx: &mut TestAppContext) { + setup_test(cx); + + let gate_a = Gate::new(); + let gate_a_for_fetch = gate_a.clone(); + let gate_b = Gate::new(); + let gate_b_for_fetch = gate_b.clone(); + let b_calls = Arc::new(Mutex::new(0u32)); + let b_calls_for_fetch = b_calls.clone(); + let executor = cx.background_executor.clone(); + + struct H { + entity: Entity<QueryResource<&'static str, QueryError>>, + } + + let harness = cx.new(|cx| { + let (entity, _sub) = use_query_manual::<&'static str, QueryError, _>( + QueryKey::from("discarded-success-retry-count"), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + cx, + ); + entity.update(cx, |r, _| { + r.set_retry_policy(RetryPolicy::new(3).with_delay(0)) + }); + + let executor_for_a = executor.clone(); + fetch_query( + &entity, + move || { + let gate_a_for_fetch = gate_a_for_fetch.clone(); + let executor_for_a = executor_for_a.clone(); + async move { + gate_a_for_fetch.wait(&executor_for_a).await; + Ok::<_, QueryError>("superseded-data") + } + }, + cx, + ); + + let executor_for_b = executor.clone(); + fetch_query( + &entity, + move || { + let b_calls_for_fetch = b_calls_for_fetch.clone(); + let gate_b_for_fetch = gate_b_for_fetch.clone(); + let executor_for_b = executor_for_b.clone(); + async move { + let n = { + let mut g = b_calls_for_fetch.lock().unwrap(); + *g += 1; + *g + }; + if n >= 2 { + gate_b_for_fetch.wait(&executor_for_b).await; + } + Err::<_, QueryError>(QueryError::response("b-fail")) + } + }, + cx, + ); + H { entity } + }); + + cx.run_until_parked(); + + gate_a.release(); + cx.background_executor + .advance_clock(std::time::Duration::from_millis(5)); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + harness.read(cx).entity.read(cx).retry_count(), + 1, + "a discarded superseded success must not clobber the active \ + request's mid-flight retry count" + ); + }); + + gate_b.release(); + cx.background_executor + .advance_clock(std::time::Duration::from_millis(20)); + cx.run_until_parked(); + + cx.update(|cx| { + let resource = harness.read(cx).entity.read(cx); + assert_eq!(crate::core::QueryStatus::Failure, resource.status()); + assert!( + resource.data().is_none(), + "the superseded success must never land" + ); + assert_eq!(resource.retry_count(), 0); + }); +} + +#[gpui::test] +fn hook_infinite_retry_count_tracks_attempts(cx: &mut TestAppContext) { + setup_query_client(cx); + + let calls = Arc::new(Mutex::new(0u32)); + let calls_for_fetch = calls.clone(); + let gate = Gate::new(); + let gate_for_fetch = gate.clone(); + let executor = cx.background_executor.clone(); + + struct H { + entity: Entity<InfiniteQueryResource<Vec<i32>, QueryError>>, + } + + let harness = cx.new(|cx| { + let (entity, _sub) = use_infinite_query( + InfiniteQueryOptions::new("infinite-retry-count") + .cache_policy(CachePolicy::Ttl { ttl_ms: 0 }) + .retry_policy(RetryPolicy::new(5).with_delay(0)), + move |_last_page| { + let calls_for_fetch = calls_for_fetch.clone(); + let gate_for_fetch = gate_for_fetch.clone(); + let executor = executor.clone(); + async move { + let n = { + let mut g = calls_for_fetch.lock().unwrap(); + *g += 1; + *g + }; + if n >= 2 { + gate_for_fetch.wait(&executor).await; + } + Err::<_, QueryError>(QueryError::response("inf-transient")) + } + }, + cx, + ); + H { entity } + }); + + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + harness.read(cx).entity.read(cx).retry_count(), + 1, + "infinite retry attempts must be reflected in retry_count while \ + the retry sequence is in flight" + ); + }); + + gate.release(); + cx.background_executor + .advance_clock(std::time::Duration::from_millis(20)); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + crate::core::QueryStatus::Failure, + harness.read(cx).entity.read(cx).status() + ); + }); +} + +#[gpui::test] +fn hook_mutation_retry_count_reset_on_success(cx: &mut TestAppContext) { + setup_test(cx); + + let calls = Arc::new(Mutex::new(0u32)); + let calls_for_mutator = calls.clone(); + + struct H { + mutation: Entity<MutationResource<String, String, QueryError>>, + } + + let harness = cx.new(|cx| { + let (entity, _sub) = use_mutation::<String, String, QueryError, _>( + MutationOptions { + retry_policy: RetryPolicy::new(2).with_delay(0), + gc_time_ms: 300_000, + }, + cx, + ); + mutate( + &entity, + "vars".to_string(), + move |_v| { + let calls_for_mutator = calls_for_mutator.clone(); + async move { + let n = { + let mut g = calls_for_mutator.lock().unwrap(); + *g += 1; + *g + }; + if n < 3 { + Err::<String, _>(QueryError::response("transient")) + } else { + Ok::<_, QueryError>("recovered".to_string()) + } + } + }, + cx, + ); + H { mutation: entity } + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let resource = harness.read(cx).mutation.read(cx); + assert_eq!(resource.status(), MutationStatus::Success); + assert_eq!( + resource.retry_count(), + 0, + "an accepted success must reset the retry counter, matching the \ + query family" + ); + }); +} From f5dd467ef8637e58323a2e911669575b627e0dae Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 01:43:17 +0200 Subject: [PATCH 049/111] fix: scope infinite fetch runner docs to hook-driven page changes --- .../src/hook/use_infinite_query/fetch_helpers.rs | 6 ++---- .../src/hook/use_infinite_query/fetch_runners.rs | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs index 4728860..c765fb6 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_helpers.rs @@ -54,10 +54,8 @@ pub fn fetch_previous_page_infinite<T, E, C, FPrev, Fut>( spawn_page_fetch(entity, fetcher, PageDirection::Previous, cx); } -/// Mints the `RequestId` from the bucket's sequencer (so ids from -/// `QueryClient` and the resource's own fallback never collide), begins the -/// fetch, and stores the spawned task so a replacement fetch or entity drop -/// aborts the prior one. +/// Mints the `RequestId` from the bucket sequencer, begins the fetch, and +/// stores the task so a replacement fetch or entity drop aborts it. pub(super) fn spawn_page_fetch<T, E, C, F, Fut>( entity: &Entity<InfiniteQueryResource<T, E>>, fetcher: F, diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs index 49b7433..6338ebb 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs @@ -32,11 +32,11 @@ impl PageDirection { } } -/// The `request_id` comes from `begin_fetch_*` (never re-read after the fetcher), -/// and completion is two-phase so a superseded request can never write; a -/// cancelled or superseded fetch stops retrying after the delay. The cursor is -/// read once: pages cannot change while this request stays current, and once it -/// is superseded the completion is discarded anyway. +/// Two-phase completion so a superseded request never writes; a cancelled or +/// superseded fetch stops retrying after the delay; the cursor is read once, +/// which stays accurate across retries because hook-driven page changes end +/// this request, though a manual `append_page`/`prepend_page` between +/// attempts is not covered and the next retry fetches on the old cursor. pub(super) async fn run_fetch_page_with_id<T, E, F, Fut>( entity: &gpui::WeakEntity<InfiniteQueryResource<T, E>>, fetcher: &F, From 7d96a134a5398c656257228fb358be24981ea9ee Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 02:17:42 +0200 Subject: [PATCH 050/111] refactor: dissolve coverage_gaps duplicates and trim test suite --- .../src/tests/core_cache/cache_ops.rs | 61 --- .../tests/core_cache/request_interactions.rs | 19 + crates/gpui-query/src/tests/core_error/mod.rs | 37 ++ .../core_infinite_query/initial_state.rs | 18 - .../tests/core_infinite_query/page_fetch.rs | 65 +++ .../core_infinite_query/state_transitions.rs | 6 - .../core_lifecycle/data_and_lifecycle.rs | 45 ++ .../tests/core_lifecycle/reset_and_retry.rs | 44 +- .../src/tests/core_mutation/lifecycle.rs | 8 - .../tests/core_policy_types/query_error.rs | 8 + .../tests/core_request/request_lifecycle.rs | 57 ++- .../src/tests/core_request/request_policy.rs | 18 + .../infinite_query_resource_advanced.rs | 19 + .../query_resource_advanced.rs | 40 +- .../src/tests/coverage_gaps/concurrency.rs | 156 ------- .../src/tests/coverage_gaps/gap_tests.rs | 306 ------------ .../src/tests/coverage_gaps/gc_eviction.rs | 193 -------- .../gpui-query/src/tests/coverage_gaps/mod.rs | 5 - .../src/tests/coverage_gaps/property_based.rs | 440 ------------------ .../tests/coverage_gaps/state_transitions.rs | 390 ---------------- .../tests/hook_tests/infinite_query_tests.rs | 130 ------ .../hook_tests/mutation_tests/basic_tests.rs | 39 -- .../mutation_tests/callback_tests.rs | 86 ---- .../hook_tests/query_tests/basic_hooks.rs | 132 +----- .../fetch_and_lifecycle/lifecycle.rs | 75 +-- .../tests/integration_client/client_basics.rs | 158 +------ .../invalidation_reset_gc.rs | 79 ++++ .../client_basics.rs | 14 - .../client_gap_coverage/gc_coverage.rs | 22 +- .../client_gap_coverage/hook_coverage.rs | 53 +-- .../client_mutations.rs | 44 -- .../client_operations/gc_query_operations.rs | 60 --- .../client_operations/persist_with_hydrate.rs | 95 ++++ crates/gpui-query/src/tests/mod.rs | 3 - .../query_key/deterministic_tests.rs | 45 ++ crates/gpui-query/src/tests/test_support.rs | 13 - 36 files changed, 527 insertions(+), 2456 deletions(-) delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/concurrency.rs delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/mod.rs delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/property_based.rs delete mode 100644 crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs diff --git a/crates/gpui-query/src/tests/core_cache/cache_ops.rs b/crates/gpui-query/src/tests/core_cache/cache_ops.rs index 89845ad..00a6562 100644 --- a/crates/gpui-query/src/tests/core_cache/cache_ops.rs +++ b/crates/gpui-query/src/tests/core_cache/cache_ops.rs @@ -2,27 +2,6 @@ use crate::core::*; use crate::tests::core_cache::*; use crate::tests::test_support::*; -#[test] -fn invalidate_clears_last_updated_but_retains_data() { - let mut r = ttl_resource(); - seed_data(&mut r, "cached", STORED_AT_MS); - r.invalidate(); - assert_eq!( - r.data(), - Some(&"cached"), - "data is retained after invalidate" - ); - assert_eq!( - r.last_updated_at_ms(), - None, - "last_updated_at cleared by invalidate" - ); - assert!( - !r.is_cache_fresh(STORED_AT_MS + 1), - "after invalidate, data is not fresh even within TTL" - ); -} - #[test] fn invalidate_then_begin_request_starts_fetch() { let mut r = ttl_resource(); @@ -60,32 +39,6 @@ fn invalidate_then_refetch_refreshes_cache() { assert!(r.is_cache_fresh(completed_at + 200)); } -#[test] -fn reset_clears_data_and_error() { - let mut r = ttl_resource(); - seed_data(&mut r, "data", STORED_AT_MS); - r.apply_failure("something broke", STORED_AT_MS + 100); - assert!(r.data().is_some()); - assert!(r.error().is_some()); - r.reset(); - assert_eq!(r.data(), None); - assert_eq!(r.error(), None); - assert_eq!(r.status(), QueryStatus::Idle); - assert_eq!(r.last_updated_at_ms(), None); -} - -#[test] -fn reset_clears_cache_hits_counter() { - let mut r = ttl_resource(); - seed_data(&mut r, "data", STORED_AT_MS); - let mut seq = test_sequencer(); - let result = r.begin_request(&mut seq, STORED_AT_MS + 500, QueryFetchMode::Normal); - assert_eq!(result, QueryBeginResult::CacheHit); - assert_eq!(r.cache_hits(), 1); - r.reset(); - assert_eq!(r.cache_hits(), 0); -} - #[test] fn reset_clears_all_counters() { let mut r = ttl_resource(); @@ -100,17 +53,3 @@ fn reset_clears_all_counters() { assert_eq!(r.ignored_results(), 0); assert_eq!(r.retry_count(), 0); } - -#[test] -fn reset_preserves_policies_and_key() { - let mut r = QueryResource::new( - "my-key", - CachePolicy::Ttl { ttl_ms: 5_000 }, - RequestPolicy::IgnoreWhileLoading, - ); - seed_data(&mut r, "data", STORED_AT_MS); - r.reset(); - assert_eq!(r.key().first_segment(), "my-key"); - assert_eq!(r.cache_policy(), CachePolicy::Ttl { ttl_ms: 5_000 }); - assert_eq!(r.request_policy(), RequestPolicy::IgnoreWhileLoading); -} diff --git a/crates/gpui-query/src/tests/core_cache/request_interactions.rs b/crates/gpui-query/src/tests/core_cache/request_interactions.rs index 982aa92..b86c1d5 100644 --- a/crates/gpui-query/src/tests/core_cache/request_interactions.rs +++ b/crates/gpui-query/src/tests/core_cache/request_interactions.rs @@ -121,6 +121,25 @@ fn record_cache_hit_does_not_clear_failure_status() { ); } +#[test] +fn record_cache_hit_does_not_clear_cancelled_status() { + let mut r = ttl_resource(); + seed_data(&mut r, "data", STORED_AT_MS); + + let mut seq = test_sequencer(); + let _ = r.begin_request(&mut seq, STORED_AT_MS + 100, QueryFetchMode::Force); + r.cancel(QueryError::cancelled("abort")); + assert_eq!(r.status(), QueryStatus::Cancelled); + + r.record_cache_hit(); + assert_eq!( + r.status(), + QueryStatus::Cancelled, + "cache hit should not clear Cancelled status" + ); + assert_eq!(r.cache_hits(), 1); +} + #[test] fn cache_policy_accessor_roundtrip() { let mut r = ttl_resource(); diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 65daaf5..2160547 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -90,3 +90,40 @@ fn sanitized_redacts_email_local_part_containing_underscore() { assert!(!clean.message().contains("alice")); assert!(clean.message().contains("[REDACTED_EMAIL]")); } + +#[test] +fn sanitized_redacts_mongodb_connection_string() { + let clean = QueryError::transport("connect mongodb://admin:secret@host/db failed").sanitized(); + assert!(clean.message().contains("[REDACTED_CONNECTION]")); + assert!(!clean.message().contains("admin:secret")); +} + +#[test] +fn sanitized_empty_message_stays_empty() { + let clean = QueryError::response("").sanitized(); + assert_eq!(clean.message(), ""); +} + +#[test] +fn sanitized_redacts_secret_straddling_truncation_boundary() { + let prefix = "x".repeat(500); + let secret = "s3cr3tboundaryleak".to_string(); + let msg = format!("{prefix}bearer {secret}"); + assert!(msg.len() > 512, "precondition: message must exceed the cap"); + let clean = QueryError::response(msg.as_str()).sanitized(); + assert!( + !clean.message().contains(&secret), + "truncation must not resurrect a partially-redacted secret" + ); + assert!(clean.message().ends_with("...[truncated]")); +} + +#[test] +fn sanitized_redacts_secret_beyond_truncation_boundary() { + let prefix = "x".repeat(600); + let msg = format!("{prefix}bearer s3cr3tfarpast"); + let clean = QueryError::response(msg.as_str()).sanitized(); + assert!(!clean.message().contains("s3cr3tfarpast")); + assert!(clean.message().ends_with("...[truncated]")); + assert!(clean.message().len() <= 512 + "...[truncated]".len()); +} diff --git a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs index c217699..2580e10 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/initial_state.rs @@ -34,24 +34,6 @@ fn new_resource_has_idle_state_with_empty_pages() { assert!(!r.is_page_data_valid()); } -#[test] -fn empty_pages_state_is_idle() { - let r = make_resource(); - assert!(r.pages().is_empty()); - assert_eq!(r.page_count(), 0); - assert!(!r.has_data()); - assert!(!r.is_page_data_valid()); - assert_eq!(r.status(), QueryStatus::Idle); -} - -#[test] -fn forward_only_defaults_has_next_true() { - let r = make_resource(); - assert_eq!(r.direction(), FetchDirection::ForwardOnly); - assert!(r.has_next_page()); - assert!(!r.has_previous_page()); -} - #[test] fn bidirectional_defaults_both_false() { let r = make_bidirectional_resource(); diff --git a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs index 074e81c..d0b4a11 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs @@ -183,3 +183,68 @@ fn has_more_propagated_on_prepend() { assert!(!r.has_previous_page()); } + +#[test] +fn has_more_true_on_prepend_preserves_has_previous() { + let mut r = make_resource(); + let mut seq = RequestSequencer::new(); + + let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); + r.complete_page_success(id1, vec!["page1"], true, true, 2_000); + + r.set_has_previous_page(true); + + let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); + r.complete_page_success(id2, vec!["page0"], true, false, 4_000); + + assert!( + r.has_previous_page(), + "has_more=true should keep has_previous_page=true" + ); + assert_eq!(r.page_count(), 2); + assert_eq!(r.first_page(), Some(&vec!["page0"])); +} + +#[test] +fn ignore_while_loading_prevents_previous_page_replacement() { + let mut r = InfiniteQueryResource::<Vec<&'static str>>::new( + QueryKey::from("items"), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::IgnoreWhileLoading, + ); + let mut seq = RequestSequencer::new(); + r.set_has_previous_page(true); + + let _id1 = r.begin_fetch_previous(&mut seq, 1_000).unwrap(); + assert!(r.is_fetching_previous_page()); + + let id2 = r.begin_fetch_previous(&mut seq, 2_000); + assert!( + id2.is_none(), + "second begin_fetch_previous should be ignored" + ); + assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); +} + +#[test] +fn ignore_while_loading_allows_cross_direction_fetch() { + let mut r = InfiniteQueryResource::<Vec<&'static str>>::new_bidirectional( + QueryKey::from("items"), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::IgnoreWhileLoading, + ); + let mut seq = RequestSequencer::new(); + r.set_has_next_page(true); + r.set_has_previous_page(true); + + let _id_next = r.begin_fetch_next(&mut seq, 1_000).unwrap(); + assert!(r.is_fetching_next_page()); + + let id_prev = r.begin_fetch_previous(&mut seq, 2_000); + assert!( + id_prev.is_some(), + "cross-direction should succeed under IgnoreWhileLoading" + ); + assert!(r.is_fetching_previous_page()); + assert!(!r.is_fetching_next_page()); +} diff --git a/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs b/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs index 045674e..a837515 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/state_transitions.rs @@ -83,12 +83,6 @@ fn reset_clears_diagnostics() { assert_eq!(r.cache_hits(), 0); } -#[test] -fn is_page_data_valid_false_when_idle() { - let r = make_resource(); - assert!(!r.is_page_data_valid()); -} - #[test] fn is_page_data_valid_true_when_success_with_pages() { let r = load_n_pages(1); diff --git a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs index 5a29bf7..0674082 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs @@ -1,5 +1,6 @@ use crate::core::*; use crate::tests::core_lifecycle::transitions::*; +use crate::tests::test_support::nocache_resource; #[test] fn invalidate_clears_timestamp_but_retains_data_and_active_request() { @@ -93,6 +94,50 @@ fn is_current_request_matches_active() { assert!(r.is_current_request(rid2), "rid2 is current"); } +#[test] +fn complete_success_optional_none_yields_idle_some_yields_success() { + { + let mut r = nocache_resource("optional-none"); + let mut s = seq(); + let (rid, _) = begin(&mut r, &mut s, 100); + let guard = r.accept_current_request(rid).unwrap(); + r.complete_success_optional(guard, None, 200); + assert_eq!(r.status(), QueryStatus::Idle, "None data => Idle"); + assert!(r.data().is_none()); + assert!(r.error().is_none()); + } + + { + let mut r = nocache_resource("optional-some"); + let mut s = seq(); + let (rid, _) = begin(&mut r, &mut s, 100); + let guard = r.accept_current_request(rid).unwrap(); + r.complete_success_optional(guard, Some("data"), 200); + assert_eq!(r.status(), QueryStatus::Success); + assert_eq!(r.data(), Some(&"data")); + } +} + +#[test] +fn is_data_stale_by_status() { + let mut r = nocache_resource("stale-heuristic"); + assert!(!r.is_data_stale(), "no data => not stale"); + + let mut s = seq(); + let (rid, _) = begin(&mut r, &mut s, 100); + r.complete_current_success(rid, "data", 200); + assert!(!r.is_data_stale(), "Success with data => not stale"); + + let _ = begin(&mut r, &mut s, 300); + assert_eq!(r.status(), QueryStatus::LoadingWithData); + assert!(r.is_data_stale(), "LoadingWithData with data => stale"); + + let (rid2, _) = begin(&mut r, &mut s, 400); + r.complete_current_failure_with_data(rid2, "fallback", QueryError::response("err"), 500); + assert_eq!(r.status(), QueryStatus::Failure); + assert!(r.is_data_stale(), "Failure with data => stale"); +} + #[test] fn full_lifecycle_round_trip() { let mut r = resource(); diff --git a/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs b/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs index ab76c91..83903d0 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/reset_and_retry.rs @@ -46,6 +46,39 @@ fn stale_failure_does_not_overwrite_newer_request() { assert!(r.error().is_none()); } +#[test] +fn complete_current_optional_success_rejects_stale_id() { + let mut r = resource(); + let mut s = seq(); + + let (rid1, _) = begin(&mut r, &mut s, 100); + let (rid2, _) = begin(&mut r, &mut s, 200); + + assert!(!r.complete_current_optional_success(rid1, Some("stale"), 300)); + assert_eq!(r.ignored_results(), 1); + + assert!(r.complete_current_optional_success(rid2, Some("fresh"), 300)); + assert_eq!(r.data(), Some(&"fresh")); +} + +#[test] +fn complete_current_failure_with_data_rejects_stale_id() { + let mut r = resource(); + let mut s = seq(); + + let (rid1, _) = begin(&mut r, &mut s, 100); + let (rid2, _) = begin(&mut r, &mut s, 200); + + assert!(!r.complete_current_failure_with_data( + rid1, + "fallback", + QueryError::response("stale"), + 300 + )); + assert_eq!(r.ignored_results(), 1); + assert_eq!(r.active_request_id(), Some(rid2)); +} + #[test] fn reset_from_idle() { let mut r = resource(); @@ -193,14 +226,3 @@ fn retry_counter_increments_and_resets() { r.reset_retry_count(); assert_eq!(r.retry_count(), 0); } - -#[test] -fn reset_clears_retry_count() { - let mut r = resource(); - r.increment_retry(); - r.increment_retry(); - - r.reset(); - - assert_eq!(r.retry_count(), 0); -} diff --git a/crates/gpui-query/src/tests/core_mutation/lifecycle.rs b/crates/gpui-query/src/tests/core_mutation/lifecycle.rs index 05b8324..fd48b6f 100644 --- a/crates/gpui-query/src/tests/core_mutation/lifecycle.rs +++ b/crates/gpui-query/src/tests/core_mutation/lifecycle.rs @@ -181,14 +181,6 @@ fn mutation_without_key() { assert!(m.key().is_none()); } -#[test] -fn status_labels() { - assert_eq!(MutationStatus::Idle.label(), "Idle"); - assert_eq!(MutationStatus::Loading.label(), "Loading"); - assert_eq!(MutationStatus::Success.label(), "Success"); - assert_eq!(MutationStatus::Failure.label(), "Failure"); -} - #[test] fn retry_policy_accessor() { let policy = RetryPolicy::new(5) diff --git a/crates/gpui-query/src/tests/core_policy_types/query_error.rs b/crates/gpui-query/src/tests/core_policy_types/query_error.rs index 1fd7781..d4fa8d9 100644 --- a/crates/gpui-query/src/tests/core_policy_types/query_error.rs +++ b/crates/gpui-query/src/tests/core_policy_types/query_error.rs @@ -7,6 +7,14 @@ fn query_error_kinds() { assert_eq!(QueryError::response("x").kind(), QueryErrorKind::Response); assert_eq!(QueryError::transport("x").kind(), QueryErrorKind::Transport); assert_eq!(QueryError::unknown("x").kind(), QueryErrorKind::Unknown); + assert_eq!( + QueryError::new(QueryErrorKind::Transport, "timeout").kind(), + QueryErrorKind::Transport + ); + assert_eq!( + QueryError::new(QueryErrorKind::Transport, "timeout").message(), + "timeout" + ); } #[test] diff --git a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs index e60ca77..24392ab 100644 --- a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs @@ -32,20 +32,6 @@ fn guard_into_request_id_consumes_guard() { assert_eq!(extracted, rid); } -#[test] -fn accept_current_request_returns_guard_for_active_request() { - let mut resource: QueryResource<&str> = - test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); - let mut seq = test_sequencer(); - - let request_id = begin_request_id(&mut resource, &mut seq, TEST_NOW_MS, QueryFetchMode::Normal); - - let guard = resource - .accept_current_request(request_id) - .expect("should accept the active request"); - assert_eq!(guard.request_id(), request_id); -} - #[test] fn accept_current_request_rejects_stale_request_id() { let mut resource: QueryResource<&str> = @@ -153,3 +139,46 @@ fn begin_request_with_id_none_falls_back_to_transient_sequencer() { assert_eq!(rid.scope_id(), NonZero::new(1).unwrap()); assert_eq!(rid.value(), 1); } + +#[test] +fn begin_request_with_id_swr_ignore_while_loading_keeps_active_request() { + let mut r: QueryResource<&str> = QueryResource::new( + "swr-ignore", + CachePolicy::StaleWhileRevalidate { + ttl_ms: 500, + stale_ms: 1_000, + }, + RequestPolicy::IgnoreWhileLoading, + ); + let mut seq = test_sequencer(); + + r.apply_success("cached", 100); + + let _ = r.begin_request(&mut seq, 1_500, QueryFetchMode::Force); + assert!(r.is_loading()); + + let result = r.begin_request_with_id( + Some(RequestId::scoped(NonZero::new(99).unwrap(), 1)), + 1_500, + QueryFetchMode::Normal, + ); + + match result { + QueryBeginResult::StaleCacheHit { + request_id, + replaced_request_id, + .. + } => { + assert!( + replaced_request_id.is_none(), + "no replacement under IgnoreWhileLoading" + ); + assert_ne!( + request_id, + RequestId::scoped(NonZero::new(99).unwrap(), 1), + "should use existing active request id" + ); + } + other => panic!("expected StaleCacheHit, got {:?}", other), + } +} diff --git a/crates/gpui-query/src/tests/core_request/request_policy.rs b/crates/gpui-query/src/tests/core_request/request_policy.rs index 0d953d3..b8f3f20 100644 --- a/crates/gpui-query/src/tests/core_request/request_policy.rs +++ b/crates/gpui-query/src/tests/core_request/request_policy.rs @@ -84,6 +84,24 @@ fn ignore_while_loading_rejects_new_request_when_loading() { ); } +#[test] +fn ignore_while_loading_rejects_forced_fetch_when_loading() { + let mut resource: QueryResource<&str> = test_resource_with_policies( + "key", + CachePolicy::NoCache, + RequestPolicy::IgnoreWhileLoading, + ); + let mut seq = test_sequencer(); + + let _ = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Normal); + + let result = resource.begin_request(&mut seq, TEST_NOW_MS, QueryFetchMode::Force); + assert!( + matches!(result, QueryBeginResult::IgnoredWhileLoading { .. }), + "Force mode should still respect IgnoreWhileLoading" + ); +} + #[test] fn ignore_while_loading_allows_new_request_after_completion() { let mut resource: QueryResource<&str> = test_resource_with_policies( diff --git a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs index 5779d60..e7912d7 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/infinite_query_resource_advanced.rs @@ -99,6 +99,25 @@ fn infinite_query_set_retry_policy() { assert_eq!(r.retry_policy(), &policy); } +#[test] +fn infinite_query_reset_preserves_retry_policy() { + let mut r = InfiniteQueryResource::<Vec<String>>::new( + QueryKey::from("items"), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::LatestWins, + ); + let policy = RetryPolicy::new(10) + .with_delay(500) + .with_exponential_backoff(); + r.set_retry_policy(policy.clone()); + r.reset(); + assert_eq!( + r.retry_policy(), + &policy, + "retry_policy should survive reset" + ); +} + #[test] fn infinite_query_timestamps_on_lifecycle() { let mut r = InfiniteQueryResource::<Vec<String>>::new( diff --git a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs index ccb73ac..b1e1b84 100644 --- a/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs +++ b/crates/gpui-query/src/tests/core_resource_advanced/query_resource_advanced.rs @@ -33,28 +33,6 @@ fn failure_to_success_recovery_cycle() { assert_eq!(r.previous_data(), Some(&"v1")); } -#[test] -fn cancel_then_fresh_begin_succeeds() { - let mut r = test_resource(); - let mut s = test_sequencer(); - - let _rid = match r.begin_request(&mut s, 100, QueryFetchMode::Normal) { - QueryBeginResult::Started { request_id, .. } => request_id, - _ => panic!("expected Started"), - }; - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.status(), QueryStatus::Cancelled); - - let rid2 = match r.begin_request(&mut s, 200, QueryFetchMode::Normal) { - QueryBeginResult::Started { request_id, .. } => request_id, - _ => panic!("expected Started"), - }; - assert_eq!(r.status(), QueryStatus::LoadingEmpty); - r.complete_current_success(rid2, "recovered", 300); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"recovered")); -} - #[test] fn failure_with_data_then_success_updates_data() { let mut r: QueryResource<&str> = @@ -132,14 +110,11 @@ fn retry_policy_preserved_across_reset() { fn serde_roundtrip_with_data_and_error_state() { let mut r: QueryResource<String, QueryError> = QueryResource::new( "serde-test", - CachePolicy::Ttl { ttl_ms: 5_000 }, + CachePolicy::NoCache, RequestPolicy::LatestWins, ); let mut s = test_sequencer(); - let rid = match r.begin_request(&mut s, 100, QueryFetchMode::Normal) { - QueryBeginResult::Started { request_id, .. } => request_id, - _ => panic!("expected Started"), - }; + let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); r.complete_current_success(rid, "hello".to_string(), 200); let json = serde_json::to_string(&r).unwrap(); @@ -148,8 +123,17 @@ fn serde_roundtrip_with_data_and_error_state() { assert_eq!(back.status(), QueryStatus::Success); assert_eq!(back.data(), Some(&"hello".to_string())); assert_eq!(back.key().first_segment(), "serde-test"); - assert_eq!(back.cache_policy(), CachePolicy::Ttl { ttl_ms: 5_000 }); + assert_eq!(back.cache_policy(), CachePolicy::NoCache); + assert_eq!(back.request_policy(), RequestPolicy::LatestWins); assert!(back.signal().is_none(), "signal is #[serde(skip)]"); + + let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); + r.complete_current_failure(rid2, QueryError::transport("fail"), 400); + let json2 = serde_json::to_string(&r).unwrap(); + let back2: QueryResource<String, QueryError> = serde_json::from_str(&json2).unwrap(); + assert_eq!(back2.status(), QueryStatus::Failure); + assert!(back2.error().is_some()); + assert!(back2.signal().is_none()); } #[test] diff --git a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs b/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs deleted file mode 100644 index 20defcb..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/concurrency.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::core::*; -use crate::tests::test_support::*; - -#[test] -fn two_phase_protocol_accept_then_complete_is_consistent() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - - let guard = r - .accept_current_request(rid) - .expect("should accept current request"); - assert!( - r.active_request_id().is_none(), - "accept clears active_request_id" - ); - - r.complete_success(guard, "result", 200); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"result")); -} - -#[test] -fn two_phase_stale_accept_then_complete_does_not_corrupt() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - - assert!(!complete_success_id(&mut r, rid1, "stale_data", 300)); - assert_eq!(r.ignored_results(), 1); - - assert!(complete_success_id(&mut r, rid2, "fresh_data", 400)); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"fresh_data")); -} - -#[test] -fn concurrent_replacements_increment_cancelled_count() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - assert_eq!(r.cancelled_count(), 0); - let _ = r.begin_request(&mut s, 200, QueryFetchMode::Normal); - assert_eq!(r.cancelled_count(), 1); - let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.cancelled_count(), 2); - let _ = r.begin_request(&mut s, 400, QueryFetchMode::Normal); - assert_eq!(r.cancelled_count(), 3); -} - -#[test] -fn ignore_while_loading_rejects_concurrent_requests() { - let mut r: QueryResource<&str> = QueryResource::new( - "ignore-test", - CachePolicy::NoCache, - RequestPolicy::IgnoreWhileLoading, - ); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - assert_eq!(r.active_request_id(), Some(rid1)); - - let result = r.begin_request(&mut s, 200, QueryFetchMode::Normal); - match result { - QueryBeginResult::IgnoredWhileLoading { active_request_id } => { - assert_eq!(active_request_id, rid1); - } - _ => panic!("expected IgnoredWhileLoading, got {:?}", result), - } - assert_eq!( - r.active_request_id(), - Some(rid1), - "active request should not change" - ); - assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); - - complete_success_id(&mut r, rid1, "data", 300); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"data")); -} - -#[test] -fn signal_cancelled_on_replacement() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - let signal1 = r.signal().unwrap().clone(); - assert!(!signal1.is_cancelled()); - - let _ = r.begin_request(&mut s, 200, QueryFetchMode::Normal); - assert!( - signal1.is_cancelled(), - "old signal should be cancelled on replacement" - ); - let signal2 = r.signal().unwrap().clone(); - assert!( - !signal2.is_cancelled(), - "new signal should not be cancelled" - ); - assert_ne!(signal1, signal2, "signals should be different"); -} - -#[test] -fn signal_cancelled_on_explicit_cancel() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - let signal = r.signal().unwrap().clone(); - assert!(!signal.is_cancelled()); - - r.cancel(QueryError::cancelled("abort")); - assert!( - signal.is_cancelled(), - "signal should be cancelled after explicit cancel" - ); -} - -#[test] -fn signal_cancelled_on_reset() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - let signal = r.signal().unwrap().clone(); - assert!(!signal.is_cancelled()); - - r.reset(); - assert!(signal.is_cancelled(), "signal should be cancelled on reset"); - assert!(r.signal().is_none(), "no signal after reset"); -} - -#[test] -fn is_data_stale_heuristic() { - let mut r = fresh_resource(); - assert!(!r.is_data_stale(), "no data => not stale"); - - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid, "data", 200); - assert!(!r.is_data_stale(), "Success with data => not stale"); - - let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingWithData); - assert!(r.is_data_stale(), "LoadingWithData with data => stale"); - - let rid2 = begin_request_id(&mut r, &mut s, 400, QueryFetchMode::Normal); - r.complete_current_failure_with_data(rid2, "fallback", QueryError::response("err"), 500); - assert_eq!(r.status(), QueryStatus::Failure); - assert!(r.is_data_stale(), "Failure with data => stale"); -} diff --git a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs b/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs deleted file mode 100644 index cf8bab3..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/gap_tests.rs +++ /dev/null @@ -1,306 +0,0 @@ -use crate::core::*; -use crate::tests::test_support::*; -use std::num::NonZero; - -#[test] -fn begin_request_with_id_swr_ignore_while_loading_with_active_request() { - let mut r: QueryResource<&str> = QueryResource::new( - "swr-ignore", - CachePolicy::StaleWhileRevalidate { - ttl_ms: 500, - stale_ms: 1_000, - }, - RequestPolicy::IgnoreWhileLoading, - ); - let mut seq = test_sequencer(); - - r.apply_success("cached", 100); - - let _ = r.begin_request(&mut seq, 1_500, QueryFetchMode::Force); - assert!(r.is_loading()); - - let result = r.begin_request_with_id( - Some(RequestId::scoped(NonZero::new(99).unwrap(), 1)), - 1_500, - QueryFetchMode::Normal, - ); - - match result { - QueryBeginResult::StaleCacheHit { - request_id, - replaced_request_id, - .. - } => { - assert!( - replaced_request_id.is_none(), - "no replacement under IgnoreWhileLoading" - ); - assert_ne!( - request_id, - RequestId::scoped(NonZero::new(99).unwrap(), 1), - "should use existing active request id" - ); - } - other => panic!("expected StaleCacheHit, got {:?}", other), - } -} - -#[test] -fn complete_current_optional_success_rejects_stale_id() { - let mut r = test_resource(); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - - assert!( - !r.complete_current_optional_success(rid1, Some("stale"), 300), - "stale ID should be rejected" - ); - assert_eq!(r.ignored_results(), 1); - - assert!( - r.complete_current_optional_success(rid2, Some("fresh"), 300), - "current ID should be accepted" - ); - assert_eq!(r.data(), Some(&"fresh")); -} - -#[test] -fn complete_current_failure_with_data_rejects_stale_id() { - let mut r = test_resource(); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let _rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - - assert!( - !r.complete_current_failure_with_data(rid1, "fallback", QueryError::response("stale"), 300), - "stale ID should be rejected" - ); - assert_eq!(r.ignored_results(), 1); - - assert!(r.active_request_id().is_some()); -} - -#[test] -fn ignore_while_loading_rejects_forced_fetch_when_loading() { - let mut r: QueryResource<&str> = QueryResource::new( - "test", - CachePolicy::NoCache, - RequestPolicy::IgnoreWhileLoading, - ); - let mut s = test_sequencer(); - - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - - let result = r.begin_request(&mut s, 200, QueryFetchMode::Force); - assert!( - matches!(result, QueryBeginResult::IgnoredWhileLoading { .. }), - "Force mode should still respect IgnoreWhileLoading" - ); -} - -#[test] -fn query_error_sanitized_mongodb_connection() { - let err = QueryError::transport("connect mongodb://admin:secret@host/db failed"); - let clean = err.sanitized(); - assert!( - clean.message().contains("[REDACTED_CONNECTION]"), - "mongodb connection string should be redacted" - ); - assert!( - !clean.message().contains("admin:secret"), - "credentials should be removed" - ); -} - -#[test] -fn query_error_sanitized_empty_message() { - let err = QueryError::response(""); - let clean = err.sanitized(); - assert_eq!(clean.message(), ""); -} - -#[test] -fn query_error_new_with_explicit_kind() { - let err = QueryError::new(QueryErrorKind::Transport, "timeout"); - assert_eq!(err.kind(), QueryErrorKind::Transport); - assert_eq!(err.message(), "timeout"); -} - -#[test] -fn record_cache_hit_does_not_clear_cancelled_status() { - let mut r: QueryResource<&str> = QueryResource::new( - "cache-cancel", - CachePolicy::Ttl { ttl_ms: 1_000 }, - RequestPolicy::LatestWins, - ); - r.apply_success("data", 1_000); - - let mut seq = test_sequencer(); - let _ = r.begin_request(&mut seq, 1_100, QueryFetchMode::Force); - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.status(), QueryStatus::Cancelled); - - r.record_cache_hit(); - assert_eq!( - r.status(), - QueryStatus::Cancelled, - "cache hit should not clear Cancelled status" - ); - assert_eq!(r.cache_hits(), 1); -} - -#[test] -fn join_appends_segment() { - let key = QueryKey::from(["users"]); - let extended = key.join("42"); - assert_eq!(extended.parts().len(), 2); - assert_eq!(extended.to_path(), "users::42"); - assert_eq!(key.parts().len(), 1); -} - -#[test] -fn join_chain_creates_multi_part_key() { - let key = QueryKey::from("users").join("42").join("posts"); - assert_eq!(key.parts().len(), 3); - assert_eq!(key.to_path(), "users::42::posts"); -} - -#[test] -fn from_vec_string() { - let key = QueryKey::from(vec!["users".to_string(), "42".to_string()]); - assert_eq!(key.parts().len(), 2); - assert_eq!(key.to_path(), "users::42"); -} - -#[test] -fn deref_allows_indexing() { - let key = QueryKey::from(["a", "b", "c"]); - assert_eq!(&*key[0], "a"); - assert_eq!(&*key[2], "c"); - assert_eq!(key.len(), 3); -} - -#[test] -fn serde_deserialize_single_string() { - let json = "\"users\""; - let key: QueryKey = serde_json::from_str(json).unwrap(); - assert_eq!(key.parts().len(), 1); - assert_eq!(key.first_segment(), "users"); -} - -#[test] -fn hash_consistency() { - use std::collections::HashSet; - let k1 = QueryKey::from(["users", "42"]); - let k2 = QueryKey::from(["users", "42"]); - let k3 = QueryKey::from(["users", "43"]); - let mut set = HashSet::new(); - set.insert(k1.clone()); - assert!(set.contains(&k2), "equal keys must have equal hashes"); - assert!(!set.contains(&k3), "different keys should not match"); -} - -#[test] -fn ignore_while_loading_prevents_previous_page_replacement() { - let mut r = InfiniteQueryResource::<Vec<String>>::new( - QueryKey::from("items"), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::IgnoreWhileLoading, - ); - let mut seq = RequestSequencer::new(); - r.set_has_previous_page(true); - - let _id1 = r.begin_fetch_previous(&mut seq, 1_000).unwrap(); - assert!(r.is_fetching_previous_page()); - - let id2 = r.begin_fetch_previous(&mut seq, 2_000); - assert!( - id2.is_none(), - "second begin_fetch_previous should be ignored" - ); - assert_eq!(r.cancelled_count(), 0, "no cancellation on ignore"); -} - -#[test] -fn ignore_while_loading_cross_direction_next_then_prev() { - let mut r = InfiniteQueryResource::<Vec<String>>::new_bidirectional( - QueryKey::from("items"), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::IgnoreWhileLoading, - ); - let mut seq = RequestSequencer::new(); - r.set_has_next_page(true); - r.set_has_previous_page(true); - - let _id_next = r.begin_fetch_next(&mut seq, 1_000).unwrap(); - assert!(r.is_fetching_next_page()); - - let id_prev = r.begin_fetch_previous(&mut seq, 2_000); - assert!( - id_prev.is_some(), - "cross-direction should succeed under IgnoreWhileLoading" - ); - assert!(r.is_fetching_previous_page()); - assert!(!r.is_fetching_next_page()); -} - -#[test] -fn infinite_query_reset_preserves_retry_policy() { - let mut r = InfiniteQueryResource::<Vec<String>>::new( - QueryKey::from("items"), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - ); - let policy = RetryPolicy::new(10) - .with_delay(500) - .with_exponential_backoff(); - r.set_retry_policy(policy.clone()); - r.reset(); - assert_eq!( - r.retry_policy(), - &policy, - "retry_policy should survive reset" - ); -} - -#[test] -fn bidirectional_resource_initial_accessors() { - let r = InfiniteQueryResource::<Vec<String>>::new_bidirectional( - QueryKey::from("items"), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - ); - assert_eq!(r.cache_policy(), CachePolicy::Ttl { ttl_ms: 60_000 }); - assert_eq!(r.request_policy(), RequestPolicy::LatestWins); - assert_eq!(r.direction(), FetchDirection::Bidirectional); - assert!(!r.has_next_page()); - assert!(!r.has_previous_page()); -} - -#[test] -fn prepend_with_has_more_true_preserves_has_previous() { - let mut r = InfiniteQueryResource::<Vec<String>>::new( - QueryKey::from("items"), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - ); - let mut seq = RequestSequencer::new(); - - let id1 = r.begin_fetch_next(&mut seq, 1_000).unwrap(); - r.complete_page_success(id1, vec!["page1".to_string()], true, true, 2_000); - - r.set_has_previous_page(true); - - let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); - r.complete_page_success(id2, vec!["page0".to_string()], true, false, 4_000); - - assert!( - r.has_previous_page(), - "has_more=true should keep has_previous_page=true" - ); - assert_eq!(r.page_count(), 2); - assert_eq!(r.first_page(), Some(&vec!["page0".to_string()])); -} diff --git a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs b/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs deleted file mode 100644 index 8a2f8c2..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/gc_eviction.rs +++ /dev/null @@ -1,193 +0,0 @@ -use crate::client::QueryClient; -use crate::core::*; -use crate::tests::test_support::*; -use gpui::{BorrowAppContext as _, TestAppContext}; - -fn create_success_at_time( - client: &mut QueryClient, - cx: &mut gpui::App, - key: &str, - data: &str, - success_time_ms: u64, -) { - let entity = client.resource_with_policies::<String, QueryError>( - QueryKey::from(key), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - cx, - ); - entity.update(cx, |r, _| { - r.apply_success(data.to_string(), success_time_ms) - }); -} - -#[gpui::test] -fn test_gc_evicts_exactly_expired_resources(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - create_success_at_time(client, cx, "young", "young_data", 2_000); - create_success_at_time(client, cx, "middle", "middle_data", 1_000); - create_success_at_time(client, cx, "old", "old_data", 100); - - assert_eq!(client.all_queries::<String, QueryError>().len(), 3); - - client.gc_with_time(2_500, cx); - - assert_eq!( - client.all_queries::<String, QueryError>().len(), - 2, - "exactly 1 of 3 resources should be evicted" - ); - assert!( - client - .query::<String, QueryError>(&QueryKey::from("young")) - .is_some(), - "young (age 500ms) should survive" - ); - assert!( - client - .query::<String, QueryError>(&QueryKey::from("middle")) - .is_some(), - "middle (age 1500ms) should survive" - ); - assert!( - client - .query::<String, QueryError>(&QueryKey::from("old")) - .is_none(), - "old (age 2400ms > success_threshold 2000ms) should be evicted" - ); - }); - }); -} - -#[gpui::test] -fn test_gc_eviction_counts_match(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - for i in 0..5 { - let _ = client.resource::<String, QueryError>(format!("idle_{}", i), cx); - } - assert_eq!(client.all_queries::<String, QueryError>().len(), 5); - - client.gc_with_time(5_000, cx); - - assert_eq!( - client.all_queries::<String, QueryError>().len(), - 0, - "all 5 idle resources with no snapshot should be evicted" - ); - }); - }); -} - -#[gpui::test] -fn test_gc_preserves_loading_resource_with_snapshot(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let key = QueryKey::from("loading_preserved"); - let prepared = client - .prepare_fetch_query::<String, QueryError>(key.clone(), cx) - .expect("should start"); - - client.gc_with_time(1_000_000, cx); - - let entity = client - .query::<String, QueryError>(&key) - .expect("loading resource must survive GC"); - - prepared.complete_success("data".to_string(), cx); - assert_eq!( - entity.read(cx).data(), - Some(&"data".to_string()), - "entity should be usable after surviving GC" - ); - }); - }); -} - -#[gpui::test] -fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let prepared = client - .prepare_fetch_query::<String, QueryError>("loading", cx) - .expect("should start"); - - create_success_at_time(client, cx, "success_fresh", "data", 1_000); - - create_success_at_time(client, cx, "success_old", "data", 0); - - assert_eq!(client.all_queries::<String, QueryError>().len(), 3); - - client.gc_with_time(2_500, cx); - - let remaining = client.all_queries::<String, QueryError>(); - assert_eq!( - remaining.len(), - 2, - "exactly 1 of 3 resources should be evicted" - ); - - let remaining_keys: Vec<String> = remaining - .iter() - .map(|e| e.read(cx).key().to_path()) - .collect(); - assert!( - remaining_keys.contains(&"loading".to_string()), - "loading should survive: {:?}", - remaining_keys - ); - assert!( - remaining_keys.contains(&"success_fresh".to_string()), - "success_fresh should survive: {:?}", - remaining_keys - ); - - prepared.complete_success("data".to_string(), cx); - }); - }); -} - -#[gpui::test] -fn test_gc_survive_then_evict_after_threshold_crossed(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let key = QueryKey::from("aged"); - create_success_at_time(client, cx, "aged", "data", 1_000); - - client.gc_with_time(2_000, cx); - assert!( - client.query::<String, QueryError>(&key).is_some(), - "age=1000ms < success_threshold=2000ms => should survive" - ); - - client.gc_with_time(3_500, cx); - assert!( - client.query::<String, QueryError>(&key).is_none(), - "age=2500ms > success_threshold=2000ms => should be evicted" - ); - }); - }); -} - -#[gpui::test] -fn test_gc_boundary_success_threshold_exact(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let key = QueryKey::from("boundary"); - create_success_at_time(client, cx, "boundary", "data", 1_000); - - client.gc_with_time(3_000, cx); - assert!( - client.query::<String, QueryError>(&key).is_none(), - "age=2000ms == success_threshold=2000ms => must be evicted (>= boundary)" - ); - }); - }); -} diff --git a/crates/gpui-query/src/tests/coverage_gaps/mod.rs b/crates/gpui-query/src/tests/coverage_gaps/mod.rs deleted file mode 100644 index d94cb45..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod concurrency; -mod gap_tests; -mod gc_eviction; -mod property_based; -mod state_transitions; diff --git a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs b/crates/gpui-query/src/tests/coverage_gaps/property_based.rs deleted file mode 100644 index e5f428d..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/property_based.rs +++ /dev/null @@ -1,440 +0,0 @@ -use crate::core::*; -use crate::tests::test_support::*; -use std::num::NonZero; - -#[test] -fn prop_retry_delay_never_exceeds_absolute_max_for_all_attempts() { - const ABSOLUTE_MAX: u64 = 3_600_000; - - let base_delays: &[u64] = &[ - 0, - 1, - 10, - 100, - 1_000, - 10_000, - 100_000, - 1_000_000, - u64::MAX / 2, - u64::MAX, - ]; - let max_delays: &[u64] = &[0, 100, 1_000, 30_000, ABSOLUTE_MAX, u64::MAX]; - - for &base in base_delays { - for &max_delay in max_delays { - let policy = RetryPolicy { - max_retries: 100, - retry_delay_ms: base, - exponential_backoff: true, - max_retry_delay_ms: max_delay, - }; - for attempt in 0..=100u32 { - let delay = policy.delay_for_attempt(attempt); - assert!( - delay <= ABSOLUTE_MAX, - "delay_for_attempt({}) = {} exceeds ABSOLUTE_MAX ({}) \ - with base={}, max_delay={}", - attempt, - delay, - ABSOLUTE_MAX, - base, - max_delay - ); - } - for attempt in [u32::MAX, 200, 500, 1000] { - let delay = policy.delay_for_attempt(attempt); - assert!( - delay <= ABSOLUTE_MAX, - "delay_for_attempt({}) = {} exceeds ABSOLUTE_MAX ({}) \ - with base={}, max_delay={}", - attempt, - delay, - ABSOLUTE_MAX, - base, - max_delay - ); - } - } - } -} - -#[test] -fn prop_retry_delay_without_backoff_is_constant() { - let delays: &[u64] = &[0, 1, 100, 1_000, 30_000, u64::MAX]; - for &base in delays { - let policy = RetryPolicy { - max_retries: 10, - retry_delay_ms: base, - exponential_backoff: false, - max_retry_delay_ms: 0, - }; - for attempt in 0..=50u32 { - assert_eq!( - policy.delay_for_attempt(attempt), - base, - "without backoff, delay should be constant for attempt {}", - attempt - ); - } - } -} - -#[test] -fn prop_retry_delay_monotonically_increases_or_capped() { - let policy = RetryPolicy::new(100) - .with_delay(100) - .with_exponential_backoff() - .with_max_delay(30_000); - let mut prev_delay: u64 = 0; - for attempt in 0..=50u32 { - let delay = policy.delay_for_attempt(attempt); - assert!( - delay >= prev_delay, - "delay decreased from {} to {} at attempt {}", - prev_delay, - delay, - attempt - ); - prev_delay = delay; - } -} - -#[test] -fn prop_cache_policy_fresh_and_expired_are_complementary_for_ttl() { - let ttl_values: &[u64] = &[1, 10, 100, 1_000, 60_000, u64::MAX]; - for &ttl in ttl_values { - let policy = CachePolicy::Ttl { ttl_ms: ttl }; - let total = policy - .total_valid_ms() - .expect("Ttl should have total_valid_ms"); - assert_eq!(total, ttl); - - let ages: &[u64] = &[ - 0, - ttl / 2, - ttl, - ttl.saturating_add(1), - ttl.saturating_mul(2), - ]; - for &age in ages { - let is_fresh = policy.is_fresh(age); - let is_expired = policy.is_expired(age); - - if age <= ttl { - assert!(is_fresh, "age {} <= ttl {} should be fresh", age, ttl); - assert!(!is_expired, "fresh age {} should not be expired", age); - } else { - assert!(!is_fresh, "age {} > ttl {} should not be fresh", age, ttl); - assert!(is_expired, "age {} > ttl {} should be expired", age, ttl); - } - } - } -} - -#[test] -fn prop_cache_policy_swr_three_way_partition() { - let cases: &[(u64, u64)] = &[ - (1, 1), - (10, 10), - (100, 200), - (1_000, 2_000), - (60_000, 30_000), - ]; - for &(ttl, stale) in cases { - let policy = CachePolicy::StaleWhileRevalidate { - ttl_ms: ttl, - stale_ms: stale, - }; - let total = ttl.saturating_add(stale); - - let ages: &[u64] = &[ - 0, - ttl / 2, - ttl, - ttl + 1, - total / 2 + ttl / 2, - total, - total + 1, - total.saturating_mul(2), - ]; - for &age in ages { - let is_fresh = policy.is_fresh(age); - let is_stale = policy.is_stale_but_serveable(age); - let is_expired = policy.is_expired(age); - - let count = is_fresh as u8 + is_stale as u8 + is_expired as u8; - assert_eq!( - count, 1, - "age {} must be exactly one of fresh/stale/expired \ - (fresh={}, stale={}, expired={}) for ttl={} stale={}", - age, is_fresh, is_stale, is_expired, ttl, stale - ); - - if age <= ttl { - assert!(is_fresh, "age {} <= ttl {} must be fresh", age, ttl); - } else if age <= total { - assert!( - is_stale, - "age {} must be stale-but-serveable (ttl={}, total={})", - age, ttl, total - ); - } else { - assert!(is_expired, "age {} > total {} must be expired", age, total); - } - } - } -} - -#[test] -fn prop_cache_policy_nocache_always_expired_never_fresh() { - let policy = CachePolicy::NoCache; - assert_eq!(policy.total_valid_ms(), None); - assert_eq!(policy.ttl_ms(), None); - - for age in [0u64, 1, 100, 1_000, u64::MAX] { - assert!( - !policy.is_fresh(age), - "NoCache should never be fresh at age {}", - age - ); - assert!( - policy.is_expired(age), - "NoCache should always be expired at age {}", - age - ); - assert!( - !policy.is_stale_but_serveable(age), - "NoCache should never be stale-but-serveable" - ); - } -} - -#[test] -fn prop_cache_policy_total_valid_ms_consistency() { - let cases: &[CachePolicy] = &[ - CachePolicy::NoCache, - CachePolicy::Ttl { ttl_ms: 1 }, - CachePolicy::Ttl { ttl_ms: 60_000 }, - CachePolicy::Ttl { ttl_ms: u64::MAX }, - CachePolicy::StaleWhileRevalidate { - ttl_ms: 100, - stale_ms: 50, - }, - CachePolicy::StaleWhileRevalidate { - ttl_ms: u64::MAX, - stale_ms: u64::MAX, - }, - ]; - for policy in cases { - match policy { - CachePolicy::NoCache => { - assert_eq!(policy.total_valid_ms(), None); - assert_eq!(policy.ttl_ms(), None); - assert_eq!(policy.stale_ms(), None); - } - CachePolicy::Ttl { ttl_ms } => { - assert_eq!(policy.total_valid_ms(), Some(*ttl_ms)); - assert_eq!(policy.ttl_ms(), Some(*ttl_ms)); - assert_eq!(policy.stale_ms(), None); - } - CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms } => { - let expected = ttl_ms.saturating_add(*stale_ms); - assert_eq!(policy.total_valid_ms(), Some(expected)); - assert_eq!(policy.ttl_ms(), Some(*ttl_ms)); - assert_eq!(policy.stale_ms(), Some(*stale_ms)); - } - } - } -} - -#[test] -fn prop_serde_roundtrip_all_statuses() { - assert_serde_roundtrip(&[ - QueryStatus::Idle, - QueryStatus::LoadingEmpty, - QueryStatus::LoadingWithData, - QueryStatus::Success, - QueryStatus::Failure, - QueryStatus::Cancelled, - ]); -} - -#[test] -fn prop_serde_roundtrip_all_cache_policies() { - assert_serde_roundtrip(&[ - CachePolicy::NoCache, - CachePolicy::Ttl { ttl_ms: 0 }, - CachePolicy::Ttl { ttl_ms: 1 }, - CachePolicy::Ttl { ttl_ms: 60_000 }, - CachePolicy::Ttl { ttl_ms: u64::MAX }, - CachePolicy::StaleWhileRevalidate { - ttl_ms: 0, - stale_ms: 0, - }, - CachePolicy::StaleWhileRevalidate { - ttl_ms: 100, - stale_ms: 200, - }, - CachePolicy::StaleWhileRevalidate { - ttl_ms: u64::MAX, - stale_ms: u64::MAX, - }, - ]); -} - -#[test] -fn prop_serde_roundtrip_all_request_policies() { - assert_serde_roundtrip(&[RequestPolicy::LatestWins, RequestPolicy::IgnoreWhileLoading]); -} - -#[test] -fn prop_serde_roundtrip_retry_policies() { - assert_serde_roundtrip(&[ - RetryPolicy::no_retries(), - RetryPolicy::default(), - RetryPolicy::new(0), - RetryPolicy::new(100), - RetryPolicy::new(5) - .with_delay(0) - .with_exponential_backoff() - .with_max_delay(0), - RetryPolicy::new(u32::MAX) - .with_delay(u64::MAX) - .with_exponential_backoff() - .with_max_delay(u64::MAX), - ]); -} - -#[test] -fn prop_serde_roundtrip_query_error_all_kinds() { - assert_serde_roundtrip(&[ - QueryError::cancelled("abort"), - QueryError::response("not found"), - QueryError::transport("timeout"), - QueryError::unknown("mystery"), - QueryError::new(QueryErrorKind::Cancelled, ""), - QueryError::new(QueryErrorKind::Response, "a".repeat(1000)), - ]); -} - -#[test] -fn prop_serde_roundtrip_query_resource_multiple_states() { - let mut r: QueryResource<String, QueryError> = QueryResource::new( - "serde-test", - CachePolicy::NoCache, - RequestPolicy::LatestWins, - ); - let mut s = test_sequencer(); - - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - - r.complete_current_success(rid, "hello".to_string(), 200); - - let json = serde_json::to_string(&r).unwrap(); - let back: QueryResource<String, QueryError> = serde_json::from_str(&json).unwrap(); - assert_eq!(back.status(), QueryStatus::Success); - assert_eq!(back.data(), Some(&"hello".to_string())); - assert_eq!(back.cache_policy(), CachePolicy::NoCache); - assert_eq!(back.request_policy(), RequestPolicy::LatestWins); - assert!(back.signal().is_none(), "signal is #[serde(skip)]"); - - let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); - r.complete_current_failure(rid2, QueryError::transport("fail"), 400); - let json2 = serde_json::to_string(&r).unwrap(); - let back2: QueryResource<String, QueryError> = serde_json::from_str(&json2).unwrap(); - assert_eq!(back2.status(), QueryStatus::Failure); - assert!(back2.error().is_some()); - assert!(back2.signal().is_none()); -} - -#[test] -fn prop_request_sequencer_monotonic_within_scope() { - let mut seq = RequestSequencer::new(); - let mut prev = seq.next_request(); - for _ in 0..1000 { - let curr = seq.next_request(); - assert!( - curr > prev, - "RequestIds must be monotonically increasing: {:?} <= {:?}", - prev, - curr - ); - prev = curr; - } -} - -#[test] -fn prop_request_sequencer_scope_advance_preserves_monotonicity() { - let mut seq = RequestSequencer { - scope_id: NonZero::new(1).unwrap(), - next_request_id: u64::MAX - 5, - }; - let mut prev = seq.next_request(); - for i in 0..20 { - let curr = seq.next_request(); - assert!( - curr > prev, - "monotonicity broken at iteration {}: {:?} <= {:?}", - i, - prev, - curr - ); - prev = curr; - } - assert!( - seq.scope_id.get() >= 2, - "scope should have advanced past overflow" - ); -} - -#[test] -fn prop_request_sequencer_uniqueness_across_many_ids() { - use std::collections::HashSet; - let mut seq = RequestSequencer::new(); - let mut seen = HashSet::new(); - for _ in 0..10_000 { - let id = seq.next_request(); - assert!(seen.insert(id), "duplicate RequestId generated: {:?}", id); - } -} - -#[test] -fn prop_request_sequencer_two_sequencers_no_collision() { - let mut seq1 = RequestSequencer::new(); - let mut seq2 = RequestSequencer::new(); - let id1_first = seq1.next_request(); - let id2_first = seq2.next_request(); - assert_eq!(id1_first, id2_first, "both start at 1:1"); - - let id1_second = seq1.next_request(); - assert_ne!( - id1_second, id2_first, - "advanced id should differ from initial" - ); - - let mut seq3 = RequestSequencer { - scope_id: NonZero::new(2).unwrap(), - next_request_id: 1, - }; - let id3 = seq3.next_request(); - assert_ne!(id3.scope_id(), id1_first.scope_id(), "different scopes"); -} - -#[test] -fn prop_request_sequencer_double_overflow_wraps_correctly() { - let mut seq = RequestSequencer { - scope_id: NonZero::new(u64::MAX).unwrap(), - next_request_id: u64::MAX, - }; - let id_before = seq.next_request(); - assert_eq!(id_before.scope_id(), NonZero::new(u64::MAX).unwrap()); - assert_eq!(id_before.value(), u64::MAX); - - let id_after = seq.next_request(); - assert!( - id_after.scope_id() <= NonZero::new(2).unwrap(), - "scope should wrap after u64::MAX: got {}", - id_after.scope_id() - ); - assert_ne!(id_before, id_after, "ids must differ across scope wrap"); -} diff --git a/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs b/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs deleted file mode 100644 index 241f458..0000000 --- a/crates/gpui-query/src/tests/coverage_gaps/state_transitions.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::core::*; -use crate::tests::test_support::*; - -#[test] -fn invariant_initial_state_is_consistent() { - let r = fresh_resource(); - assert_eq!(r.status(), QueryStatus::Idle); - assert!(r.data().is_none(), "Idle => data must be None"); - assert!(r.error().is_none(), "Idle => error must be None"); - assert!(r.active_request_id().is_none()); -} - -#[test] -fn invariant_after_begin_loading_empty() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingEmpty); - assert!(r.data().is_none(), "LoadingEmpty => data must be None"); - assert!(r.error().is_none(), "begin_request clears error"); - assert!(r.active_request_id().is_some()); - assert!(r.signal().is_some()); -} - -#[test] -fn invariant_after_begin_loading_with_data() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "data1", 200); - - let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingWithData); - assert!( - r.data().is_some(), - "LoadingWithData => data should still be present" - ); - assert_eq!(r.data(), Some(&"data1"), "data preserved during refetch"); - assert!(r.error().is_none(), "begin_request clears error"); - assert_eq!(r.active_request_id(), Some(rid2)); - - r.complete_current_success(rid2, "data2", 400); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"data2")); - assert_eq!(r.previous_data(), Some(&"data1")); -} - -#[test] -fn invariant_after_complete_success() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid, "result", 200); - - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"result"), "Success => data must be Some"); - assert!(r.error().is_none(), "Success => error must be None"); - assert!( - r.active_request_id().is_none(), - "completed => no active request" - ); - assert!(r.signal().is_some()); -} - -#[test] -fn invariant_after_complete_failure() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_failure(rid, QueryError::response("fail"), 200); - - assert_eq!(r.status(), QueryStatus::Failure); - assert!( - r.data().is_none(), - "Failure from LoadingEmpty => data must be None" - ); - assert!(r.error().is_some(), "Failure => error must be Some"); - assert!( - r.active_request_id().is_none(), - "completed => no active request" - ); -} - -#[test] -fn invariant_after_complete_failure_from_loading_with_data() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "original", 200); - - let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); - r.complete_current_failure(rid2, QueryError::transport("timeout"), 400); - - assert_eq!(r.status(), QueryStatus::Failure); - assert!(r.error().is_some(), "Failure => error must be Some"); - assert!(r.active_request_id().is_none()); - assert_eq!( - r.data(), - Some(&"original"), - "apply_failure retains data in-place" - ); - assert!( - r.previous_data().is_none(), - "apply_failure does NOT set previous_data" - ); -} - -#[test] -fn invariant_after_cancel_from_loading_empty() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - - let cancelled = r.cancel(QueryError::cancelled("abort")); - assert!( - cancelled, - "cancel should return true when request is active" - ); - assert_eq!(r.status(), QueryStatus::Cancelled); - assert!( - r.data().is_none(), - "Cancelled from LoadingEmpty => data must be None" - ); - assert!(r.error().is_some(), "Cancelled => error must be Some"); - assert!( - r.active_request_id().is_none(), - "cancelled => no active request" - ); -} - -#[test] -fn invariant_after_cancel_from_loading_with_data() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "data", 200); - - let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingWithData); - assert_eq!(r.data(), Some(&"data")); - - let cancelled = r.cancel(QueryError::cancelled("abort")); - assert!(cancelled); - assert_eq!(r.status(), QueryStatus::Cancelled); - assert!( - r.data().is_none(), - "cancel clears data (saved to previous_data)" - ); - assert!(r.error().is_some()); - assert_eq!( - r.previous_data(), - Some(&"data"), - "cancel saves data to previous_data for rollback" - ); -} - -#[test] -fn invariant_cancel_returns_false_when_no_active_request() { - let mut r = fresh_resource(); - assert!(!r.cancel(QueryError::cancelled("noop"))); - assert_eq!(r.status(), QueryStatus::Idle); -} - -#[test] -fn invariant_after_reset() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid, "data", 200); - r.increment_retry(); - - r.reset(); - - assert_eq!(r.status(), QueryStatus::Idle); - assert!(r.data().is_none(), "reset clears data"); - assert!(r.error().is_none(), "reset clears error"); - assert!(r.active_request_id().is_none()); - assert!(r.signal().is_none()); - assert!(r.previous_data().is_none()); - assert_eq!(r.cache_hits(), 0); - assert_eq!(r.cancelled_count(), 0); - assert_eq!(r.ignored_results(), 0); - assert_eq!(r.retry_count(), 0); - assert_eq!(r.cache_policy(), CachePolicy::NoCache); - assert_eq!(r.request_policy(), RequestPolicy::LatestWins); -} - -#[test] -fn invariant_complete_success_optional_none_yields_idle() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let guard = r.accept_current_request(rid).unwrap(); - r.complete_success_optional(guard, None, 200); - - assert_eq!( - r.status(), - QueryStatus::Idle, - "None data => Idle (not Success)" - ); - assert!(r.data().is_none(), "Idle => data must be None"); - assert!(r.error().is_none()); -} - -#[test] -fn invariant_complete_success_optional_some_yields_success() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let guard = r.accept_current_request(rid).unwrap(); - r.complete_success_optional(guard, Some("data"), 200); - - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"data")); -} - -#[test] -fn invariant_complete_failure_with_data() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let guard = r.accept_current_request(rid).unwrap(); - r.complete_failure_with_data(guard, "fallback", QueryError::response("partial"), 200); - - assert_eq!(r.status(), QueryStatus::Failure); - assert_eq!( - r.data(), - Some(&"fallback"), - "Failure with data => data must be Some" - ); - assert!(r.error().is_some(), "Failure => error must be Some"); -} - -#[test] -fn invariant_stale_accept_rejected() { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - let rid2 = begin_request_id(&mut r, &mut s, 200, QueryFetchMode::Normal); - assert!( - r.accept_current_request(rid1).is_none(), - "stale request should be rejected" - ); - assert_eq!(r.ignored_results(), 1); - - assert!( - r.accept_current_request(rid2).is_some(), - "current request should be accepted" - ); -} - -#[test] -fn table_driven_all_transitions_from_idle() { - let mut r = fresh_resource(); - assert_eq!(r.status(), QueryStatus::Idle); - - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingEmpty); - assert!(r.data().is_none()); - - r.complete_current_success(rid, "data", 200); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"data")); - assert!(r.error().is_none()); - - let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingWithData); - assert_eq!(r.data(), Some(&"data"), "LoadingWithData preserves data"); - - r.complete_current_failure(rid2, QueryError::response("fail"), 400); - assert_eq!(r.status(), QueryStatus::Failure); - assert_eq!( - r.data(), - Some(&"data"), - "apply_failure retains data in-place" - ); - assert!( - r.previous_data().is_none(), - "apply_failure does NOT set previous_data" - ); - - let _rid3 = begin_request_id(&mut r, &mut s, 500, QueryFetchMode::Normal); - assert_eq!( - r.status(), - QueryStatus::LoadingWithData, - "data present => LoadingWithData even after Failure" - ); - assert!(r.error().is_none(), "begin_request clears error"); - - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.status(), QueryStatus::Cancelled); - assert!(r.data().is_none()); - assert!(r.error().is_some()); - - r.reset(); - assert_eq!(r.status(), QueryStatus::Idle); - assert!(r.data().is_none()); - assert!(r.error().is_none()); -} - -#[test] -fn table_driven_cancel_from_every_loading_state() { - { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let _ = r.begin_request(&mut s, 100, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingEmpty); - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.status(), QueryStatus::Cancelled); - assert!(r.data().is_none()); - assert!(r.error().is_some()); - } - - { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid, "data", 200); - let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); - assert_eq!(r.status(), QueryStatus::LoadingWithData); - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.status(), QueryStatus::Cancelled); - assert!( - r.data().is_none(), - "cancel clears data (saves to previous_data)" - ); - assert_eq!(r.previous_data(), Some(&"data")); - } -} - -#[test] -fn table_driven_rollback_from_every_state() { - - { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "v1", 200); - let rid2 = begin_request_id(&mut r, &mut s, 300, QueryFetchMode::Normal); - r.complete_current_success(rid2, "v2", 400); - assert_eq!(r.previous_data(), Some(&"v1")); - - let rolled_back = r.rollback_to_previous(); - assert!(rolled_back); - assert_eq!(r.status(), QueryStatus::Success, "rollback sets Success"); - assert_eq!(r.data(), Some(&"v1"), "rollback restores previous data"); - } - - { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "v1", 200); - let _ = r.begin_request(&mut s, 300, QueryFetchMode::Normal); - r.cancel(QueryError::cancelled("abort")); - assert_eq!(r.previous_data(), Some(&"v1")); - - let rolled_back = r.rollback_to_previous(); - assert!(rolled_back); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"v1")); - } - - { - let mut r = fresh_resource(); - let mut s = test_sequencer(); - let rid1 = begin_request_id(&mut r, &mut s, 100, QueryFetchMode::Normal); - r.complete_current_success(rid1, "v1", 200); - r.set_data("v2_optimistic"); - assert_eq!(r.data(), Some(&"v2_optimistic")); - assert_eq!(r.previous_data(), Some(&"v1")); - - let rolled_back = r.rollback_to_previous(); - assert!(rolled_back); - assert_eq!(r.status(), QueryStatus::Success); - assert_eq!(r.data(), Some(&"v1")); - } - - { - let mut r = fresh_resource(); - assert!( - !r.rollback_to_previous(), - "no previous_data => rollback fails" - ); - assert_eq!(r.status(), QueryStatus::Idle); - } -} diff --git a/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs b/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs index ba62e34..222d443 100644 --- a/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/infinite_query_tests.rs @@ -103,51 +103,6 @@ fn test_fetch_next_page_appends_page(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_fetch_next_page_while_fetching(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<InfiniteQueryResource<Vec<i32>, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_infinite_query( - InfiniteQueryOptions::new("next-while-fetching") - .cache_policy(CachePolicy::Ttl { ttl_ms: 0 }), - |_last_page| async move { Ok::<_, QueryError>((vec![1], true)) }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).pages().len(), 1); - }); - - harness.update(cx, |this, cx| { - fetch_next_page_infinite( - &this.entity, - |_last_page| async move { Ok::<_, QueryError>((vec![2], false)) }, - cx, - ); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - let resource = harness.read(cx).entity.read(cx); - assert_eq!(resource.status(), QueryStatus::Success); - let pages = resource.pages(); - assert_eq!(pages.len(), 2, "should have first page + one next page"); - assert_eq!(pages[0].as_ref(), &vec![1]); - assert_eq!(pages[1].as_ref(), &vec![2]); - assert!(!resource.has_next_page()); - }); -} - #[gpui::test] fn test_fetch_previous_page_prepends_page(cx: &mut TestAppContext) { setup_query_client(cx); @@ -267,91 +222,6 @@ fn test_infinite_query_max_pages_enforcement(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_fetch_next_page_infinite_direct_call(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<InfiniteQueryResource<Vec<&'static str>, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_infinite_query( - InfiniteQueryOptions::new("direct-next").cache_policy(CachePolicy::Ttl { ttl_ms: 0 }), - |_lp| async move { Ok::<_, QueryError>((vec!["p1"], true)) }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - harness.update(cx, |this, cx| { - fetch_next_page_infinite( - &this.entity, - |_lp| async move { Ok::<_, QueryError>((vec!["p2"], false)) }, - cx, - ); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - let resource = harness.read(cx).entity.read(cx); - assert_eq!(resource.pages().len(), 2); - assert_eq!(resource.pages()[0].as_ref(), &vec!["p1"]); - assert_eq!(resource.pages()[1].as_ref(), &vec!["p2"]); - }); -} - -#[gpui::test] -fn test_fetch_previous_page_infinite_direct_call(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<InfiniteQueryResource<Vec<&'static str>, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_infinite_query( - InfiniteQueryOptions::new("direct-prev").cache_policy(CachePolicy::Ttl { ttl_ms: 0 }), - |_lp| async move { Ok::<_, QueryError>((vec!["p2"], false)) }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - let entity = cx.update(|cx| harness.read(cx).entity.clone()); - cx.update(|cx| { - entity.update(cx, |r, _| { - r.set_has_previous_page(true); - }); - }); - - harness.update(cx, |this, cx| { - fetch_previous_page_infinite( - &this.entity, - |_fp| async move { Ok::<_, QueryError>((vec!["p0"], true)) }, - cx, - ); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - let resource = harness.read(cx).entity.read(cx); - assert_eq!(resource.pages().len(), 2); - assert_eq!( - resource.pages()[0].as_ref(), - &vec!["p0"], - "previous page should be prepended" - ); - assert_eq!(resource.pages()[1].as_ref(), &vec!["p2"]); - }); -} - #[gpui::test] fn test_infinite_query_first_page_failure(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs index 1f00c49..f617acb 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/basic_tests.rs @@ -86,45 +86,6 @@ fn test_mutate_failure_stores_error(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_mutate_rejects_concurrent_calls(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - mutation: Entity<MutationResource<String, String, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); - - mutate( - &entity, - "first".to_string(), - |_vars| async move { Ok::<_, QueryError>("first-result".to_string()) }, - cx, - ); - assert!(entity.read(cx).is_loading()); - - mutate( - &entity, - "second".to_string(), - |_vars| async move { Ok::<_, QueryError>("second-result".to_string()) }, - cx, - ); - - H { mutation: entity } - }); - - cx.run_until_parked(); - - cx.update(|cx| { - let resource = harness.read(cx).mutation.read(cx); - assert!(resource.is_success()); - assert_eq!(resource.variables(), Some(&"first".to_string())); - assert_eq!(resource.data(), Some(&"first-result".to_string())); - }); -} - #[gpui::test] fn test_use_mutation_registers_with_client(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs index ec60f23..f90c2e0 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs @@ -6,92 +6,6 @@ use crate::core::{MutationResource, QueryError}; use crate::hook::*; use crate::tests::test_support::*; -#[gpui::test] -fn test_mutate_with_callbacks_success(cx: &mut TestAppContext) { - setup_query_client(cx); - - let success_called = Arc::new(Mutex::new(false)); - let settled_called = Arc::new(Mutex::new(false)); - let success_clone = success_called.clone(); - let settled_clone = settled_called.clone(); - - #[allow(dead_code)] - struct H { - mutation: Entity<MutationResource<String, String, QueryError>>, - } - - let _harness = cx.new(|cx| { - let (entity, _sub) = use_mutation::<String, String, QueryError, _>((), cx); - mutate_with_callbacks( - &entity, - "vars".to_string(), - |v| async move { Ok::<_, QueryError>(format!("ok-{}", v)) }, - MutationCallbacks::new() - .on_success(move |data| { - assert_eq!(data, "ok-vars"); - *success_clone.lock().unwrap() = true; - }) - .on_settled(move |opt_data, opt_err| { - assert!(opt_data.is_some()); - assert!(opt_err.is_none()); - *settled_clone.lock().unwrap() = true; - }), - cx, - ); - H { mutation: entity } - }); - - cx.run_until_parked(); - - assert!(*success_called.lock().unwrap(), "on_success should fire"); - assert!(*settled_called.lock().unwrap(), "on_settled should fire"); -} - -#[gpui::test] -fn test_mutate_with_callbacks_failure(cx: &mut TestAppContext) { - setup_query_client(cx); - - let error_called = Arc::new(Mutex::new(false)); - let settled_called = Arc::new(Mutex::new(false)); - let error_clone = error_called.clone(); - let settled_clone = settled_called.clone(); - - #[allow(dead_code)] - struct H { - mutation: Entity<MutationResource<String, String, QueryError>>, - } - - let _harness = cx.new(|cx| { - let (entity, _sub) = - use_mutation::<String, String, QueryError, _>(no_retry_mutation_options(), cx); - mutate_with_callbacks( - &entity, - "fail-input".to_string(), - |_| async { Err::<String, _>(QueryError::response("cb-error")) }, - MutationCallbacks::<String, QueryError>::new() - .on_error(move |err: &QueryError| { - assert!(err.to_string().contains("cb-error")); - *error_clone.lock().unwrap() = true; - }) - .on_settled(move |opt_data, opt_err| { - assert!(opt_data.is_none()); - assert!(opt_err.is_some()); - *settled_clone.lock().unwrap() = true; - }), - cx, - ); - H { mutation: entity } - }); - - cx.run_until_parked(); - - assert!(*error_called.lock().unwrap(), "on_error should fire"); - assert!( - *settled_called.lock().unwrap(), - "on_settled should fire on failure" - ); -} - #[gpui::test] fn test_mutate_callbacks_all_fire_on_success(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs index f23f54c..78e27c6 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/basic_hooks.rs @@ -1,5 +1,3 @@ -use std::sync::{Arc, Mutex}; - use gpui::{AppContext as _, Entity, TestAppContext}; use crate::core::{ @@ -120,7 +118,7 @@ fn test_use_query_completes_with_failure(cx: &mut TestAppContext) { fn test_use_query_signal_not_cancelled_on_normal_fetch(cx: &mut TestAppContext) { setup_query_client(cx); - let signal_cancelled = Arc::new(Mutex::new(false)); + let signal_cancelled = std::sync::Arc::new(std::sync::Mutex::new(false)); let sc = signal_cancelled.clone(); struct H { @@ -156,100 +154,6 @@ fn test_use_query_signal_not_cancelled_on_normal_fetch(cx: &mut TestAppContext) ); } -#[gpui::test] -fn test_use_query_manual_creates_entity_without_fetch(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<String, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_query_manual::<String, QueryError, _>( - QueryKey::from("manual-key"), - CachePolicy::Ttl { ttl_ms: 1_000 }, - RequestPolicy::LatestWins, - cx, - ); - let resource = entity.read(cx); - assert_eq!(resource.status(), QueryStatus::Idle); - assert!(resource.data().is_none()); - assert_eq!(resource.key(), &QueryKey::from("manual-key")); - H { entity } - }); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).status(), QueryStatus::Idle); - }); -} - -#[gpui::test] -fn test_fetch_query_triggers_refetch_on_existing_entity(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<String, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_query_manual::<String, QueryError, _>( - QueryKey::from("refetch-key"), - CachePolicy::Ttl { ttl_ms: 0 }, - RequestPolicy::LatestWins, - cx, - ); - assert_eq!(entity.read(cx).status(), QueryStatus::Idle); - fetch_query( - &entity, - || async { Ok::<_, QueryError>("refetched".to_string()) }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - cx.update(|cx| { - let resource = harness.read(cx).entity.read(cx); - assert_eq!(resource.status(), QueryStatus::Success); - assert_eq!(resource.data(), Some(&"refetched".to_string())); - }); -} - -#[gpui::test] -fn test_fetch_query_can_refetch_after_success(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<&'static str, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_query( - QueryOptions::new("double-fetch").cache_policy(CachePolicy::NoCache), - |_signal| async move { Ok::<_, QueryError>("first") }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"first")); - }); - - harness.update(cx, |this, cx| { - fetch_query(&this.entity, || async { Ok::<_, QueryError>("second") }, cx); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"second")); - }); -} - #[gpui::test] fn test_subscription_drops_gracefully(cx: &mut TestAppContext) { setup_query_client(cx); @@ -399,37 +303,3 @@ fn test_use_query_unsignalled_auto_fetches(cx: &mut TestAppContext) { assert_eq!(resource.data(), Some(&99)); }); } - -#[gpui::test] -fn test_fetch_query_refetch_after_success(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<i32, QueryError>>, - } - - let harness = cx.new(|cx| { - let (entity, _sub) = use_query( - QueryOptions::new("force-test").cache_policy(CachePolicy::NoCache), - |_signal| async move { Ok::<_, QueryError>(1_i32) }, - cx, - ); - H { entity } - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&1)); - }); - - harness.update(cx, |this, cx| { - fetch_query(&this.entity, || async { Ok::<_, QueryError>(2_i32) }, cx); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&2)); - }); -} diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs index 91fc6f5..c70ecd0 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/lifecycle.rs @@ -2,83 +2,10 @@ use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; -use crate::core::{CachePolicy, QueryError, QueryKey, QueryResource, QueryStatus, RequestPolicy}; +use crate::core::{CachePolicy, QueryError, QueryResource, QueryStatus, RequestPolicy}; use crate::hook::*; use crate::tests::test_support::*; -#[gpui::test] -fn test_dropping_subscription_stops_observation(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<&'static str, QueryError>>, - _sub: gpui::Subscription, - } - - let harness = cx.new(|cx| { - let (entity, sub) = use_query_manual::<&'static str, QueryError, _>( - QueryKey::from("drop-obs"), - CachePolicy::NoCache, - RequestPolicy::LatestWins, - cx, - ); - H { entity, _sub: sub } - }); - - harness.update(cx, |this, cx| { - fetch_query( - &this.entity, - || async { Ok::<_, QueryError>("with-sub") }, - cx, - ); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&"with-sub")); - }); -} - -#[gpui::test] -fn test_multiple_observations_same_entity(cx: &mut TestAppContext) { - setup_query_client(cx); - - struct H { - entity: Entity<QueryResource<u32, QueryError>>, - _sub1: gpui::Subscription, - _sub2: gpui::Subscription, - } - - let harness = cx.new(|cx| { - let (entity, sub1) = use_query_manual::<u32, QueryError, _>( - QueryKey::from("multi-obs"), - CachePolicy::NoCache, - RequestPolicy::LatestWins, - cx, - ); - let observer2 = crate::client::QueryObserver::new(&entity); - let sub2 = observer2 - .observe(cx) - .expect("second observation should succeed on live entity"); - H { - entity, - _sub1: sub1, - _sub2: sub2, - } - }); - - harness.update(cx, |this, cx| { - fetch_query(&this.entity, || async { Ok::<_, QueryError>(42_u32) }, cx); - }); - - cx.run_until_parked(); - - cx.update(|cx| { - assert_eq!(harness.read(cx).entity.read(cx).data(), Some(&42)); - }); -} - #[gpui::test] fn test_use_query_ignore_while_loading_policy(cx: &mut TestAppContext) { setup_test(cx); diff --git a/crates/gpui-query/src/tests/integration_client/client_basics.rs b/crates/gpui-query/src/tests/integration_client/client_basics.rs index 66c70c4..baaf3e0 100644 --- a/crates/gpui-query/src/tests/integration_client/client_basics.rs +++ b/crates/gpui-query/src/tests/integration_client/client_basics.rs @@ -1,58 +1,9 @@ use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; -use crate::client::{MutationObserver, ObserverConfig, QueryClient, QueryObserver}; +use crate::client::{MutationObserver, QueryClient, QueryObserver}; use crate::core::*; use crate::tests::test_support::*; -#[gpui::test] -fn test_client_creation_and_global_registration(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - let diag = cx.update_global::<QueryClient, _>(|client, cx| client.diagnostics(cx)); - assert_eq!(diag.query_count, 0, "new client should have zero queries"); - assert_eq!( - diag.mutation_count, 0, - "new client should have zero mutations" - ); - }); -} - -#[gpui::test] -fn test_client_with_custom_policies(cx: &mut TestAppContext) { - setup_query_client_with_policies( - cx, - CachePolicy::Ttl { ttl_ms: 5_000 }, - RequestPolicy::LatestWins, - ); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("test_key", cx); - entity.read_with(cx, |r, _| { - assert_eq!(r.cache_policy(), CachePolicy::Ttl { ttl_ms: 5_000 }); - }); - }); - }); -} - -#[gpui::test] -fn test_client_with_gc_time(cx: &mut TestAppContext) { - setup_query_client_with_gc(cx, 1_000); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("key", cx); - entity.update(cx, |r, _| { - r.apply_success("hello".to_string(), 100); - }); - client.gc_with_time(3_000, cx); - let remaining = client.all_queries::<String, QueryError>(); - assert!( - remaining.is_empty(), - "resource should be evicted by GC (age 2900 > success_threshold 2000)" - ); - }); - }); -} - #[gpui::test] fn test_resource_creates_and_deduplicates(cx: &mut TestAppContext) { setup_query_client(cx); @@ -127,71 +78,6 @@ fn test_query_retrieves_existing_entity(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_type_partitioned_buckets_no_conflict(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let string_entity = client.resource::<String, QueryError>("data", cx); - let u32_entity = client.resource::<u32, QueryError>("data", cx); - let user_entity = client.resource::<User, QueryError>("data", cx); - - assert_ne!( - string_entity.entity_id(), - u32_entity.entity_id(), - "different T types must produce different entities" - ); - assert_ne!( - string_entity.entity_id(), - user_entity.entity_id(), - "String and User must be separate" - ); - assert_ne!( - u32_entity.entity_id(), - user_entity.entity_id(), - "u32 and User must be separate" - ); - - let strings = client.all_queries::<String, QueryError>(); - assert_eq!(strings.len(), 1); - assert_eq!(strings[0].entity_id(), string_entity.entity_id()); - - let users = client.all_queries::<User, QueryError>(); - assert_eq!(users.len(), 1); - assert_eq!(users[0].entity_id(), user_entity.entity_id()); - }); - }); -} - -#[gpui::test] -fn test_same_type_different_error_types_no_conflict(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let e1 = client.resource::<String, QueryError>("key", cx); - let e2 = client.resource::<String, String>("key", cx); - - assert_ne!( - e1.entity_id(), - e2.entity_id(), - "different E types must produce different entities" - ); - }); - }); -} - -#[gpui::test] -fn test_diagnostics_empty_client(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - let diag = cx.update_global::<QueryClient, _>(|client, cx| client.diagnostics(cx)); - assert_eq!(diag.query_count, 0); - assert_eq!(diag.mutation_count, 0); - assert!(diag.queries.is_empty()); - assert!(diag.mutations.is_empty()); - }); -} - #[gpui::test] fn test_diagnostics_with_resources(cx: &mut TestAppContext) { setup_query_client(cx); @@ -236,34 +122,6 @@ fn test_diagnostics_with_resources(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_diagnostics_across_type_buckets(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let _s = client.resource::<String, QueryError>("s", cx); - let _u = client.resource::<User, QueryError>("u", cx); - - let diag = client.diagnostics(cx); - assert_eq!(diag.query_count, 2, "should count across type buckets"); - assert_eq!(diag.queries.len(), 2); - }); - }); -} - -#[gpui::test] -fn test_query_observer_creation(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("obs_key", cx); - let _observer = QueryObserver::new(&entity); - let weak = entity.downgrade(); - assert!(weak.upgrade().is_some(), "entity should still be alive"); - }); - }); -} - #[gpui::test] fn test_query_observer_observe_returns_subscription(cx: &mut TestAppContext) { setup_query_client(cx); @@ -292,17 +150,3 @@ fn test_mutation_observer_creation(cx: &mut TestAppContext) { let _observer = MutationObserver::<String, User, QueryError>::new(&entity); }); } - -#[gpui::test] -fn test_observer_config_custom_settings(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("config_key", cx); - let config = ObserverConfig { - notify_on_status_change_only: false, - }; - let _observer = QueryObserver::new(&entity).with_config(config); - }); - }); -} diff --git a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs index ba43b6e..d280a26 100644 --- a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs +++ b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs @@ -4,6 +4,24 @@ use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; +fn create_success_at_time( + client: &mut QueryClient, + cx: &mut gpui::App, + key: &str, + data: &str, + success_time_ms: u64, +) { + let entity = client.resource_with_policies::<String, QueryError>( + QueryKey::from(key), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::LatestWins, + cx, + ); + entity.update(cx, |r, _| { + r.apply_success(data.to_string(), success_time_ms) + }); +} + #[gpui::test] fn test_invalidate_queries_exact_filter(cx: &mut TestAppContext) { setup_query_client(cx); @@ -310,3 +328,64 @@ fn test_gc_across_multiple_type_buckets(cx: &mut TestAppContext) { }); }); } + +#[gpui::test] +fn test_gc_mixed_states_precise_eviction(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let prepared = client + .prepare_fetch_query::<String, QueryError>("loading", cx) + .expect("should start"); + + create_success_at_time(client, cx, "success_fresh", "data", 1_000); + + create_success_at_time(client, cx, "success_old", "data", 0); + + assert_eq!(client.all_queries::<String, QueryError>().len(), 3); + + client.gc_with_time(2_500, cx); + + let remaining = client.all_queries::<String, QueryError>(); + assert_eq!( + remaining.len(), + 2, + "exactly 1 of 3 resources should be evicted" + ); + + let remaining_keys: Vec<String> = remaining + .iter() + .map(|e| e.read(cx).key().to_path()) + .collect(); + assert!( + remaining_keys.contains(&"loading".to_string()), + "loading should survive: {:?}", + remaining_keys + ); + assert!( + remaining_keys.contains(&"success_fresh".to_string()), + "success_fresh should survive: {:?}", + remaining_keys + ); + + prepared.complete_success("data".to_string(), cx); + }); + }); +} + +#[gpui::test] +fn test_gc_boundary_success_threshold_exact(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("boundary"); + create_success_at_time(client, cx, "boundary", "data", 1_000); + + client.gc_with_time(3_000, cx); + assert!( + client.query::<String, QueryError>(&key).is_none(), + "age=2000ms == success_threshold=2000ms => must be evicted (>= boundary)" + ); + }); + }); +} diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs index 3eb3e6d..82e57b2 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_basics.rs @@ -4,20 +4,6 @@ use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; -#[gpui::test] -fn test_client_new_equals_default(cx: &mut TestAppContext) { - cx.update(|cx| { - let c1 = QueryClient::new(); - let c2 = QueryClient::default(); - cx.set_global(c1); - let d1 = cx.update_global::<QueryClient, _>(|c, cx| c.diagnostics(cx)); - cx.set_global(c2); - let d2 = cx.update_global::<QueryClient, _>(|c, cx| c.diagnostics(cx)); - assert_eq!(d1.query_count, d2.query_count); - assert_eq!(d1.mutation_count, d2.mutation_count); - }); -} - #[gpui::test] fn test_builder_chaining_with_policies_and_gc(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index 0505c01..11e7b83 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -1,6 +1,6 @@ use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; -use crate::client::{QueryClient, QueryObserver}; +use crate::client::QueryClient; use crate::core::*; use crate::tests::test_support::*; @@ -65,7 +65,7 @@ fn test_gc_preserves_swr_resources_within_ttl(cx: &mut TestAppContext) { } #[gpui::test] -fn test_gc_evicts_completed_mutation_after_gc_time(cx: &mut TestAppContext) { +fn test_gc_preserves_success_mutation(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -239,24 +239,6 @@ fn test_idle_mutation_is_evicted_by_gc_after_age_exceeds_threshold(cx: &mut Test }); } -#[gpui::test] -fn test_query_observer_observe_returns_some_for_live_entity(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("obs_live", cx); - - let mut observer = QueryObserver::new(&entity); - - let sub = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); - assert!( - sub.is_some(), - "observe should return Some(Subscription) for a live entity" - ); - }); - }); -} - #[gpui::test] fn test_observer_status_dedup_default_config_is_status_change_only(_cx: &mut TestAppContext) { let config = crate::client::ObserverConfig::default(); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs index 68ccc5e..7da2c45 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs @@ -4,14 +4,14 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use crate::core::*; use crate::hook::{ - InfiniteQueryOptions, MutationCallbacks, MutationOptions, QueryOptions, - fetch_next_page_infinite, fetch_query, fetch_query_with_signal, mutate_with_callbacks, - use_infinite_query, use_mutation, use_query_manual, use_query_select, + InfiniteQueryOptions, MutationOptions, QueryOptions, fetch_next_page_infinite, fetch_query, + fetch_query_with_signal, use_infinite_query, use_mutation, use_query_manual, + use_query_select, }; use crate::tests::test_support::*; #[gpui::test] -fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext) { +fn test_use_mutation_accepts_mutation_options_directly(cx: &mut TestAppContext) { setup_query_client(cx); #[allow(dead_code)] @@ -33,51 +33,6 @@ fn test_deprecated_use_mutation_with_options_still_works(cx: &mut TestAppContext }); } -#[gpui::test] -fn test_mutation_callbacks_fire_on_entity_drop_during_retry_delay(cx: &mut TestAppContext) { - setup_query_client(cx); - - let error_called = Arc::new(Mutex::new(false)); - let settled_called = Arc::new(Mutex::new(false)); - let ec = error_called.clone(); - let sc = settled_called.clone(); - - #[allow(dead_code)] - struct H { - mutation: Entity<MutationResource<String, String, QueryError>>, - } - - let _harness = cx.new(|cx| { - let (entity, _sub) = - use_mutation::<String, String, QueryError, _>(no_retry_mutation_options(), cx); - mutate_with_callbacks( - &entity, - "vars".to_string(), - |_| async { Err::<String, _>(QueryError::response("fail")) }, - MutationCallbacks::<String, QueryError>::new() - .on_error(move |_| { - *ec.lock().unwrap() = true; - }) - .on_settled(move |_, _| { - *sc.lock().unwrap() = true; - }), - cx, - ); - H { mutation: entity } - }); - - cx.run_until_parked(); - - assert!( - *error_called.lock().unwrap(), - "on_error should fire when mutation fails" - ); - assert!( - *settled_called.lock().unwrap(), - "on_settled should fire when mutation fails" - ); -} - #[gpui::test] fn test_fetch_retry_stops_after_request_replaced(cx: &mut TestAppContext) { setup_test(cx); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs index c126acf..4c91ef2 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_mutations.rs @@ -160,15 +160,6 @@ fn test_diagnostics_includes_mutations_with_status(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_observer_config_default(_cx: &mut TestAppContext) { - let config = ObserverConfig::default(); - assert!( - config.notify_on_status_change_only, - "default should notify on status change only" - ); -} - #[gpui::test] fn test_diagnostics_mutation_retry_count(cx: &mut TestAppContext) { setup_query_client(cx); @@ -193,23 +184,6 @@ fn test_diagnostics_mutation_retry_count(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_query_observer_observe_succeeds_for_live_entity(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.resource::<String, QueryError>("live_obs", cx); - let mut observer = QueryObserver::new(&entity); - - let result = observe_with_dummy_view::<String, QueryError>(cx, &mut observer); - assert!( - result.is_some(), - "observe should return Some(Subscription) for a live entity" - ); - }); - }); -} - #[gpui::test] fn test_mutation_observer_observe_returns_subscription(cx: &mut TestAppContext) { setup_query_client(cx); @@ -228,24 +202,6 @@ fn test_mutation_observer_observe_returns_subscription(cx: &mut TestAppContext) }); } -#[gpui::test] -fn test_mutation_observer_weak_entity_pattern(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - let entity = cx - .new(|_| MutationResource::<String, User, QueryError>::new(RetryPolicy::no_retries())); - let observer = MutationObserver::<String, User, QueryError>::new(&entity); - - struct DummyView; - let view = cx.new(|_| DummyView); - let sub = view.update(cx, |_view, cx| observer.observe(cx)); - assert!( - sub.is_some(), - "observe should return Some for live mutation entity" - ); - }); -} - #[gpui::test] fn test_query_observer_with_config_always_notify(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs index 7fec77b..0e90962 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs @@ -185,22 +185,6 @@ fn test_infinite_query_observer_creation_and_observe(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_infinite_query_observer_weak_entity_pattern(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let entity = client.infinite_resource::<String, QueryError>("inf_obs_weak", cx); - let observer = InfiniteQueryObserver::new(&entity); - - struct DummyView; - let view = cx.new(|_| DummyView); - let sub = view.update(cx, |_view, cx| observer.observe(cx)); - assert!(sub.is_some(), "observe should return Some for live entity"); - }); - }); -} - #[gpui::test] fn test_current_time_ms_is_reasonable(_cx: &mut TestAppContext) { let now = crate::client::current_time_ms(); @@ -288,21 +272,6 @@ fn test_reset_then_set_query_data(cx: &mut TestAppContext) { }); } -#[gpui::test] -fn test_large_number_of_resources_creation(cx: &mut TestAppContext) { - setup_query_client(cx); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - for i in 0..100 { - let key = format!("large_{i}"); - let _e = client.resource::<u32, QueryError>(key, cx); - } - let all = client.all_queries::<u32, QueryError>(); - assert_eq!(all.len(), 100, "should have 100 resources"); - }); - }); -} - #[gpui::test] fn test_multi_segment_key_in_client_operations(cx: &mut TestAppContext) { setup_query_client(cx); @@ -370,32 +339,3 @@ fn test_clear_data_via_resource(cx: &mut TestAppContext) { }); }); } - -#[gpui::test] -fn test_prepare_prefetch_query_returns_some_for_stale(cx: &mut TestAppContext) { - cx.update(|cx| { - cx.set_global(QueryClient::with_policies( - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - )); - }); - cx.update(|cx| { - cx.update_global::<QueryClient, _>(|client, cx| { - let key = QueryKey::from("prefresh_stale"); - let entity = client.resource::<String, QueryError>(key.clone(), cx); - entity.update(cx, |r, _| r.apply_success("stale_data".to_string(), 0)); - - let result = client.prepare_prefetch_query::<String, QueryError>( - key.clone(), - CachePolicy::Ttl { ttl_ms: 60_000 }, - RequestPolicy::LatestWins, - cx, - ); - assert!( - result.is_some(), - "prefetch should return Some for stale data \ - (data from t=0 is well past the 60s TTL at current time)" - ); - }); - }); -} diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index 9444850..45bcfc2 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -361,6 +361,101 @@ fn test_hydrate_rejects_version_mismatch(cx: &mut TestAppContext) { } } +#[gpui::test] +fn test_hydrate_hostile_entries_skip_without_panicking(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + + let now = crate::client::current_time_ms(); + let mut snap = PersistSnapshot { + entries: Default::default(), + version: crate::client::PERSIST_VERSION, + }; + snap.entries.insert( + "hostile_shape".to_string(), + PersistedEntry { + value: serde_json::json!({"evil": [1, null, "x"]}), + cached_at: now, + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + snap.entries.insert( + "future_cached_at".to_string(), + PersistedEntry { + value: serde_json::json!("future"), + cached_at: u64::MAX, + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + snap.entries.insert( + "ancient_cached_at".to_string(), + PersistedEntry { + value: serde_json::json!("ancient"), + cached_at: 0, + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + *persister.load_value.lock().unwrap() = Some(snap); + + struct H { + hostile: Entity<QueryResource<String, QueryError>>, + future: Entity<QueryResource<String, QueryError>>, + ancient: Entity<QueryResource<String, QueryError>>, + } + let harness = cx.new(|cx| { + cx.update_global::<QueryClient, _>(|client, _cx| { + client + .register_deserializer::<String, QueryError>(|v| v.as_str().map(|s| s.to_string())); + }); + let hostile = cx.update_global::<QueryClient, _>(|client, cx| { + client.resource::<String, QueryError>(QueryKey::from("hostile_shape"), cx) + }); + let future = cx.update_global::<QueryClient, _>(|client, cx| { + client.resource::<String, QueryError>(QueryKey::from("future_cached_at"), cx) + }); + let ancient = cx.update_global::<QueryClient, _>(|client, cx| { + client.resource::<String, QueryError>(QueryKey::from("ancient_cached_at"), cx) + }); + H { + hostile, + future, + ancient, + } + }); + + let outcome = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + block_on_ready(hydrate( + client, + &persister, + &PersistFilter::All, + Duration::from_secs(60), + cx, + )) + }) + }); + assert!(outcome.is_ok(), "hostile entries must not fail hydrate"); + + cx.update(|cx| { + assert!( + harness.read(cx).hostile.read(cx).data().is_none(), + "a value with a hostile JSON shape must be skipped, not primed or panicked on" + ); + assert!( + harness.read(cx).ancient.read(cx).data().is_none(), + "an entry older than max_age must be filtered out" + ); + assert_eq!( + harness.read(cx).future.read(cx).data(), + Some(&"future".to_string()), + "u64::MAX cached_at must saturate to age 0 and stay hydratable" + ); + }); +} + #[gpui::test] fn test_persist_with_driven_by_real_fetch_completion(cx: &mut TestAppContext) { setup_query_client(cx); diff --git a/crates/gpui-query/src/tests/mod.rs b/crates/gpui-query/src/tests/mod.rs index 39ccd3b..dc6c20c 100644 --- a/crates/gpui-query/src/tests/mod.rs +++ b/crates/gpui-query/src/tests/mod.rs @@ -7,14 +7,11 @@ mod core_policy_types; mod core_request; mod core_resource_advanced; mod core_select; -#[cfg(feature = "client")] -mod coverage_gaps; #[cfg(feature = "hook")] mod hook_tests; #[cfg(feature = "client")] mod integration_client; #[cfg(feature = "hook")] mod integration_client_coverage; -#[cfg(feature = "hook")] mod property_tests; mod test_support; diff --git a/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs b/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs index f119f82..9f4d7d2 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/deterministic_tests.rs @@ -2,6 +2,51 @@ use crate::core::*; use super::strategies::*; +#[test] +fn key_join_appends_and_chains_segments() { + let key = QueryKey::from(["users"]).join("42"); + assert_eq!(key.parts().len(), 2); + assert_eq!(key.to_path(), "users::42"); + + let chained = key.join("posts"); + assert_eq!(chained.parts().len(), 3); + assert_eq!(chained.to_path(), "users::42::posts"); + assert_eq!(key.parts().len(), 2, "join must not mutate the receiver"); +} + +#[test] +fn key_from_vec_string() { + let key = QueryKey::from(vec!["users".to_string(), "42".to_string()]); + assert_eq!(key.parts().len(), 2); + assert_eq!(key.to_path(), "users::42"); +} + +#[test] +fn key_deref_allows_indexing_and_len() { + let key = QueryKey::from(["a", "b", "c"]); + assert_eq!(&*key[0], "a"); + assert_eq!(&*key[2], "c"); + assert_eq!(key.len(), 3); +} + +#[test] +fn key_deserialize_accepts_single_string() { + let key: QueryKey = serde_json::from_str("\"users\"").unwrap(); + assert_eq!(key.parts().len(), 1); + assert_eq!(key.first_segment(), "users"); +} + +#[test] +fn key_deserialize_rejects_malformed_shapes() { + for json in ["null", "42", "[\"a\", 1]", "[[\"a\"]]", "[]", "{}"] { + let result: Result<QueryKey, _> = serde_json::from_str(json); + assert!( + result.is_err(), + "malformed input {json} must Err, not panic or accept" + ); + } +} + #[test] fn key_empty_string_segment_distinguishes_from_multi() { let single_empty = QueryKey::from([""]); diff --git a/crates/gpui-query/src/tests/test_support.rs b/crates/gpui-query/src/tests/test_support.rs index e84a047..a5c89b8 100644 --- a/crates/gpui-query/src/tests/test_support.rs +++ b/crates/gpui-query/src/tests/test_support.rs @@ -75,10 +75,6 @@ pub fn nocache_resource(key: impl Into<QueryKey>) -> QueryResource<&'static str> QueryResource::new(key, CachePolicy::NoCache, RequestPolicy::LatestWins) } -pub fn fresh_resource() -> QueryResource<&'static str> { - nocache_resource("invariant-test") -} - pub fn begin_request_id( r: &mut QueryResource<impl Clone, impl Clone>, seq: &mut RequestSequencer, @@ -97,15 +93,6 @@ pub fn begin_request_id( } } -pub fn complete_success_id<T, E>( - r: &mut QueryResource<T, E>, - request_id: RequestId, - data: T, - now_ms: u64, -) -> bool { - r.complete_current_success(request_id, data, now_ms) -} - #[cfg(feature = "hook")] pub fn no_retry_mutation_options() -> MutationOptions { MutationOptions { From 53bf0b210e6389de0dbd6bd3ed68aca858b1e2e2 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 02:52:26 +0200 Subject: [PATCH 051/111] fix: detect truncate-first flips in straddle test and correct hook comment --- crates/gpui-query/src/hook/mutation_hooks/hooks.rs | 2 +- crates/gpui-query/src/tests/core_error/mod.rs | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs index 6801089..69a1a5a 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs @@ -79,7 +79,7 @@ where since = "0.2.0", note = "Use `use_mutation(options, cx)` instead — it now accepts MutationOptions via Into" )] -// Not re-exported; kept alive by the deprecated source-compat test. +// Not re-exported: pub inside a private module, so no caller can reach it. #[allow(dead_code)] pub fn use_mutation_with_options<V, T, E, C>( options: &MutationOptions, diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 2160547..0f8f5a9 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -105,14 +105,12 @@ fn sanitized_empty_message_stays_empty() { } #[test] -fn sanitized_redacts_secret_straddling_truncation_boundary() { - let prefix = "x".repeat(500); - let secret = "s3cr3tboundaryleak".to_string(); - let msg = format!("{prefix}bearer {secret}"); +fn sanitized_redacts_email_straddling_truncation_boundary() { + let msg = format!("{} alice@corp.com", "x".repeat(505)); assert!(msg.len() > 512, "precondition: message must exceed the cap"); let clean = QueryError::response(msg.as_str()).sanitized(); assert!( - !clean.message().contains(&secret), + !clean.message().contains("alice"), "truncation must not resurrect a partially-redacted secret" ); assert!(clean.message().ends_with("...[truncated]")); From 96c9a793553915eb0fb58e6d5dcb359b0a3334e1 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 03:30:19 +0200 Subject: [PATCH 052/111] fix: refresh cache entries on 304 and cap parse-error echoes --- crates/gpui-query-http/src/cache.rs | 155 ++++++++++++++++++++++++++-- crates/gpui-query-http/src/lib.rs | 88 +++++++++++++++- 2 files changed, 233 insertions(+), 10 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index a259700..ee0c161 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -59,8 +59,9 @@ impl<B: HttpBackend> HttpCache<B> { /// Fetches `url`: a fresh entry skips the network, a stale one /// revalidates. Returns `(body, policy, meta)`: only a cacheable `200` - /// stores and yields `meta`, a `304` re-serves the cached body, and - /// everything else is [`CachePolicy::NoCache`] with `None`. + /// stores and yields `meta`, a `304` re-serves the cached body and + /// restarts its freshness window, and everything else is + /// [`CachePolicy::NoCache`] with `None`. pub async fn fetch( &self, url: &str, @@ -93,10 +94,18 @@ impl<B: HttpBackend> HttpCache<B> { url: url.to_string(), }); }; + if let Some(old) = cached_meta.as_ref() + && let Some(meta) = refreshed_meta(&resp.headers, old) + { + { + let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; + guard.insert(url.to_string(), meta.clone()); + } + return Ok((body, policy_from_meta(&meta), Some(meta))); + } let policy = cached_meta .as_ref() - .map(policy_from_meta) - .unwrap_or(CachePolicy::NoCache); + .map_or(CachePolicy::NoCache, policy_from_meta); return Ok((body, policy, cached_meta)); } @@ -177,6 +186,27 @@ fn policy_from_meta(meta: &CacheMeta) -> CachePolicy { } } +/// RFC 9111 §4.3.4: a `304` updates stored fields and restarts freshness; +/// a `no-store`/`no-cache`/malformed `Cache-Control` on it leaves the entry +/// untouched. +fn refreshed_meta(headers: &HeaderMap, old: &CacheMeta) -> Option<CacheMeta> { + let policy = if headers.contains_key(http::header::CACHE_CONTROL) { + match cache_policy_from_headers(headers) { + Ok(p) if p != CachePolicy::NoCache => Some(p), + _ => return None, + } + } else { + None + }; + Some(CacheMeta { + etag: header_str(headers, "etag").or_else(|| old.etag.clone()), + last_modified: header_str(headers, "last-modified").or_else(|| old.last_modified.clone()), + stored_at: SystemTime::now(), + fresh_for: policy.map_or(old.fresh_for, fresh_for_from_policy), + stale_for: policy.map_or(old.stale_for, stale_for_from_policy), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -245,9 +275,14 @@ mod tests { } } - fn resp_304_with_etag(etag: &str) -> BackendResponse { + fn resp_304(cache_control: Option<&str>, etag: Option<&str>) -> BackendResponse { let mut headers = HeaderMap::new(); - headers.insert(http::header::ETAG, etag.parse().unwrap()); + if let Some(cache_control) = cache_control { + headers.insert(http::header::CACHE_CONTROL, cache_control.parse().unwrap()); + } + if let Some(etag) = etag { + headers.insert(http::header::ETAG, etag.parse().unwrap()); + } BackendResponse { status: 304, headers, @@ -255,6 +290,30 @@ mod tests { } } + fn seed_entry( + cache: &HttpCache<MockBackend>, + url: &str, + body: &'static [u8], + fresh_for: Duration, + stored_at: SystemTime, + ) { + cache.meta.lock().unwrap().insert( + url.to_string(), + CacheMeta { + etag: Some("\"v0\"".to_string()), + last_modified: None, + stored_at, + fresh_for, + stale_for: Duration::ZERO, + }, + ); + cache + .bodies + .lock() + .unwrap() + .insert(url.to_string(), Bytes::copy_from_slice(body)); + } + #[tokio::test] async fn two_hundred_stores_body_and_meta() { let backend = MockBackend::new(vec![Ok(resp_200("hello", "max-age=600"))]); @@ -292,7 +351,7 @@ mod tests { async fn not_modified_returns_cached_body() { let backend = MockBackend::new(vec![ Ok(resp_200("payload", "max-age=0, stale-while-revalidate=60")), - Ok(resp_304_with_etag("\"v1\"")), + Ok(resp_304(None, Some("\"v1\""))), ]); let cache = HttpCache::new(backend); @@ -386,7 +445,7 @@ mod tests { #[tokio::test] async fn not_modified_without_cached_body_is_typed_error() { - let backend = MockBackend::new(vec![Ok(resp_304_with_etag("\"v1\""))]); + let backend = MockBackend::new(vec![Ok(resp_304(None, Some("\"v1\"")))]); let cache = HttpCache::new(backend); let err = cache.fetch("https://example.test/e").await.unwrap_err(); @@ -395,4 +454,84 @@ mod tests { "got {err:?}" ); } + + #[tokio::test] + async fn not_modified_restarts_freshness_window() { + let backend = MockBackend::new(vec![Ok(resp_304(None, Some("\"v1\"")))]); + let cache = HttpCache::new(backend); + let url = "https://example.test/refresh"; + seed_entry( + &cache, + url, + b"cached", + Duration::from_secs(60), + SystemTime::now() - Duration::from_secs(3600), + ); + + let (body, policy, meta) = cache.fetch(url).await.unwrap(); + assert_eq!(body, Bytes::from_static(b"cached")); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 60_000 }); + assert_eq!( + meta.expect("304 yields meta").etag.as_deref(), + Some("\"v1\""), + "304 fields update the stored entry" + ); + + let (body2, _, _) = cache.fetch(url).await.unwrap(); + assert_eq!(body2, Bytes::from_static(b"cached")); + assert_eq!( + cache.backend.calls(), + 1, + "refreshed entry serves without the network" + ); + } + + #[tokio::test] + async fn not_modified_adopts_new_cache_control() { + let backend = MockBackend::new(vec![Ok(resp_304(Some("max-age=300"), None))]); + let cache = HttpCache::new(backend); + let url = "https://example.test/new-window"; + seed_entry( + &cache, + url, + b"cached", + Duration::from_secs(60), + SystemTime::now() - Duration::from_secs(3600), + ); + + let (_, policy, _) = cache.fetch(url).await.unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 300_000 }); + let stored = cache.meta.lock().unwrap().get(url).cloned().unwrap(); + assert_eq!(stored.fresh_for, Duration::from_secs(300)); + } + + #[tokio::test] + async fn not_modified_with_no_store_keeps_stale_meta() { + let backend = MockBackend::new(vec![Ok(resp_304(Some("no-store"), None))]); + let cache = HttpCache::new(backend); + let url = "https://example.test/no-resurrect"; + let stored_at = SystemTime::now() - Duration::from_secs(3600); + seed_entry(&cache, url, b"cached", Duration::from_secs(60), stored_at); + + let (body, _, _) = cache.fetch(url).await.unwrap(); + assert_eq!(body, Bytes::from_static(b"cached")); + let stored = cache.meta.lock().unwrap().get(url).cloned().unwrap(); + assert_eq!(stored.stored_at, stored_at, "no-store 304 must not refresh"); + assert_eq!(stored.etag.as_deref(), Some("\"v0\"")); + } + + #[tokio::test] + async fn not_modified_with_malformed_cache_control_still_serves() { + let backend = MockBackend::new(vec![Ok(resp_304(Some("max-age=abc"), None))]); + let cache = HttpCache::new(backend); + let url = "https://example.test/malformed-304"; + let stored_at = SystemTime::now() - Duration::from_secs(3600); + seed_entry(&cache, url, b"cached", Duration::from_secs(60), stored_at); + + let (body, policy, _) = cache.fetch(url).await.unwrap(); + assert_eq!(body, Bytes::from_static(b"cached")); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 60_000 }); + let stored = cache.meta.lock().unwrap().get(url).cloned().unwrap(); + assert_eq!(stored.stored_at, stored_at); + } } diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index e67a130..6fd4788 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -162,6 +162,9 @@ fn split_cache_directives(raw: &str) -> impl Iterator<Item = &str> { }) } +// Errors echo attacker-controlled header bytes; bound them like core's sanitizer. +const ERROR_VALUE_MAX_BYTES: usize = 512; + fn parse_secs(is_stale: bool, raw: &str) -> Result<u64, ParseError> { if !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit()) { return Ok(match raw.parse::<u128>() { @@ -170,10 +173,19 @@ fn parse_secs(is_stale: bool, raw: &str) -> Result<u64, ParseError> { Err(_) => u64::MAX, }); } + let value = if raw.len() <= ERROR_VALUE_MAX_BYTES { + raw.to_string() + } else { + let mut cut = ERROR_VALUE_MAX_BYTES; + while !raw.is_char_boundary(cut) { + cut -= 1; + } + format!("{}...[truncated]", &raw[..cut]) + }; Err(if is_stale { - ParseError::InvalidStaleWhileRevalidate(raw.to_string()) + ParseError::InvalidStaleWhileRevalidate(value) } else { - ParseError::InvalidMaxAge(raw.to_string()) + ParseError::InvalidMaxAge(value) }) } @@ -350,6 +362,78 @@ mod tests { assert!(matches!(err, ParseError::InvalidStaleWhileRevalidate(_))); } + #[test] + fn no_cache_with_field_argument_is_no_cache() { + assert_eq!( + cache_policy_from_headers(&cc("no-cache=\"Set-Cookie\"")).unwrap(), + CachePolicy::NoCache + ); + } + + #[test] + fn quoted_argument_cannot_smuggle_no_store() { + let policy = cache_policy_from_headers(&cc("private=\"no-store\", max-age=600")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 600_000 }); + } + + #[test] + fn empty_max_age_value_is_typed_error() { + let err = cache_policy_from_headers(&cc("max-age=")).unwrap_err(); + assert!(matches!(err, ParseError::InvalidMaxAge(s) if s.is_empty())); + } + + #[test] + fn max_age_overflow_beyond_u128_saturates() { + let policy = + cache_policy_from_headers(&cc(&format!("max-age={}", "9".repeat(45)))).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: u64::MAX }); + } + + #[test] + fn swr_overflow_beyond_u128_saturates() { + let policy = cache_policy_from_headers(&cc(&format!( + "max-age=10, stale-while-revalidate={}", + "9".repeat(45) + ))) + .unwrap(); + assert_eq!( + policy, + CachePolicy::StaleWhileRevalidate { + ttl_ms: 10_000, + stale_ms: u64::MAX + } + ); + } + + #[test] + fn tabs_around_equals_are_tolerated() { + let policy = cache_policy_from_headers(&cc("max-age\t=\t60")).unwrap(); + assert_eq!(policy, CachePolicy::Ttl { ttl_ms: 60_000 }); + } + + #[test] + fn invalid_value_is_truncated_in_error_text() { + let err = + cache_policy_from_headers(&cc(&format!("max-age={}", "x".repeat(600)))).unwrap_err(); + let text = err.to_string(); + assert!( + text.ends_with("...[truncated]"), + "unbounded error text: {} bytes", + text.len() + ); + assert!( + text.len() + <= "invalid max-age value: ".len() + ERROR_VALUE_MAX_BYTES + "...[truncated]".len() + ); + } + + #[test] + fn invalid_value_truncation_lands_on_char_boundary() { + let raw = format!("{}{}", "x".repeat(511), "é".repeat(60)); + let err = parse_secs(false, &raw).unwrap_err(); + assert!(err.to_string().ends_with("...[truncated]")); + } + #[test] fn cache_meta_serde_roundtrip() { let meta = CacheMeta { From f9acf14d3545eb055ee0c0d57e6b38f14e29505d Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 03:30:42 +0200 Subject: [PATCH 053/111] perf: skip the fresh-hit meta clone and compress policy docs --- crates/gpui-query-http/src/cache.rs | 3 ++- crates/gpui-query-http/src/lib.rs | 11 +++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index ee0c161..bef50b9 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -76,7 +76,8 @@ impl<B: HttpBackend> HttpCache<B> { && meta.stored_at.checked_add(meta.fresh_for).is_none_or(|t| t > SystemTime::now()) && let Some(body) = self.cached_body(url)? { - return Ok((body, policy_from_meta(meta), cached_meta.clone())); + let policy = policy_from_meta(meta); + return Ok((body, policy, cached_meta)); } let conditionals = Conditionals::from_meta(cached_meta.as_ref()); diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 6fd4788..7cbb34a 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -64,12 +64,11 @@ pub enum ParseError { } /// Derives a [`CachePolicy`] from response `Cache-Control` headers ("server -/// wins"): `no-store`/`no-cache` anywhere wins regardless of position (RFC -/// 9111 §5.2.2); otherwise the first `max-age` sets the TTL, falling back to -/// `s-maxage` only when absent (private cache; §5.2.2.10), and a -/// `stale-while-revalidate` alongside yields the SWR policy. Duplicates keep -/// their first occurrence (§4.2.1); over-large delta-seconds saturate -/// (§1.2.2); malformed values error as [`ParseError`]. +/// wins"): `no-store`/`no-cache` from any position wins (RFC 9111 §5.2.2), +/// otherwise the first `max-age` (`s-maxage` as private-cache fallback, +/// §5.2.2.10) with `stale-while-revalidate` if present; duplicates keep the +/// first occurrence (§4.2.1), over-large delta-seconds saturate (§1.2.2), +/// malformed values error as [`ParseError`]. pub fn cache_policy_from_headers(headers: &HeaderMap) -> Result<CachePolicy, ParseError> { // Option<Result>: first occurrence wins; errors surface only after the scan. let mut s_maxage: Option<Result<u64, ParseError>> = None; From 059b9d719644b7f3457ffc606f886389a4dc61ad Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 03:51:21 +0200 Subject: [PATCH 054/111] fix: qualify 304 refresh doc and trim redundant scope block --- crates/gpui-query-http/src/cache.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index bef50b9..25bc25c 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -60,8 +60,8 @@ impl<B: HttpBackend> HttpCache<B> { /// Fetches `url`: a fresh entry skips the network, a stale one /// revalidates. Returns `(body, policy, meta)`: only a cacheable `200` /// stores and yields `meta`, a `304` re-serves the cached body and - /// restarts its freshness window, and everything else is - /// [`CachePolicy::NoCache`] with `None`. + /// refreshes the stored entry unless its own `Cache-Control` blocks + /// caching, and everything else is [`CachePolicy::NoCache`] with `None`. pub async fn fetch( &self, url: &str, @@ -98,10 +98,8 @@ impl<B: HttpBackend> HttpCache<B> { if let Some(old) = cached_meta.as_ref() && let Some(meta) = refreshed_meta(&resp.headers, old) { - { - let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; - guard.insert(url.to_string(), meta.clone()); - } + let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; + guard.insert(url.to_string(), meta.clone()); return Ok((body, policy_from_meta(&meta), Some(meta))); } let policy = cached_meta From bbcdbb53f8eade0f8ed274668ac0173e44dbc075 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 04:18:57 +0200 Subject: [PATCH 055/111] fix: fsync parent dir for bare filenames and drop unsafe fsync ffi --- crates/gpui-query-persist/src/lib.rs | 52 ++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 3ead3d8..decbbe5 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -76,11 +76,8 @@ impl FilePersister { .lock() .map_err(|_| PersistError::Permission("write lock poisoned".to_string()))?; - if let Some(parent) = self.path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent)?; - } + let parent = effective_parent(&self.path); + fs::create_dir_all(parent)?; let bytes: Vec<u8> = match self.format { PersistFormat::Json => serde_json::to_vec(snapshot)?, @@ -93,7 +90,6 @@ impl FilePersister { } }; - let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); let mut tmp = tempfile::Builder::new() .prefix( self.path @@ -281,16 +277,10 @@ fn try_fullfsync(file: &File) { /// fsync the parent directory so the rename is durable across power loss. #[cfg(unix)] fn fsync_parent(parent: &Path) { - use std::os::fd::AsRawFd; match OpenOptions::new().read(true).open(parent) { Ok(dir) => { - unsafe extern "C" { - fn fsync(fd: std::ffi::c_int) -> std::ffi::c_int; - } - // SAFETY: fd is a valid open directory file descriptor. - let rc = unsafe { fsync(dir.as_raw_fd()) }; - if rc != 0 { - eprintln!("FilePersister: parent-dir fsync failed"); + if let Err(e) = dir.sync_all() { + eprintln!("FilePersister: parent-dir fsync failed: {e}"); } } Err(e) => { @@ -299,6 +289,13 @@ fn fsync_parent(parent: &Path) { } } +/// A bare filename has `parent() == Some("")`, which `open`/`create_dir_all` reject or skip. +fn effective_parent(path: &Path) -> &Path { + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + #[cfg(test)] mod tests { use super::*; @@ -333,4 +330,31 @@ mod tests { "corrupt inner JSON -> empty snapshot" ); } + + #[test] + fn bincode_corrupt_meta_json_is_tolerated() { + let adapter = BincodeSnapshot { + entries: HashMap::from([( + "users::42".to_string(), + BincodeEntry { + value_json: "null".to_string(), + cached_at: 0, + cache_policy: CachePolicy::NoCache, + meta_json: Some("{ this is not valid json".to_string()), + }, + )]), + version: PERSIST_VERSION, + }; + let bytes = bincode::serialize(&adapter).expect("serialize adapter frame"); + assert!(bincode_load(&bytes).is_err()); + } + + #[test] + fn effective_parent_of_bare_filename_is_dot() { + assert_eq!(effective_parent(Path::new("cache.json")), Path::new(".")); + assert_eq!( + effective_parent(Path::new("a/b/cache.json")), + Path::new("a/b") + ); + } } From 0ed79540a6481cb3a392b3f2ab4ab5724db660ad Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 04:19:05 +0200 Subject: [PATCH 056/111] test: cover hostile and truncated cache files on load --- .../tests/file_persister.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index 16376a7..ccaaf8e 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -293,6 +293,75 @@ fn file_persister_cache_file_is_owner_only() { ); } +#[test] +fn file_persister_non_utf8_json_yields_empty_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.json"); + std::fs::write(&path, [0xFF, 0xFE, b'{', b'}']).expect("write non-utf8 bytes"); + + let p = FilePersister::json(&path); + let loaded = pollster::block_on(p.load()).expect("tolerant load"); + assert!(loaded.entries.is_empty(), "non-UTF8 cache -> empty snapshot"); +} + +#[test] +fn file_persister_deeply_nested_json_yields_empty_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.json"); + let mut deep = Vec::with_capacity(20_000); + deep.extend(std::iter::repeat_n(b'[', 10_000)); + deep.extend(std::iter::repeat_n(b']', 10_000)); + std::fs::write(&path, deep).expect("write deep nesting"); + + let p = FilePersister::json(&path); + let loaded = pollster::block_on(p.load()).expect("tolerant load"); + assert!( + loaded.entries.is_empty(), + "10k-deep nesting -> empty snapshot" + ); +} + +#[test] +fn file_persister_truncated_bincode_yields_empty_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.bin"); + let p = FilePersister::bincode(&path); + + pollster::block_on(p.save(&sample_snapshot())).expect("save"); + let full = std::fs::read(&path).expect("read saved"); + + for cut in [full.len() / 2, full.len() - 1] { + std::fs::write(&path, &full[..cut]).expect("write truncated"); + let loaded = pollster::block_on(p.load()).expect("tolerant load"); + assert!( + loaded.entries.is_empty(), + "truncated at {cut} of {} bytes -> empty snapshot", + full.len() + ); + } +} + +#[test] +fn file_persister_hostile_bincode_length_claims_yield_empty_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cache.bin"); + + let count_claim = u64::MAX.to_le_bytes().to_vec(); + let mut string_len_claim = 1u64.to_le_bytes().to_vec(); + string_len_claim.extend_from_slice(&u64::MAX.to_le_bytes()); + + for (claim, bytes) in [("map count", count_claim), ("string length", string_len_claim)] { + std::fs::write(&path, bytes).expect("write hostile frame"); + + let p = FilePersister::bincode(&path); + let loaded = pollster::block_on(p.load()).expect("tolerant load"); + assert!( + loaded.entries.is_empty(), + "hostile {claim} claim -> empty snapshot without unbounded allocation" + ); + } +} + #[test] fn file_persister_second_instance_overwrite_stays_parseable() { let dir = tempfile::tempdir().expect("tempdir"); From e239002df89f2c2b80ab2b934d1f6e963f7d95b5 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 14:17:41 +0200 Subject: [PATCH 057/111] fix: import AppContext for the release-only query fallback --- crates/gpui-query/src/hook/query_hooks.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index e44a5f8..8346cb4 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -3,6 +3,8 @@ //! `WeakEntity`, so it self-terminates on entity drop. use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; +#[cfg(not(debug_assertions))] +use gpui::AppContext as _; use crate::client::{QueryClient, QueryObserver}; use crate::core::{ From c9c23bfcfe7dfabd0b8b8450ce30a62e591da287 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 14:17:43 +0200 Subject: [PATCH 058/111] test: nest deep-nesting fixture inside an entry value --- crates/gpui-query-persist/tests/file_persister.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index ccaaf8e..f6f734f 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -308,16 +308,20 @@ fn file_persister_non_utf8_json_yields_empty_snapshot() { fn file_persister_deeply_nested_json_yields_empty_snapshot() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("cache.json"); - let mut deep = Vec::with_capacity(20_000); + let mut deep = Vec::with_capacity(20_091); + deep.extend_from_slice(br#"{"entries":{"k":{"value":"#); deep.extend(std::iter::repeat_n(b'[', 10_000)); deep.extend(std::iter::repeat_n(b']', 10_000)); + deep.extend_from_slice( + br#","cached_at":0,"cache_policy":"NoCache","meta":null}},"version":1}"#, + ); std::fs::write(&path, deep).expect("write deep nesting"); let p = FilePersister::json(&path); let loaded = pollster::block_on(p.load()).expect("tolerant load"); assert!( loaded.entries.is_empty(), - "10k-deep nesting -> empty snapshot" + "10k-deep nesting inside an entry value -> empty snapshot" ); } From 2c2d50906466367c3c7c624a32b80281c9ccab0a Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 14:47:43 +0200 Subject: [PATCH 059/111] docs: correct drifted claims in readmes and skills prose --- README.md | 39 ++++----- crates/gpui-query-http/README.md | 4 +- crates/gpui-query/README.md | 21 ++--- skills/gpui-query-extensions/SKILL.md | 119 ++++++++++++-------------- skills/gpui-query/SKILL.md | 114 ++++++++++++------------ 5 files changed, 139 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index cbf667d..83f98c7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Fetch, cache, and synchronize async data in GPUI applications without hand-rolli GPUI renders synchronously on the main thread. That makes async data awkward: you have to track loading states, handle errors, cache responses, deduplicate concurrent requests, and retry on failure. gpui-query handles all of it. -You write a fetcher function. The library manages caching, retry, deduplication, stale-while-revalidate, garbage collection, and cooperative cancellation, on top of GPUI's `Entity` and `ViewContext` system. +You write a fetcher function. The library manages caching, retry, deduplication, stale-while-revalidate, garbage collection, and cooperative cancellation on top of GPUI's `Entity` system. The API mirrors TanStack Query: `use_query`, `use_mutation`, and `use_infinite_query` hooks that return `Entity` handles you read from in your view's `render` method. @@ -37,7 +37,7 @@ To use only the core state machine with no GPUI dependency: gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` -The `core` layer also builds for `wasm32-unknown-unknown`. The wasm-specific setup (ahash switches to compile-time RNG on wasm targets) is handled internally, so there is nothing to configure. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. +The `core` layer also builds for `wasm32-unknown-unknown`; the wasm-specific setup (ahash switches to compile-time RNG on wasm targets) is handled internally. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for wasm. ## quick start @@ -58,7 +58,7 @@ Fetch data with `use_query`: ```rust use gpui_query::{use_query, QueryOptions}; -fn setup_query(cx: &mut ViewContext<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { +fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { use_query( "users", |signal| async move { @@ -74,17 +74,12 @@ fn setup_query(cx: &mut ViewContext<MyView>) -> (Entity<QueryResource<Vec<User>, Read the state in your render method: ```rust -fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { - let entity = self.query_entity.clone(); - entity.read_with(cx, |resource| { - match resource.status() { - QueryStatus::LoadingEmpty => "Loading...", - QueryStatus::Success => "Got data", - QueryStatus::Failure => "Error", - _ => "Idle", - } - }) -} +let label = self.query_entity.read_with(cx, |resource| match resource.status() { + QueryStatus::LoadingEmpty => "Loading...", + QueryStatus::Success => "Got data", + QueryStatus::Failure => "Error", + _ => "Idle", +}); ``` ## architecture @@ -128,7 +123,7 @@ let (entity, sub) = use_query( ); ``` -`QueryResource<T,E>` tracks the full lifecycle: idle, loading (with or without previous data), success, failure, or cancelled. You get `data()`, `error()`, `status()`, `is_loading()`, `has_data()`, `display_data()` (returns data or a placeholder), `cache_age_ms()`, and `retry_count()`. +`QueryResource<T,E>` tracks the full lifecycle: idle, loading (with or without previous data), success, failure, or cancelled. You get `data()`, `error()`, `status()`, `is_loading()`, `has_data()`, `cache_age_ms(now_ms)`, and `retry_count()`. For manual control with no auto-fetch, use `use_query_manual` and trigger fetches with `fetch_query` when you're ready. @@ -163,10 +158,10 @@ Mutations track their own state in `MutationResource<V,T,E>` with a begin/comple For paginated data. The fetcher receives the last page (or `None` for the first request) and returns `(page_data, has_more)`. ```rust -use gpui_query::{use_infinite_query, InfiniteQueryOptions, QueryKey}; +use gpui_query::{use_infinite_query, InfiniteQueryOptions}; let (entity, sub) = use_infinite_query( - InfiniteQueryOptions::new(QueryKey::from(["feed"])).max_pages(Some(10)), + InfiniteQueryOptions::new("feed").max_pages(10), |last_page| async move { let cursor = last_page.map(|p| p.cursor()); let page = fetch_page(cursor).await?; @@ -195,11 +190,11 @@ let client = cx.global::<QueryClient>(); client.invalidate_queries(&QueryKeyFilter::Prefix(&QueryKey::from(["users"])), cx); // Remove everything -client.remove_queries(&QueryKeyFilter::All, cx); +client.remove_queries(&QueryKeyFilter::All); -// Optimistic update -client.set_query_data::<Vec<User>, MyError>(&key, Some(vec![new_user]), cx); -client.rollback_query_data::<Vec<User>, MyError>(&key, cx); +// Optimistic update; the previous value is kept for the resource's +// rollback_to_previous() +client.set_query_data::<Vec<User>, MyError>(&key, vec![new_user], cx); ``` Invalidation matching supports `Exact`, `Prefix`, and `All` filters via `QueryKeyFilter`. @@ -255,7 +250,7 @@ Only `Success` entries with a registered serializer are persisted; the typed rou `QueryError::sanitized()` redacts connection strings, bearer tokens, file paths, emails, and hex keys from error messages. Useful for logging without leaking secrets. -`use_query_select` projects a `QueryResource<T,E>` through a `SelectTransform<T,U>` to produce a `MappedQueryResource` that derives values from cached data. No extra fetches are needed. +`use_query_select` projects a `QueryResource<T,E>` through a `SelectTransform<T,U>` to produce a `MappedQueryResource` that derives values from cached data. `ClientDiagnostic`, `QueryDiagnostic`, and `MutationDiagnostic` give you runtime introspection of the query client's internal state for debugging. diff --git a/crates/gpui-query-http/README.md b/crates/gpui-query-http/README.md index 357a205..dc6e5ab 100644 --- a/crates/gpui-query-http/README.md +++ b/crates/gpui-query-http/README.md @@ -25,10 +25,10 @@ The usage examples also reference `gpui-query` (for `core::{CachePolicy, Fetched ## What it does - Header → policy ("server wins"): `cache_policy_from_headers` reads RFC 9111 `Cache-Control` directives and returns the matching `gpui_query::core::CachePolicy`. -- In-memory HTTP cache: `HttpCache<B>` wraps any `HttpBackend`. Fresh entries skip the network entirely, stale entries revalidate with `If-None-Match` / `If-Modified-Since`, and a `304 Not Modified` refreshes the entry without transferring a body. +- In-memory HTTP cache: `HttpCache<B>` wraps any `HttpBackend`. Fresh entries skip the network entirely, stale entries revalidate with `If-None-Match` / `If-Modified-Since`, and a `304 Not Modified` re-serves the cached body and refreshes the stored entry unless its own `Cache-Control` blocks caching. - Pluggable backend: `HttpBackend` abstracts a single conditional `GET`. The crate ships `ReqwestBackend` behind the `reqwest` feature; any other client can implement the trait and feed `HttpCache::new`. - Serializable metadata: `CacheMeta` (ETag, `Last-Modified`, `stored_at`, `fresh_for`, `stale_for`) is serde-serializable, so it round-trips through a persistence layer for cheap `304` refetches on cold start. -- Typed errors: `ParseError` (`InvalidMaxAge`, `InvalidStaleWhileRevalidate`) for malformed directives, `HttpError` for backend failures, poisoned mutexes, and spurious `304`s. A malformed `Cache-Control` never fails a fetch: `HttpCache::fetch` serves the body uncacheable and stores nothing. `ParseError` (wrapped as `HttpError::InvalidPolicy`) surfaces only for direct callers of `cache_policy_from_headers`. +- Typed errors: `ParseError` (`InvalidMaxAge`, `InvalidStaleWhileRevalidate`) for malformed directives, `HttpError` for backend failures, poisoned mutexes, and spurious `304`s. A malformed `Cache-Control` never fails a fetch: `HttpCache::fetch` serves the body uncacheable and stores nothing. `ParseError` surfaces only for direct callers of `cache_policy_from_headers`. Parsing rules (priority order): diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 6852c01..be58f18 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -27,7 +27,7 @@ If you only want the core state machine without pulling in GPUI: gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } ``` -The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only because they depend on `gpui`, which does not build for `wasm32-unknown-unknown`. +The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only because they depend on `gpui`, which does not build for wasm. ## Quick start @@ -48,7 +48,7 @@ Create a query in your view: ```rust use gpui_query::{use_query, QueryOptions}; -fn setup_query(cx: &mut ViewContext<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { +fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { use_query( "users", |signal| async move { @@ -63,17 +63,12 @@ fn setup_query(cx: &mut ViewContext<MyView>) -> (Entity<QueryResource<Vec<User>, Read the state in `render`: ```rust -fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { - let entity = self.query_entity.clone(); - entity.read_with(cx, |resource| { - match resource.status() { - QueryStatus::LoadingEmpty => "Loading...", - QueryStatus::Success => "Got data", - QueryStatus::Failure => "Error", - _ => "Idle", - } - }) -} +let label = self.query_entity.read_with(cx, |resource| match resource.status() { + QueryStatus::LoadingEmpty => "Loading...", + QueryStatus::Success => "Got data", + QueryStatus::Failure => "Error", + _ => "Idle", +}); ``` ## Feature layers diff --git a/skills/gpui-query-extensions/SKILL.md b/skills/gpui-query-extensions/SKILL.md index b8ec106..cb8efac 100644 --- a/skills/gpui-query-extensions/SKILL.md +++ b/skills/gpui-query-extensions/SKILL.md @@ -7,9 +7,9 @@ description: Use when adding HTTP cache-header handling (RFC 9111 Cache-Control The main crate ships an in-memory, type-partitioned cache with GC, invalidation, and TTL/SWR policies. Two **satellite crates** + one **main-crate feature** add cross-restart durability and server-driven HTTP cache semantics. Everything here is strictly additive over `client`. -- `gpui-query-http` (v0.1.0) — RFC 9111 `Cache-Control` → `CachePolicy` ("server wins"), plus a URL-keyed in-memory `HttpCache<B>` for cheap `304` revalidations. **GPUI-free** (depends on `core` only). -- `gpui-query-persist` (v0.1.0) — reference atomic-durable `FilePersister`. -- main crate `persist` feature — the async `Persister` trait, `persist_with` debounced driver, `hydrate`, and the typed serializer/deserializer registries. +- `gpui-query-http` (v0.1.0): RFC 9111 `Cache-Control` → `CachePolicy` ("server wins"), plus a URL-keyed in-memory `HttpCache<B>` for cheap `304` revalidations. **GPUI-free** (depends on `core` only). +- `gpui-query-persist` (v0.1.0): reference atomic-durable `FilePersister`. +- main crate `persist` feature: the async `Persister` trait, `persist_with` debounced driver, `hydrate`, and the typed serializer/deserializer registries. Reach for these when: - the app should **survive a restart** with its cached data primed (cold start shows last-known-good instead of a loading spinner); @@ -22,7 +22,7 @@ Do NOT reach for them for ephemeral in-memory state, or if you only need client- ```toml [dependencies] gpui = "0.2.2" -gpui-query = { version = "0.2.0", features = ["persist"] } # enables persist layer +gpui-query = { version = "0.2.1", features = ["persist"] } # enables persist layer # HTTP cache (optional reqwest backend): gpui-query-http = { version = "0.1", features = ["reqwest"] } # drop "reqwest" to use your own HttpBackend @@ -31,7 +31,7 @@ gpui-query-http = { version = "0.1", features = ["reqwest"] } # drop "reqwest" t gpui-query-persist = "0.1" ``` -The `persist` feature is `client + hook + dep:serde_json + dep:thiserror`. `gpui-query-persist` hard-depends on `persist + client + hook`. `gpui-query-http`'s `reqwest` feature pulls `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }`. With `default-features = false`, reqwest drops its own defaults (`default-tls` = native-tls, plus `charset`, `http2`, `macos-system-configuration`) and only rustls TLS is added back. Cookies and gzip/brotli/deflate compression are opt-in reqwest features that were **never** on by default — enable them on your own `reqwest::Client` if you need them. +The `persist` feature is `client + hook + dep:serde_json + dep:thiserror`. `gpui-query-persist` hard-depends on `persist + client + hook`. `gpui-query-http`'s `reqwest` feature pulls `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }`. With `default-features = false`, reqwest drops its own defaults (`default-tls` = native-tls, plus `charset`, `http2`, `macos-system-configuration`) and only rustls TLS is added back. Cookies and gzip/brotli/deflate compression are opt-in reqwest features that were **never** on by default; enable them on your own `reqwest::Client` if you need them. macOS note: building `client`/`hook`/`persist` needs the Metal Toolchain once (`xcodebuild -downloadComponent MetalToolchain`). `gpui-query-http` (core-only) needs nothing extra. @@ -45,9 +45,9 @@ register (de)serializers ──► hydrate() at cold start ──► persist re-prime concrete T) CacheMutation bump) ``` -1. **Register** a serializer and deserializer per resource data type `T`. Only resources with a registered serializer are emitted into the snapshot; unregistered types fall back to metadata-only (skipped). +1. **Register** a serializer and deserializer per resource data type `T`. Only resources with a registered serializer are emitted into the snapshot; unregistered types are skipped. 2. **`hydrate()`** at startup: load the snapshot, offer each on-disk entry to every registered deserializer, prime matches via `set_query_data::<T, E>`. -3. **`persist_with()`**: install a drop-guard driver. Every `CacheMutation` bump (a query/mutation resolving, `set_query_data`, `invalidate`, GC eviction) collects a fresh snapshot on the main thread, stashes it in a single pending slot, and spawns a debounced `save` on GPUI's `background_executor`. Bursts coalesce — only the latest snapshot survives the window. +3. **`persist_with()`**: install a drop-guard driver. The first `CacheMutation` bump in a window (a query/mutation resolving, `set_query_data`, invalidation, GC eviction) arms a task that waits out the debounce, then collects a fresh snapshot on the main thread and saves it on GPUI's `background_executor`. Bumps arriving while a task is armed are free; every save reflects the latest state. The snapshot value is an opaque `serde_json::Value`; the typed round-trip is driven by the registries, so core never needs a `T: Serialize` bound. @@ -67,7 +67,7 @@ pub enum PersistError { #[error("persistence serialize error: {0}")] Serialize(#[from] serde_json::Error), #[error("persistence deserialize error: {0}")] - Deserialize(String), // reserved for backends that surface parse errors + Deserialize(String), // for persisters that decline to tolerate a parse failure #[error("persistence version mismatch: expected {expected}, found {found}")] VersionMismatch { expected: u32, found: u32 }, #[error("persistence bad path: {0}")] @@ -102,7 +102,7 @@ pub trait Persister: Send + Sync + 'static { } ``` -`PersistHandle` is the drop-guard returned by `persist_with`. Holding it keeps the `CacheMutation` observation (and thus the debounced save loop) alive; **dropping it drops the `Subscription`**, so no new saves are scheduled. A save already parked on its debounce timer is detached and may still complete one final save. `PersistHandle::empty()` constructs a no-op handle (tests). +`PersistHandle` is the drop-guard returned by `persist_with`. Holding it keeps the `CacheMutation` observation (and thus the debounced save loop) alive; **dropping it drops the `Subscription`**, so no new saves are scheduled. A task already parked on its debounce timer still completes its final collect and save. `PersistHandle::empty()` constructs a no-op handle (tests). --- @@ -135,7 +135,7 @@ pub enum PersistFilter { // owned counterpart to core's borrowing QueryKey impl PersistFilter { pub fn matches(&self, key: &QueryKey) -> bool; } ``` -The observer callback collects a snapshot on the main thread (cheap; has `&App`), stashes it in a shared `Mutex<Option<PersistSnapshot>>` slot (replacing any pending one), then spawns a debounced task on the `background_executor` that, after `opts.debounce`, drains the slot and runs `persister.save`. An `armed` flag bounds in-flight tasks to one per window — a bump arriving while a task is already armed skips spawning (its snapshot still lands in the slot, drained by the armed task). Latest snapshot wins. +The observer callback arms at most one task per debounce window (`armed` flag). The armed task waits out `opts.debounce` on the `background_executor` timer, then collects a fresh snapshot inside a main-thread `update_global` (entity reads need `&App`) and hands it to `persister.save` on the background executor. Because collection happens at drain time, a burst of bumps coalesces into one save of the latest state. --- @@ -151,9 +151,9 @@ pub async fn hydrate<P: Persister>( ) -> Result<PersistSnapshot, PersistError>; ``` -Loads the snapshot, double-checks `version == PERSIST_VERSION` (returns `VersionMismatch` otherwise), then for **every** registered deserializer walks **every** entry and lets the step decode + prime it. Returns the post-filter snapshot so you can do additional metadata-only priming or diagnostics. +Loads the snapshot, double-checks `version == PERSIST_VERSION` (returns `VersionMismatch` otherwise), then for **every** registered deserializer walks **every** entry and lets the step decode + prime it. Returns the loaded snapshot so you can inspect entries or prime types with no registered deserializer. -> Name collision: there is also a legacy `QueryClient::hydrate(&mut self, _state, _cx)` **method** (metadata-only, no-op stub — see Legacy tier below). The value-carrying primitive is the **free function** `hydrate(...)`. +> Name collision: there is also a legacy `QueryClient::hydrate(&mut self, _state, _cx)` **method** (metadata-only, no-op stub; see Legacy tier below). The value-carrying primitive is the **free function** `hydrate(...)`. ```rust impl QueryClient { @@ -169,9 +169,9 @@ impl QueryClient { } ``` -- `f`/`deserialize` are **plain `fn` pointers** (not closures) — `Send + Sync + 'static` with no boxing at the call site. The registry boxes each pointer internally (`Box<dyn Fn>` / `Arc<dyn Fn>`) for type-erased storage — one allocation per registered type, not per save. +- `f`/`deserialize` are **plain `fn` pointers** (not closures): `Send + Sync + 'static` with no boxing at the call site. The registry boxes each pointer internally (`Box<dyn Fn>` / `Arc<dyn Fn>`) for type-erased storage, one allocation per registered type, not per save. - `SerializerRegistry` keys on `TypeId::of::<T>()` alone (not `(T, E)`), because the bucket impls look up by `T`. Serialization depends only on the data type. **Registering two serializers for the same `T` under different `E` silently overwrites** (last wins); whichever survives applies to both `(T, E)` buckets, which is correct because the value *is* that `T`. -- `DeserializerRegistry` is a `Vec<(TypeId, step)>`. **`hydrate` offers each entry to every deserializer** — O(deserializers × entries). There is no type discriminator on `PersistedEntry`, so routing is by trial. +- `DeserializerRegistry` is a `Vec<(TypeId, step)>`. **`hydrate` offers each entry to every deserializer**, O(deserializers × entries). There is no type discriminator on `PersistedEntry`, so routing is by trial. - **Strict-deserializer contract:** a deserializer MUST return `None` for any JSON shape it does not recognize as its own `T`. A lax decoder that accepts a foreign shape wastes work and can mis-prime. Keep them strict and cheap (`v.as_str().map(...)` for a string, `serde_json::from_value(v).ok()` for a struct). --- @@ -201,12 +201,12 @@ impl Persister for FilePersister { async fn load(&self) -> Result<PersistSnapsho |---|---| | Missing file | empty snapshot (no error) | | Corrupt / unparseable | logged via `eprintln!` + **empty snapshot** (no panic) | -| `version != PERSIST_VERSION` | `Err(PersistError::VersionMismatch { expected, found })` — typed, so you can distinguish corrupt from wrong-format | +| `version != PERSIST_VERSION` | `Err(PersistError::VersionMismatch { expected, found })`, typed so you can distinguish corrupt from wrong-format | | Valid | decoded snapshot | -**Error mapping:** Windows `ERROR_ACCESS_DENIED` during the atomic replace (antivirus / concurrent reader) maps to `PersistError::Permission` (retryable — back off and retry). Every other IO failure is `PersistError::Io` with the original `std::io::Error` (kind + source chain intact). +**Error mapping:** Windows `ERROR_ACCESS_DENIED` during the atomic replace (antivirus / concurrent reader) maps to `PersistError::Permission` (retryable: back off and retry). Every other IO failure is `PersistError::Io` with the original `std::io::Error` (kind + source chain intact). -`save`/`load` do **synchronous `std::fs` I/O** in their async bodies — intended for GPUI's `background_executor` (a blocking-friendly pool). On a tokio multi-thread runtime, wrap in `spawn_blocking`. **Bincode format** JSON-encodes each entry's `value` to a `String` inside a bincode-safe adapter (bincode can't drive `serde_json::Value`'s `deserialize_any`); the conversion is lossless. +`save`/`load` do **synchronous `std::fs` I/O** in their async bodies, intended for GPUI's `background_executor` (a blocking-friendly pool). On a tokio multi-thread runtime, wrap in `spawn_blocking`. **Bincode format** JSON-encodes each entry's `value` to a `String` inside a bincode-safe adapter (bincode can't drive `serde_json::Value`'s `deserialize_any`); the conversion is lossless. `pub use gpui_query::client::NoopPersister;` is re-exported from this crate as a one-stop default/test persister. @@ -232,7 +232,7 @@ struct Users(Vec<User>); // Global so the bootstrap task can install the client before any view reads it. struct ClientGlobal(QueryClient); -impl Global for ClientGlobal; +impl Global for ClientGlobal {} fn bootstrap(cx: &mut App) { let mut client = QueryClient::new(); @@ -242,32 +242,23 @@ fn bootstrap(cx: &mut App) { client.register_deserializer::<Users, QueryError>(|v| serde_json::from_value(v.clone()).ok()); let persister = FilePersister::json("/var/cache/myapp/gpui-query-cache.json"); - let max_age = Duration::from_secs(60 * 60 * 24); - let filter = PersistFilter::All; - - // 2. Cold start: hydrate primes the live cache from disk. Block on it from - // a background task; GPUI's background_executor is blocking-friendly. - cx.background_executor().spawn({ - let persister = persister; // FilePersister is Send + Sync + Clone-free - async move { - // NOTE: hydrate needs &mut QueryClient + &mut App — drive it in a - // cx.update_global lease, not detached across an await holding a guard. - } - }).detach(); + let opts = PersistOptions { + filter: PersistFilter::All, + max_age: Duration::from_secs(60 * 60 * 24), + ..PersistOptions::default() + }; + + // 2. Cold start: hydrate primes the live cache from disk. It needs + // &mut QueryClient + &mut App, so drive it on the main thread inside a + // global lease with a blocking executor (pollster::block_on, or a test + // clock), before any view reads the client: + // hydrate(&mut client, &persister, &opts.filter, opts.max_age, cx) cx.set_global(ClientGlobal(client)); - // (Hydrate must run inside a global lease that owns &mut App. The typical - // shape is a blocking `cx.run` / `block_on` for the ready load, or a - // spawn that re-enters via update_global. See the hydrate signature above.) - // 3. Install the debounced save driver. Hold the handle for the app lifetime. let _handle = cx.update_global::<ClientGlobal, _>(|ClientGlobal(client), cx| { - client.persist_with( - persister, - PersistOptions { filter, max_age, ..PersistOptions::default() }, - cx, - ) + client.persist_with(persister, opts, cx) }); } ``` @@ -307,10 +298,10 @@ response headers ──► cache_policy_from_headers() ──► CachePolicy ─ Two independent pieces: -1. **`cache_policy_from_headers`** — pure header → `CachePolicy` ("server wins"). Hand the result to `Fetched::with_policy(data, policy)` so the resource adopts the server's TTL. -2. **`HttpCache<B>`** — a URL-keyed in-memory layer over any `HttpBackend`. Fresh entries short-circuit the network; stale entries revalidate with `If-None-Match` / `If-Modified-Since`; a `304` re-serves the cached body without transferring a new one. This is the cheap-revalidation cache that lives *inside* your fetcher, orthogonal to gpui-query's own resource cache. +1. **`cache_policy_from_headers`**: pure header → `CachePolicy` ("server wins"). Hand the result to `Fetched::with_policy(data, policy)` so the resource adopts the server's TTL. +2. **`HttpCache<B>`**: a URL-keyed in-memory layer over any `HttpBackend`. Fresh entries short-circuit the network; stale entries revalidate with `If-None-Match` / `If-Modified-Since`; a `304` re-serves the cached body without transferring a new one. This is the cheap-revalidation cache that lives *inside* your fetcher, orthogonal to gpui-query's own resource cache. -`CacheMeta` is `Serialize + Deserialize` so a future persistence layer can store it alongside the body and rehydrate a cold start with valid `ETag`s — enabling cheap `304` refetches on the first request after launch. It uses `SystemTime` (epoch-relative, serde-supported), **never** `Instant` (no serde, meaningless across restarts). +`CacheMeta` is `Serialize + Deserialize` so a persistence layer can store it alongside the body and rehydrate a cold start with valid `ETag`s for cheap `304` refetches right after launch. It uses `SystemTime` (epoch-relative, serde-supported), **never** `Instant` (no serde, meaningless across restarts). --- @@ -323,13 +314,13 @@ pub fn cache_policy_from_headers(headers: &http::HeaderMap) Priority order (from [RFC 9111]): -1. **`no-store` / `no-cache`** (any value, including bare) → `CachePolicy::NoCache`. Short-circuits immediately — a malformed trailing directive (e.g. `no-store, max-age=abc`) does NOT surface a parse error. -2. **`s-maxage=N`** (shared-cache directive) takes precedence over `max-age=N` when both present. The chosen TTL yields `CachePolicy::Ttl { ttl_ms: N*1000 }`. If `stale-while-revalidate=M` is also present, yields `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms: M*1000 }` instead. +1. **`no-store` / `no-cache`** (any value, including bare) → `CachePolicy::NoCache`. Short-circuits immediately: a malformed directive elsewhere in the header (e.g. `no-store, max-age=abc`) does NOT surface a parse error. +2. **`max-age=N`** (seconds) → `CachePolicy::Ttl { ttl_ms: N*1000 }`; add `stale-while-revalidate=M` → `CachePolicy::StaleWhileRevalidate { ttl_ms, stale_ms: M*1000 }`. When `max-age` and `s-maxage` are both present, `max-age` wins (private cache; RFC 9111 §5.2.2.10 scopes `s-maxage` to shared caches); `s-maxage` alone still sets the TTL. 3. **Otherwise** → `CachePolicy::NoCache`. There is **no `Expires`-based heuristic** (reserved for a later addition). Directive names are matched **case-insensitively**; values may be quoted (`max-age="600"`). Multiple `Cache-Control` headers combine. Other directives (`public`, `private`, …) are ignored unless they map to a rule above. -`ParseError { InvalidMaxAge(String), InvalidStaleWhileRevalidate(String) }` — only a malformed TTL value (e.g. `max-age=abc`) produces an error; wrap with `.unwrap_or(CachePolicy::NoCache)` to degrade gracefully. +`ParseError { InvalidMaxAge(String), InvalidStaleWhileRevalidate(String) }`: only a malformed TTL value (e.g. `max-age=abc`) produces an error; wrap with `.unwrap_or(CachePolicy::NoCache)` to degrade gracefully. [RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111 @@ -358,11 +349,11 @@ pub struct BackendResponse { pub trait HttpBackend: Send + Sync { type Error: std::error::Error + Send + Sync + 'static; fn fetch(&self, url: &str, conditionals: Conditionals) - -> impl Future<Output = Result<BackendResponse, Self::Error>> + Send; + -> impl Future<Output = Result<BackendResponse, Self::Error>> + MaybeSend; } ``` -The trait uses `-> impl Future + Send` (not `async fn`) so the future is guaranteed `Send` for any executor. This makes it **non-object-safe** — dispatch is static via `HttpCache<B: HttpBackend>`, no `dyn`/`Pin<Box<dyn Future>>` overhead. +The trait uses `-> impl Future + MaybeSend` (not `async fn`). `MaybeSend` is exactly `Send` on native targets and a no-op on `wasm32`, so a native impl written with `+ Send` compiles unchanged while browser backends with `!Send` JS futures still fit. It also makes the trait **non-object-safe**; dispatch is static via `HttpCache<B: HttpBackend>`, no `dyn`/`Pin<Box<dyn Future>>` overhead. ```rust pub struct HttpCache<B: HttpBackend> { /* backend + two Mutex<HashMap> */ } @@ -380,8 +371,8 @@ impl<B: HttpBackend> HttpCache<B> { |---|---| | **Fresh hit** (`stored_at + fresh_for > now`) | return cached body immediately; **backend never called** | | Stale / first fetch | build `Conditionals::from_meta(cached)`, call `backend.fetch` | -| **`304 Not Modified`** | re-serve cached body + stored policy/meta; `Err(NotModifiedWithoutCachedBody)` if no cached body exists | -| **`200 OK`** | parse policy via `cache_policy_from_headers`; if `NoCache`, serve body + store nothing; else store body + meta, return fresh triple | +| **`304 Not Modified`** | re-serve the cached body and refresh the stored entry unless the 304's own `Cache-Control` blocks caching (`Err(NotModifiedWithoutCachedBody)` when no cached body exists) | +| **`200 OK`** | parse policy via `cache_policy_from_headers`; if `NoCache` or malformed, serve body + store nothing; else store body + meta, return fresh triple | | Any other status | return body + `CachePolicy::NoCache` + `None`; nothing stored | Only `200`s with a cacheable policy populate the cache. `meta` is `None` only for non-cacheable responses. @@ -400,7 +391,7 @@ pub enum HttpError { } ``` -**Concurrency:** state is guarded by two `std::sync::Mutex`es (one for meta, one for bodies). A guard is acquired, the needed value is **cloned out, and the guard dropped before any `.await` point** — so the cache never holds a `std` mutex across `.await`. It is `Send + Sync` and requires **no tokio** (works on GPUI's `background_executor`, tokio, or anything else). +**Concurrency:** state is guarded by two `std::sync::Mutex`es (one for meta, one for bodies). A guard is acquired, the needed value is **cloned out, and the guard dropped before any `.await` point**, so the cache never holds a `std` mutex across `.await`. It is `Send + Sync` and requires **no tokio** (works on GPUI's `background_executor`, tokio, or anything else). `CacheMeta` round-trips through persistence: a non-zero `stale_for` reconstructs `StaleWhileRevalidate`, a non-zero `fresh_for` reconstructs `Ttl`, both-zero collapses to `NoCache` (mirroring `fresh_for_from_policy` / `stale_for_from_policy`). @@ -419,11 +410,11 @@ impl ReqwestBackend { impl HttpBackend for ReqwestBackend { type Error = reqwest::Error; fn fetch(&self, url: &str, conditionals: Conditionals) - -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + Send; + -> impl Future<Output = Result<BackendResponse, reqwest::Error>> + MaybeSend; } ``` -The request is built **synchronously** (no `.await`) — `If-None-Match` / `If-Modified-Since` attached when present — then the `send` + `bytes()` half is returned as a `Send` future. This eager build keeps the future `Send` even where `reqwest::RequestBuilder` is `!Send`. The client is reused across requests (configure TLS provider, timeouts, proxies on the `reqwest::Client` before wrapping). +The request is built **synchronously** (no `.await`), with `If-None-Match` / `If-Modified-Since` attached when present; the `send` + `bytes()` half is returned as the future. This eager build keeps it `Send` on native targets even where `reqwest::RequestBuilder` is `!Send`. The client is reused across requests (configure TLS provider, timeouts, proxies on the `reqwest::Client` before wrapping). `reqwest` is *one* possible backend. Any client that can do a conditional `GET` and produce status + headers + body can `impl HttpBackend` and feed `HttpCache::new`. @@ -440,7 +431,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] struct Release { tag: String } -// One cache per app, shared across fetchers. Wrap it in an `Arc` — `HttpCache` +// One cache per app, shared across fetchers. Wrap it in an `Arc`: `HttpCache` // is NOT `Clone` (it holds `Mutex<HashMap>`s), so clone the `Arc`, not the cache. fn http_cache() -> std::sync::Arc<HttpCache<ReqwestBackend>> { std::sync::Arc::new(HttpCache::new(ReqwestBackend::from_client(reqwest::Client::new()))) @@ -451,7 +442,7 @@ fn fetch_releases(cx: &mut gpui::Context<impl 'static>) { let (entity, _sub) = use_query_with_policy::<Release, QueryError, _, _, _>( QueryOptions::new(["releases", "latest"]), move |_signal| { - let cache = cache.clone(); // cheap Arc clone — one per fetch invocation + let cache = cache.clone(); // cheap Arc clone, one per fetch invocation async move { // 1. HttpCache.fetch handles fresh short-circuit / 304 revalidation. let (body, policy, meta) = cache @@ -494,7 +485,7 @@ impl<T> Fetched<T> { --- -## Legacy tier (metadata-only) — steer away +## Legacy tier (metadata-only): steer away Alongside the value-carrying `Persister`, the main crate retains an older **metadata-only** persistence API (also gated behind `persist`), kept for back-compat. It carries **no data**: @@ -514,25 +505,25 @@ And on `QueryClient`: | Method | Behavior | |---|---| | `dehydrate(&self, cx: &App) -> DehydratedState` | collects key + `TypeId` + kind of `Success` entries only (**no data**) | -| `hydrate(&mut self, _state, _cx)` | **no-op stub** — body is empty; callers must iterate and `set_query_data` themselves | +| `hydrate(&mut self, _state, _cx)` | **no-op stub**: body is empty; callers must iterate and `set_query_data` themselves | | `persist(&self, &dyn QueryPersister, cx)` | dehydrates + saves entries (metadata-only) | | `restore(persister: &dyn QueryPersister) -> Vec<DehydratedEntry>` | associated fn (no `&self`); loads raw entries | -Prefer the **value-carrying** `Persister` + `persist_with` + free-fn `hydrate` for any new code — it round-trips real data through the serializer/deserializer registries. The legacy types exist only to avoid breaking the old `dehydrate`/`hydrate`/`persist`/`restore` surface. +Prefer the **value-carrying** `Persister` + `persist_with` + free-fn `hydrate` for any new code; it round-trips real data through the serializer/deserializer registries. The legacy types exist only to avoid breaking the old `dehydrate`/`hydrate`/`persist`/`restore` surface. --- ## Gotchas - **Strict deserializers are load-bearing.** `hydrate` offers every on-disk entry to every registered deserializer (O(n × m), no type discriminator). A permissive decoder that accepts a foreign shape will mis-prime the wrong bucket. Return `None` for anything that isn't unambiguously your `T`. -- **TypeId-only registry overwrites.** `register_serializer::<T, E_a>` then `register_serializer::<T, E_b>` for the same `T` silently overwrites — both are keyed on `TypeId::of::<T>()`. Last write wins, and it applies to both `(T, E)` buckets. This is correct (the value *is* that `T`) but surprises people expecting per-`(T, E)` keying. -- **Non-object-safe traits.** `Persister` and `HttpBackend` both return `impl Future + Send` and are consumed generically (`persist_with<P: Persister>`, `HttpCache<B: HttpBackend>`). You cannot `Box<dyn Persister>` or `Box<dyn HttpBackend>` — use an `enum` of backends or generic plumbing instead. -- **Mutex guards never cross `.await`.** Both `HttpCache` and `FilePersister` acquire a `std::sync::Mutex`, clone the value out, and drop the guard before yielding. If you write your own `Persister`/`HttpBackend`, do the same — holding a `std` mutex across `.await` is undefined behavior (the future is `Send` but the guard often is not) and trips on some runtimes. -- **Only `200`s are cached.** A `304` re-serves a *prior* `200` body; a `304` with no cached body is `Err(NotModifiedWithoutCachedBody)`. `no-store`/`no-cache` and non-`200`/`304` statuses store nothing. -- **`no-store` short-circuits parsing.** `no-store, max-age=abc` returns `Ok(NoCache)` — the malformed `max-age` is never reached. Only a malformed TTL *without* a preceding `no-store`/`no-cache` yields `InvalidMaxAge`. +- **TypeId-only registry overwrites.** `register_serializer::<T, E_a>` then `register_serializer::<T, E_b>` for the same `T` silently overwrites; both are keyed on `TypeId::of::<T>()`. Last write wins, and it applies to both `(T, E)` buckets. This is correct (the value *is* that `T`) but surprises people expecting per-`(T, E)` keying. +- **Non-object-safe traits.** `Persister` and `HttpBackend` both return `impl Future` and are consumed generically (`persist_with<P: Persister>`, `HttpCache<B: HttpBackend>`). You cannot `Box<dyn Persister>` or `Box<dyn HttpBackend>`; use an `enum` of backends or generic plumbing instead. +- **Mutex guards never cross `.await`.** Both `HttpCache` and `FilePersister` acquire a `std::sync::Mutex`, clone the value out, and drop the guard before yielding. If you write your own `Persister`/`HttpBackend`, do the same: holding a `std` mutex guard across `.await` usually makes the future `!Send`, so it stops compiling on executors that require `Send`. +- **Only `200`s are cached.** A `304` re-serves a *prior* `200` body and refreshes the stored entry unless its own `Cache-Control` blocks caching; a `304` with no cached body is `Err(NotModifiedWithoutCachedBody)`. `no-store`/`no-cache` and non-`200`/`304` statuses store nothing. +- **`no-store` short-circuits parsing.** `no-store, max-age=abc` returns `Ok(NoCache)`; the malformed `max-age` is never reached. Only a malformed TTL *without* any `no-store`/`no-cache` in the header yields `InvalidMaxAge`. - **macOS Metal Toolchain.** Building `client`/`hook`/`persist` (so, the persist feature and `gpui-query-persist`) fails on macOS without it: run `xcodebuild -downloadComponent MetalToolchain` once. `gpui-query-http` (core-only) is unaffected. -- **`PersistHandle` drop stops *new* saves.** A save already parked on its debounce timer is detached and may still complete once after you drop the handle. Keep the handle for the app lifetime (store it on a long-lived view/entity) if you want continuous persistence. -- **`Debounced saves use GPUI's timer.** In tests, the mock clock does not advance on `run_until_parked`; use `cx.background_executor().advance_clock(debounce + ε)` to mature the timer, or set `debounce: Duration::ZERO` for immediate saves. +- **`PersistHandle` drop stops *new* saves.** A task already parked on its debounce timer still completes its final collect and save after you drop the handle. Keep the handle for the app lifetime (store it on a long-lived view/entity) if you want continuous persistence. +- **Debounced saves use GPUI's timer.** In tests, the mock clock does not advance on `run_until_parked`; use `cx.background_executor().advance_clock(debounce + ε)` to mature the timer, or set `debounce: Duration::ZERO` for immediate saves. --- diff --git a/skills/gpui-query/SKILL.md b/skills/gpui-query/SKILL.md index 6d9db1e..7ecca3e 100644 --- a/skills/gpui-query/SKILL.md +++ b/skills/gpui-query/SKILL.md @@ -1,21 +1,21 @@ --- name: gpui-query -description: Use when building a GPUI app that depends on gpui-query (v0.2.0) — writing use_query / use_mutation / use_infinite_query / use_query_select hooks; configuring in-memory CachePolicy (NoCache/Ttl/StaleWhileRevalidate) or RetryPolicy; constructing QueryKey / QueryKeyFilter; calling QueryClient for fetch_query / prefetch / set_query_data / invalidate_queries / cancel_queries / reset_queries / remove_queries; wiring cx.set_global(QueryClient::new()); or debugging observer re-render / stale-write / GC behavior. Do NOT use for general GPUI app work that does not involve gpui-query, for the HTTP-cache/disk-persistence satellites (use the gpui-query-extensions skill), or for editing the gpui-query crate itself (see AGENTS.md for crate-internal work). +description: Use when building a GPUI app that depends on gpui-query (v0.2.1): writing use_query / use_mutation / use_infinite_query / use_query_select hooks; configuring in-memory CachePolicy (NoCache/Ttl/StaleWhileRevalidate) or RetryPolicy; constructing QueryKey / QueryKeyFilter; calling QueryClient for fetch_query / prefetch / set_query_data / invalidate_queries / cancel_queries / reset_queries / remove_queries; wiring cx.set_global(QueryClient::new()); or debugging observer re-render / stale-write / GC behavior. Do NOT use for general GPUI app work that does not involve gpui-query, for the HTTP-cache/disk-persistence satellites (use the gpui-query-extensions skill), or for editing the gpui-query crate itself (see AGENTS.md for crate-internal work). --- # gpui-query (essential) -Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Crate: `gpui-query` v0.2.0. This skill covers the in-memory core/client/hook tiers (persistence and HTTP-cache satellites are separate). +Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Crate: `gpui-query` v0.2.1. This skill covers the in-memory core/client/hook tiers (persistence and HTTP-cache satellites are separate). ## Install -Three strictly-additive tiers, glob re-exported at the crate root (`pub use core::*; pub use client::*; pub use hook::*;`) — import everything from `gpui_query::`. +Three strictly-additive tiers, glob re-exported at the crate root (`pub use core::*; pub use client::*; pub use hook::*;`); import everything from `gpui_query::`. | Tier | Cargo line | What you get | |---|---|---| -| core only (no GPUI) | `gpui-query = { version = "0.2.0", default-features = false, features = ["core"] }` | `QueryResource` state machine, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`. Zero GPUI dep — usable in non-GPUI libs. | -| client (DEFAULT) | `gpui-query = "0.2.0"` | + `QueryClient` GPUI `Global`: type-partitioned buckets, GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`. | -| hooks | `gpui-query = { version = "0.2.0", features = ["hook"] }` | + `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`. | +| core only (no GPUI) | `gpui-query = { version = "0.2.1", default-features = false, features = ["core"] }` | `QueryResource` state machine, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`. Zero GPUI dep, usable in non-GPUI libs. | +| client (DEFAULT) | `gpui-query = "0.2.1"` | + `QueryClient` GPUI `Global`: type-partitioned buckets, GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`. | +| hooks | `gpui-query = { version = "0.2.1", features = ["hook"] }` | + `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`. | The `client` tier pulls `gpui = "0.2.2"`. macOS builds of any tier with GPUI need the Metal Toolchain installed once: `xcodebuild -downloadComponent MetalToolchain` (core-only builds need nothing). @@ -25,10 +25,10 @@ The `client` tier pulls `gpui = "0.2.2"`. macOS builds of any tier with GPUI nee 1. `begin_request` → `QueryBeginResult` (`Started { request_id, .. }` | `CacheHit` | `StaleCacheHit { request_id, .. }` | `IgnoredWhileLoading`). Transitions status to `LoadingEmpty`/`LoadingWithData` and installs a fresh `QuerySignal`. 2. Caller fetches async (cooperative: poll `signal.is_cancelled()` to abort early). -3. `accept_current_request(request_id) -> Option<RequestGuard>` — `Some` ONLY if `request_id` is still the active one. A superseded fetch gets `None`. +3. `accept_current_request(request_id) -> Option<RequestGuard>`: `Some` ONLY if `request_id` is still the active one. A superseded fetch gets `None`. 4. `complete_success(guard, data, now_ms)` / `complete_failure(guard, error, now_ms)` consume the single-use `RequestGuard` by value. -**`RequestGuard` is the authoritative stale-write protection.** A cancelled or superseded async task can `accept` → `None` and its result is silently discarded. Do NOT rely on a `signal.is_cancelled()` check after the fetch returns (TOCTOU window) — the guard is what guarantees correctness. The hooks wrap all of this for you; you only touch `accept`/`complete` directly via `fetch_query_with_signal` or `PreparedFetch`. +**`RequestGuard` is the authoritative stale-write protection.** A cancelled or superseded async task can `accept` → `None` and its result is silently discarded. Do NOT rely on a `signal.is_cancelled()` check after the fetch returns (the TOCTOU window); the guard is what guarantees correctness. The hooks wrap all of this for you; you only touch `accept`/`complete` directly via `fetch_query_with_signal` or `PreparedFetch`. **Status states** (`QueryStatus`, `#[derive(Default)]` so `Idle` is the start): @@ -41,7 +41,7 @@ Success → LoadingWithData → Success | Failure (refetch) - `is_pending()` = `Idle | LoadingEmpty` (TanStack `isPending` parity) - Plus `Cancelled` (explicit cancel). -Plain-query fetch tasks are `.detach()`ed — the signal + guard already prevent stale writes, and the task self-terminates when its `WeakEntity` target is dropped. Mutations and infinite queries store the task via `set_current_task`, so a replacement call or component unmount HARD-aborts the prior task. +Plain-query fetch tasks are `.detach()`ed: the signal + guard already prevent stale writes, and the task self-terminates when its `WeakEntity` target is dropped. Mutations and infinite queries store the task via `set_current_task`, so a replacement call or component unmount HARD-aborts the prior task. ## Quick start @@ -56,7 +56,7 @@ cx.set_global(QueryClient::new()); struct UserList { users: gpui::Entity<gpui_query::QueryResource<Vec<User>, MyError>>, - _subscription: gpui::Subscription, // store both — see Observers + _subscription: gpui::Subscription, // store both; see Observers } impl UserList { @@ -74,7 +74,7 @@ impl UserList { } ``` -Every hook returns `(Entity<Resource>, Subscription)` — both must be stored. Dropping the `Subscription` kills the observation and the component stops re-rendering on state changes. +Every hook returns `(Entity<Resource>, Subscription)`; both must be stored. Dropping the `Subscription` kills the observation and the component stops re-rendering on state changes. ## use_query / use_query_with_policy @@ -92,7 +92,7 @@ where Fut: Future<Output = Result<T, E>> + Send + 'static; ``` -`use_query_with_policy` is identical except `Fut: Future<Output = Result<Fetched<T>, E>>` — the fetcher returns a `Fetched<T>` so a server-derived `CachePolicy` overrides the resource's on success (**"server wins"**): +`use_query_with_policy` is identical except `Fut: Future<Output = Result<Fetched<T>, E>>`: the fetcher returns a `Fetched<T>` so a server-derived `CachePolicy` overrides the resource's policy on success (**"server wins"**): ```rust use gpui_query::{use_query_with_policy, QueryOptions, Fetched, CachePolicy}; @@ -118,7 +118,7 @@ let (entity, _sub) = use_query_with_policy( |---|---| | `.cache_policy(p)` | Per-query cache policy (default `Ttl { ttl_ms: 60_000 }`). | | `.request_policy(p)` | `LatestWins` (default) or `IgnoreWhileLoading`. | -| `.retry_policy(p)` | Per-query retry (default 3 + exponential). Installed onto the entity — without a hook the resource starts at `RetryPolicy::no_retries()`. | +| `.retry_policy(p)` | Per-query retry (default 3 + exponential). Installed onto the entity; without a hook the resource starts at `RetryPolicy::no_retries()`. | | `.force()` | `force_fetch = true` → bypass cache freshness, always fetch. | | `.gc_time(ms)` / `.keep_previous()` | **Reserved / forward-compat.** Stored, NOT consumed today (GC runs off `QueryClient::with_gc_time`). | @@ -156,7 +156,7 @@ pub fn mutate_with_callbacks<V, T, E, C, F, Fut>( ); ``` -`MutationCallbacks::new().on_success(|t: &T| ...).on_error(|e: &E| ...).on_settled(|opt_t: Option<&T>, opt_e: Option<&E>| ...)`. The closures are `Fn(&T)` / `Fn(&E)` / `Fn(Option<&T>, Option<&E>)` (borrowed, not owned) — they fire on the terminal outcome (after retries exhaust / first success), run outside any entity borrow, and are safe to call `entity.update()` inside. +`MutationCallbacks::new().on_success(|t: &T| ...).on_error(|e: &E| ...).on_settled(|opt_t: Option<&T>, opt_e: Option<&E>| ...)`. The closures are `Fn(&T)` / `Fn(&E)` / `Fn(Option<&T>, Option<&E>)` (borrowed, not owned); they fire on the terminal outcome (after retries exhaust / first success), run outside any entity borrow, and are safe to call `entity.update()` inside. ```rust use gpui_query::hook::{use_mutation, mutate_with_callbacks, MutationCallbacks}; @@ -177,10 +177,10 @@ Behavior: - **Concurrent guard:** `mutate` does an atomic check+`begin` inside one `entity.update`. If already `Loading`, the call is a no-op (no second in-flight mutation on the same entity). - **Drop safety net:** if the entity is dropped mid-mutation, `on_error` / `on_settled(None, None)` still fire so callers always get a terminal callback. - **Hard-abort:** the task is stored via `set_current_task`; a replacement `mutate` or component unmount aborts the prior task. -- `mutate_by_ref` / `mutate_arc` — mutator takes `&V` (borrowed from a stored `Arc<V>`), so the retry loop does **no `V::clone` per attempt**. `V: Clone` is still required (the initial `begin` stores one owned copy). +- `mutate_by_ref` / `mutate_arc`: mutator takes `&V` (borrowed from a stored `Arc<V>`), so the retry loop does **no `V::clone` per attempt**. `V: Clone` is still required (the initial `begin` stores one owned copy). - `use_mutation_state::<V,T,E,_>(cx)` returns all registered mutation entities of that type triple (for devtools / batch views). -**Optimistic update pattern** — write to the cache before the mutation resolves, then invalidate/refetch on settle: +**Optimistic update pattern**: write to the cache before the mutation resolves, then invalidate/refetch on settle: ```rust // Before mutate: prime the cache so the UI updates instantly. @@ -232,25 +232,25 @@ fetch_next_page_infinite(&self.feed, |last| async move { /* same shape */ }, cx) // Backward: fetch_previous_page_infinite(&entity, |first| async move { ... }, cx); ``` -`InfiniteQueryOptions`: `.max_pages(n)` (default `Some(50)`, bounded to prevent unbounded growth), `.unbounded_pages()` (`None` — never evict; use with caution), plus `.cache_policy()` / `.retry_policy()` / `.gc_time()` builders. Construct via `InfiniteQueryOptions::new(key)` or `InfiniteQueryOptions::from("feed")`. +`InfiniteQueryOptions`: `.max_pages(n)` (default `Some(50)`, bounded to prevent unbounded growth), `.unbounded_pages()` (`None`, never evict; use with caution), plus `.cache_policy()` / `.retry_policy()` / `.gc_time()` builders. Construct via `InfiniteQueryOptions::new(key)` or `InfiniteQueryOptions::from("feed")`. `InfiniteQueryResource<T, E>` accessors: | Method | Returns | |---|---| -| `pages()` | `&VecDeque<Arc<T>>` — all loaded pages, first→last. | +| `pages()` | `&VecDeque<Arc<T>>`: all loaded pages, first→last. | | `page_count()` / `has_data()` | page count / any loaded. | | `first_page()` / `last_page()` | `Option<&T>` borrowed views. | -| `first_page_arc()` / `last_page_arc()` | `Option<Arc<T>>` — cheap refcount bump to hand to a fetcher. | +| `first_page_arc()` / `last_page_arc()` | `Option<Arc<T>>`: cheap refcount bump to hand to a fetcher. | | `has_next_page()` / `has_previous_page()` | more pages available in either direction. | | `is_fetching_next_page()` / `is_fetching_previous_page()` | a page fetch is in flight. | -| `is_page_data_valid()` | `true` on `Success`/`LoadingWithData`; on `Failure` returns `true` if pages exist (the failure is scoped to the last page fetch — prior pages stay valid). | +| `is_page_data_valid()` | `true` on `Success`/`LoadingWithData`; on `Failure` returns `true` if pages exist (the failure is scoped to the last page fetch; prior pages stay valid). | -`FetchDirection` (set at construction, not by the hook options): `ForwardOnly` (default — `has_next_page` starts `true` as an assumption, fetcher's `has_more` drives it false) vs `Bidirectional` (both start `false`; use `InfiniteQueryResource::new_bidirectional`). The default hook uses `ForwardOnly`. +`FetchDirection` (set at construction, not by the hook options): `ForwardOnly` (default; `has_next_page` starts `true` as an assumption, fetcher's `has_more` drives it false) vs `Bidirectional` (both start `false`; use `InfiniteQueryResource::new_bidirectional`). The default hook uses `ForwardOnly`. ## use_query_select -Project cached data through a `SelectTransform<T, U>` — multiple derived views over one cache entry, no duplication. +Project cached data through a `SelectTransform<T, U>`: multiple derived views over one cache entry, no duplication. ```rust pub type QuerySelectResult<T, U, E> = ( @@ -281,7 +281,7 @@ let (mapped, query_entity, subs) = use_query_select( // mapped.read(cx).data() -> Option<usize> ``` -Store **both** subscriptions (`(query_sub, mapped_sub)`) or the projection stops updating. The transform closure runs on every `MappedQueryResource::data()` call (no output cache) — for expensive transforms, bind `let data = mapped.read(cx).data();` once per render and reuse. The mapped entity re-syncs from the source only when the source `T` actually changed (`PartialEq`), so unchanged notifications pay just an `Arc::clone`. +Store **both** subscriptions (`(query_sub, mapped_sub)`) or the projection stops updating. The transform closure runs on every `MappedQueryResource::data()` call (no output cache); for expensive transforms, bind `let data = mapped.read(cx).data();` once per render and reuse. The mapped entity re-syncs from the source only when the source `T` actually changed (`PartialEq`), so unchanged notifications pay just an `Arc::clone`. ## CachePolicy @@ -290,7 +290,7 @@ Store **both** subscriptions (`(query_sub, mapped_sub)`) or the projection stops | Variant | Behavior | Freshness | |---|---|---| | `NoCache` | Always fetch; never short-circuits. | `is_fresh` always false; `is_expired` always true. | -| `Ttl { ttl_ms }` | Serve fresh within TTL. | `is_fresh(age)` when `age <= ttl_ms` — **INCLUSIVE boundary** (opposite of HTTP `max-age`; mind the off-by-one). | +| `Ttl { ttl_ms }` | Serve fresh within TTL. | `is_fresh(age)` when `age <= ttl_ms` (**inclusive** boundary, opposite of HTTP `max-age`; mind the off-by-one). | | `StaleWhileRevalidate { ttl_ms, stale_ms }` | `[0, ttl]` fresh; `[ttl, ttl+stale]` serve stale + background revalidate; past `ttl+stale` expired. | `is_stale_but_serveable(age)` in the stale window. | Helper methods: `can_short_circuit()` (Ttl/SWR), `can_serve_stale()` (SWR only), `is_fresh(age)`, `is_stale_but_serveable(age)`, `is_expired(age)`, `ttl_ms()`, `total_valid_ms()` (Ttl → `ttl_ms`; SWR → `ttl_ms + stale_ms` saturating). `ttl_ms == 0` behaves like `NoCache` (only `debug_assert`s in debug builds). @@ -310,38 +310,38 @@ pub struct RetryPolicy { | Constructor / builder | Result | |---|---| -| `RetryPolicy::no_retries()` | `max_retries: 0`. (This is what a bare `QueryResource::new` starts with — the hook installs the real policy.) | +| `RetryPolicy::no_retries()` | `max_retries: 0`. (A bare `QueryResource::new` starts here; the hook installs the real policy.) | | `RetryPolicy::new(n)` | `max_retries: n`, base 1000ms, no exponential, cap 30_000ms. | -| `RetryPolicy::default()` | `new(3).with_exponential_backoff()` — **3 retries, exponential, 1s base, 30s cap.** | +| `RetryPolicy::default()` | `new(3).with_exponential_backoff()`: **3 retries, exponential, 1s base, 30s cap.** | | `.with_delay(ms)` / `.with_exponential_backoff()` / `.with_max_delay(ms)` | mutators. | `delay_for_attempt(attempt)` (0-based): - exponential off → constant `retry_delay_ms`. -- exponential on → `retry_delay_ms * 2^attempt`, where the shift is clamped to 62 (prevents overflow) and the multiply is saturating; then `.min(max_retry_delay_ms).min(3_600_000)` — a **hard 1-hour absolute ceiling** regardless of config. +- exponential on → `retry_delay_ms * 2^attempt`, where the shift is clamped to 62 (prevents overflow) and the multiply is saturating; then `.min(max_retry_delay_ms).min(3_600_000)`, a **hard 1-hour ceiling** regardless of config. -`should_retry(current_retries)` = `current_retries < max_retries`. Mutations and queries reset `retry_count` on a new `begin`, so each invocation gets fresh retries. +`should_retry(current_retries)` = `current_retries < max_retries`. The rest-state contract is uniform across families: `retry_count` is nonzero only mid-retry-sequence, 0 after an accepted success or terminal failure, and discards never touch it. Mutations reach that state by resetting on `begin`; queries reset inside the accept guard on completion. ## QueryKey + QueryKeyFilter -`QueryKey` — hierarchical key backed by `Arc<[Arc<str>]>` (clone = one refcount bump regardless of length). Serde-flexible (deserializes from a JSON string array OR a bare string). +`QueryKey`: hierarchical key backed by `Arc<[Arc<str>]>` (clone = one refcount bump regardless of length). Serde-flexible (deserializes from a JSON string array OR a bare string). ```rust // Construction QueryKey::from("users") // From<&str> -> single segment QueryKey::from(["users", "42", "posts"]) // From<[&str; N]> -> multi-segment QueryKey::from_single("users") -QueryKey::new(["users", &id.to_string()]) // PANICS on an empty iterator — guard emptiness +QueryKey::new(["users", &id.to_string()]) // PANICS on an empty iterator; guard emptiness ``` -Accessors: `parts() -> &[Arc<str>]`, `as_single() -> Option<&str>` (only if exactly one segment), `first_segment() -> &str` (first only — NOT the joined key), `to_path() -> String` (`"::"`-joined, e.g. `"users::42::posts"`), `join(extra) -> QueryKey` (O(n) copy — prefer one `new([...])` over chained `.join()`). `starts_with(prefix)` is **segment-wise** (used by `Prefix` filters); an empty prefix matches every valid key. +Accessors: `parts() -> &[Arc<str>]`, `as_single() -> Option<&str>` (only if exactly one segment), `first_segment() -> &str` (first segment only, NOT the joined key), `to_path() -> String` (`"::"`-joined, e.g. `"users::42::posts"`), `join(extra) -> QueryKey` (O(n) copy; prefer one `new([...])` over chained `.join()`). `starts_with(prefix)` is **segment-wise** (used by `Prefix` filters); an empty prefix matches every valid key. `QueryKeyFilter<'a>` (used by every bulk op on `QueryClient`): | Variant | Matches | |---|---| | `Exact(&QueryKey)` | only that exact key. | -| `Prefix(&QueryKey)` | all keys that `starts_with` it (segment-wise — `["users"]` matches `["users", "42", "posts"]`). | +| `Prefix(&QueryKey)` | all keys that `starts_with` it (segment-wise: `["users"]` matches `["users", "42", "posts"]`). | | `All` | every key. | ## QueryClient API @@ -349,9 +349,9 @@ Accessors: `parts() -> &[Arc<str>]`, `as_single() -> Option<&str>` (only if exac A GPUI `Global`. Set once: `cx.set_global(QueryClient::new())`. Access from anywhere: `cx.global::<QueryClient>()` / `cx.update_global::<QueryClient, _>(|c, cx| ...)`. **Construction:** -- `QueryClient::new()` — defaults (`Ttl 60s`, `LatestWins`, `gc_time_ms: 300_000`). -- `QueryClient::with_policies(cache, request)` — custom default policies for resources created via `resource()` (per-query `QueryOptions` still overrides). -- `.with_gc_time(ms)` — `0` disables **opportunistic** (automatic) GC only; an explicit `gc(cx)` / `gc_with_time(now, cx)` still runs (clamping any value `< 1000` to `1000` during the pass). +- `QueryClient::new()`: defaults (`Ttl 60s`, `LatestWins`, `gc_time_ms: 300_000`). +- `QueryClient::with_policies(cache, request)`: custom default policies for resources created via `resource()` (per-query `QueryOptions` still overrides). +- `.with_gc_time(ms)`: `0` skips the **opportunistic** (automatic) GC sweep only; an explicit `gc(cx)` / `gc_with_time(now, cx)` still runs (clamping any value `< 1000` to `1000` during the pass). **Resource access** (type-partitioned by `(T, E)`): ```rust @@ -363,8 +363,8 @@ client.all_queries::<T, E>() // Vec<Entity<...>> (allocates) **Data accessors** (ergonomic cache reads/writes; type params required): ```rust -client.get_query_data::<Vec<User>, MyError>(&"users", cx) // Option<Vec<User>> (clones T) -client.with_query_data::<Vec<User>, MyError, _>(&"users", cx, |u: &Vec<User>| u.len()) // Option<R>, zero-clone +client.get_query_data::<Vec<User>, MyError>(&QueryKey::from("users"), cx) // Option<Vec<User>> (clones T) +client.with_query_data::<Vec<User>, MyError, _>(&QueryKey::from("users"), cx, |u: &Vec<User>| u.len()) // Option<R>, zero-clone client.set_query_data::<Vec<User>, MyError>("users", data, cx) // creates resource if absent; saves previous for rollback ``` @@ -378,12 +378,12 @@ client.cancel_queries(&filter, cx) // cooperative-cancel in-flight (signal `invalidate_queries` is the workhorse after a mutation: it marks matching keys stale so the next `use_query` mount or read refetches, without dropping the cached value (avoids a loading flash). **GC and diagnostics:** -- `client.gc(cx)` / `gc_with_time(now_ms, cx)` — evicts dead refs; `Idle`/`Failure`/`Cancelled` older than `gc_time_ms`; `Success` older than `gc_time_ms * SUCCESS_GC_MULTIPLIER`; loading resources always retained. GC also fires opportunistically every `GC_INTERVAL` operations (debounced by `MIN_GC_TIME_MS`), so you rarely call it manually. -- `client.diagnostics(cx) -> ClientDiagnostic` — per-resource key/status/cache-age for devtools. +- `client.gc(cx)` / `gc_with_time(now_ms, cx)`: evicts dead refs; `Idle`/`Failure`/`Cancelled` older than `gc_time_ms`; `Success` older than `gc_time_ms * SUCCESS_GC_MULTIPLIER`; loading resources always retained. GC also fires opportunistically every `GC_INTERVAL` operations (debounced by `MIN_GC_TIME_MS`), so you rarely call it manually. +- `client.diagnostics(cx) -> ClientDiagnostic`: per-resource key/status/cache-age for devtools. -**Imperative fetch / prefetch** (no observer attached — for warming the cache outside a component): +**Imperative fetch / prefetch** (no observer attached; for warming the cache outside a component): ```rust -// fetchQuery: forced, always returns a PreparedFetch (None only if no sequencer). +// fetchQuery: forced, always returns a PreparedFetch. if let Some(p) = client.prepare_fetch_query::<UserData, MyError>("user/42", cx) { let signal = p.signal.clone(); let entity = p.entity.clone(); @@ -395,20 +395,20 @@ if let Some(p) = client.prepare_fetch_query::<UserData, MyError>("user/42", cx) }).detach(); } -// prefetchQuery: respects cache_policy — returns None on a fresh cache hit. +// prefetchQuery: respects cache_policy; returns None on a fresh cache hit. if let Some(p) = client.prepare_prefetch_query::<T, E>(key, cache, request, cx) { /* ... */ } ``` -`PreparedFetch<T, E>` is `#[must_use]` — it holds the entity, `request_id`, `signal`, and the captured `now_ms` (used as the logical completion time). `complete_success(data, cx)` / `complete_failure(err, cx)` consume it by value and are no-ops if the request was superseded. +`PreparedFetch<T, E>` is `#[must_use]`: it holds the entity, `request_id`, `signal`, and the captured `now_ms` (used as the logical completion time). `complete_success(data, cx)` / `complete_failure(err, cx)` consume it by value and are no-ops if the request was superseded. ### Manual `QueryResource` / `MutationResource` control Beyond hooks, the entity itself exposes (call inside `entity.update(cx, |r, cx| …)`): -- `cancel(error: E) -> bool` — Loading → Cancelled, stashes current data into `previous_data`. **No-op (returns `false`) unless currently Loading.** `MutationResource::cancel(error: E)` has the same shape — cancel takes an `E`, not unit. -- `reset()` — back to Idle; clears data / error / diagnostic counters, preserves policies and key. -- `set_data(data)` / `clear_data()` — optimistic primitives (write or clear without a fetch); `rollback_to_previous()` undoes a `set_query_data` / `set_data`. -- `is_data_stale(now_ms)` — staleness heuristic; `complete_current_success(request_id, data, now_ms)` / `complete_current_failure(request_id, err, now_ms)` do accept + complete in one call for manual flows. +- `cancel(error: E) -> bool`: Loading → Cancelled, stashes current data into `previous_data`. **No-op (returns `false`) unless currently Loading.** `MutationResource::cancel(error: E)` has the same shape; cancel takes an `E`, not unit. +- `reset()`: back to Idle; clears data / error / diagnostic counters, preserves policies and key. +- `set_data(data)` / `clear_data()`: optimistic primitives (write or clear without a fetch); `rollback_to_previous()` undoes a `set_query_data` / `set_data`. +- `is_data_stale()`: staleness heuristic; `complete_current_success(request_id, data, now_ms)` / `complete_current_failure(request_id, err, now_ms)` do accept + complete in one call for manual flows. ## Observers & re-render semantics @@ -417,30 +417,30 @@ A single generic `Observer<R: ObservableResource>` with aliases `QueryObserver<T ```rust let sub = QueryObserver::new(&entity) .with_config(ObserverConfig { notify_on_status_change_only: true }) // default - .observe(cx)?; // Option<Subscription> — None if entity already dropped + .observe(cx)?; // Option<Subscription>: None if entity already dropped ``` -**Re-renders fire ONLY when `observable_status()` changes.** `increment_retry()` / `prepare_retry()` (status stays `Loading`) and `set_current_task` do NOT re-render — this is why a 3-retry mutation doesn't paint 3 times. Set `notify_on_status_change_only: false` to get a notify on every entity mutation. +**Re-renders fire ONLY when `observable_status()` changes.** `increment_retry()` / `prepare_retry()` (status stays `Loading`) and `set_current_task` do NOT re-render; this is why a 3-retry mutation doesn't paint 3 times. Set `notify_on_status_change_only: false` to get a notify on every entity mutation. -**Subscription retention is mandatory.** A hook's returned `Subscription` must outlive the component's interest; dropping it detaches the observer and the component stops reacting. `use_query_select` returns **two** subscriptions (query + mapped observer) — store both, usually as a tuple field. +**Subscription retention is mandatory.** A hook's returned `Subscription` must outlive the component's interest; dropping it detaches the observer and the component stops reacting. `use_query_select` returns **two** subscriptions (query + mapped observer); store both, usually as a tuple field. ## Gotchas -- **Inclusive TTL boundary.** `is_fresh(age)` is `age <= ttl_ms` (not `<`) — opposite of HTTP `max-age`. A `Ttl { ttl_ms: 1000 }` is still fresh at exactly 1000ms. Watch for off-by-one when translating server `Cache-Control`. +- **Inclusive TTL boundary.** `is_fresh(age)` is `age <= ttl_ms` (not `<`), the opposite of HTTP `max-age`. A `Ttl { ttl_ms: 1000 }` is still fresh at exactly 1000ms. Watch for off-by-one when translating server `Cache-Control`. - **Empty-key panic.** `QueryKey::new(iter::empty())` panics unconditionally in all build modes. Use `from_single` / `From<&str>` / guard the iterator at the call site. -- **`QueryClient` required in debug.** `use_query_manual` — and the `use_query` / `use_query_with_policy` / `use_query_unsignalled` / `use_query_manual_opts` hooks that delegate to it — **panics** in debug builds if no global `QueryClient` is set; release silently falls back to a standalone entity. `use_infinite_query` and `use_mutation` do NOT panic: in debug `use_infinite_query` prints an `eprintln!` warning and falls back to a standalone entity, and `use_mutation` silently skips client registration (so no shared cache, no GC, no bulk ops, no `use_mutation_state`). Always `cx.set_global(QueryClient::new())` at startup. +- **`QueryClient` required in debug.** `use_query_manual` (and the `use_query` / `use_query_with_policy` / `use_query_unsignalled` / `use_query_manual_opts` hooks that delegate to it) **panics** in debug builds if no global `QueryClient` is set; release silently falls back to a standalone entity. `use_infinite_query` and `use_mutation` do NOT panic: in debug `use_infinite_query` prints an `eprintln!` warning and falls back to a standalone entity, and `use_mutation` silently skips client registration (so no shared cache, no GC, no bulk ops, no `use_mutation_state`). Always `cx.set_global(QueryClient::new())` at startup. - **Subscription drop kills observation.** Every hook returns `(Entity, Subscription)`; `use_query_select` returns a 3-tuple with two subs. Bind them as struct fields (`_subscription`), not temporaries, or the component freezes after first render. -- **`WeakEntity` silent discard.** Async tasks capture `entity.downgrade()`. If the owning component unmounts mid-fetch, `weak.upgrade()` returns `None` and the result is silently discarded — no callback fires. Mutations are the exception: `on_settled(None, None)` / `on_error` still fire as a safety net. For completion guarantees on queries, use `fetch_query_with_signal` with your own handling. +- **`WeakEntity` silent discard.** Async tasks capture `entity.downgrade()`. If the owning component unmounts mid-fetch, `weak.upgrade()` returns `None` and the result is silently discarded; no callback fires. Mutations are the exception: `on_settled(None, None)` / `on_error` still fire as a safety net. For completion guarantees on queries, use `fetch_query_with_signal` with your own handling. - **`QueryResource::new` starts with `no_retries()`.** The `use_query` hook installs the real `RetryPolicy` from options; if you construct resources directly (or call `fetch_query` on a manually-made entity), set it yourself via `entity.update(cx, |r, _| r.set_retry_policy(p))`. -- **Mutation GC actually runs.** `QueryClient`'s explicit `Default` sets `gc_time_ms: 300_000` (the derived `Default` would have been `0` = disabled). `with_gc_time(0)` still disables; any value `< 1000` is clamped to `1000` during the pass. Mutations are GC-eligible by completion time, not insertion time. +- **Mutation GC actually runs.** `QueryClient`'s explicit `Default` sets `gc_time_ms: 300_000` (the derived `Default` would have been `0` = disabled). `with_gc_time(0)` skips the opportunistic sweep only; an explicit `gc(cx)` still sweeps, and any value `< 1000` is clamped to `1000` during a pass. Mutations are GC-eligible by completion time, not insertion time. - **`mutate` is a no-op when Loading.** The check+`begin` is atomic inside one `entity.update`; a racing second call cannot slip through. If you need to replace an in-flight mutation, cancel or reset first. -- **`max_pages` default is bounded.** `InfiniteQueryOptions` defaults to `Some(50)`. `ForwardOnly` (default direction) starts `has_next_page = true` as an assumption — the fetcher's `has_more` is what flips it false. +- **`max_pages` default is bounded.** `InfiniteQueryOptions` defaults to `Some(50)`. `ForwardOnly` (default direction) starts `has_next_page = true` as an assumption; the fetcher's `has_more` is what flips it false. - **`MappedQueryResource::data()` re-runs the transform every call.** No output cache. Bind the result to a local once per render. - **`cancel_queries` is two-step per match.** It reads `is_loading()` before mutating so non-loading entries don't get spurious observer notifications. - **macOS Metal Toolchain.** Any tier pulling `gpui` (`client`/`hook`/`persist`) fails to build without it. `xcodebuild -downloadComponent MetalToolchain` once. Core-only builds are unaffected. ## Pointers -- Docs site: https://gpui-query.freeoxide.com/docs/ — guides under `/docs/guides/` (`caching`, `retry`, `query-keys`, `select-pattern`, `error-handling`); API reference under `/docs/api/`. +- Docs site: https://gpui-query.freeoxide.com/docs/ (guides under `/docs/guides/`: `caching`, `retry`, `query-keys`, `select-pattern`, `error-handling`; API reference under `/docs/api/`). - Public API surface: everything is glob re-exported at the crate root. The `core::`, `client::`, and `hook::` modules are also public if you need a fully-qualified path (e.g. `gpui_query::core::SelectTransform`, `gpui_query::client::PreparedFetch`). -- Type parameters are load-bearing: `QueryResource<T, E>`, `MutationResource<V, T, E>` (E defaults to `QueryError`), `InfiniteQueryResource<T, E>`. `(T, E)` is the bucket partition key — the same key under two different type pairs is two separate cache entries. +- Type parameters are load-bearing: `QueryResource<T, E>`, `MutationResource<V, T, E>` (E defaults to `QueryError`), `InfiniteQueryResource<T, E>`. `(T, E)` is the bucket partition key: the same key under two different type pairs is two separate cache entries. From 8feb355190054548c27372d0794de3969547e9bd Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 14:58:45 +0200 Subject: [PATCH 060/111] docs: fix debounce wording and caching example receiver --- README.md | 18 +++++++++--------- skills/gpui-query-extensions/SKILL.md | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 83f98c7..e7d6664 100644 --- a/README.md +++ b/README.md @@ -184,17 +184,17 @@ Three policies: Bulk operations on the `QueryClient`: ```rust -let client = cx.global::<QueryClient>(); +cx.update_global::<QueryClient, _>(|client, cx| { + // Invalidate all queries with a matching key prefix + client.invalidate_queries(&QueryKeyFilter::Prefix(&QueryKey::from(["users"])), cx); -// Invalidate all queries with a matching key prefix -client.invalidate_queries(&QueryKeyFilter::Prefix(&QueryKey::from(["users"])), cx); + // Remove everything + client.remove_queries(&QueryKeyFilter::All); -// Remove everything -client.remove_queries(&QueryKeyFilter::All); - -// Optimistic update; the previous value is kept for the resource's -// rollback_to_previous() -client.set_query_data::<Vec<User>, MyError>(&key, vec![new_user], cx); + // Optimistic update; the previous value is kept for the resource's + // rollback_to_previous() + client.set_query_data::<Vec<User>, MyError>("users", vec![new_user], cx); +}); ``` Invalidation matching supports `Exact`, `Prefix`, and `All` filters via `QueryKeyFilter`. diff --git a/skills/gpui-query-extensions/SKILL.md b/skills/gpui-query-extensions/SKILL.md index cb8efac..77d9d78 100644 --- a/skills/gpui-query-extensions/SKILL.md +++ b/skills/gpui-query-extensions/SKILL.md @@ -123,7 +123,7 @@ impl QueryClient { |---|---|---| | `filter` | `PersistFilter` | `PersistFilter::All` | | `max_age` | `std::time::Duration` | `Duration::from_secs(24 * 60 * 60)` (24h); entries older than this at save time are skipped | -| `debounce` | `std::time::Duration` | `Duration::from_millis(500)`; `Duration::ZERO` disables the timer window (saves still serialize through the drain slot) | +| `debounce` | `std::time::Duration` | `Duration::from_millis(500)`; `Duration::ZERO` disables the timer window (saves still run at most one per debounce window) | ```rust #[derive(Clone, Debug)] From 2b43d813607e8eeb0ec2315c45dc0b669b01d723 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:32:14 +0200 Subject: [PATCH 061/111] fix: redact digit-bearing tld emails --- crates/gpui-query/src/core/error/sanitize.rs | 5 ++-- crates/gpui-query/src/tests/core_error/mod.rs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index b6f3646..5a7da38 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -183,6 +183,7 @@ fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { result.into() } +/// TLD contract: at least 2 chars, letter-first, alphanumeric; `c0m` and `c0` redact, while digit-first tails (`2x`, `1.2.10`, version and scale notation) pass through. fn try_match_email(chars: &[char], start: usize) -> Option<usize> { let len = chars.len(); if start >= len { @@ -213,8 +214,8 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { return None; } let dot_pos = (start..domain_end).rev().find(|&j| chars[j] == '.')?; - let tld_len = domain_end - dot_pos - 1; - if tld_len >= 2 && chars[dot_pos + 1..domain_end].iter().all(|c| c.is_alphabetic()) { + let tld = &chars[dot_pos + 1..domain_end]; + if tld.len() >= 2 && tld[0].is_alphabetic() && tld.iter().all(|c| c.is_alphanumeric()) { Some(domain_end) } else { None diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 0f8f5a9..35244a5 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -91,6 +91,29 @@ fn sanitized_redacts_email_local_part_containing_underscore() { assert!(clean.message().contains("[REDACTED_EMAIL]")); } +#[test] +fn sanitized_redacts_email_with_digit_bearing_tld() { + let clean = QueryError::response("login failed for alice@corp.c0m").sanitized(); + assert!(!clean.message().contains("alice")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_email_with_two_char_digit_tld() { + let clean = QueryError::response("no user bob@x.c0 registered").sanitized(); + assert!(!clean.message().contains("bob")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_leaves_digit_first_tld_notation_untouched() { + let clean = QueryError::response("pinned pkg@1.2.10, art@v1.2x, eve@x.c0-m").sanitized(); + assert!(clean.message().contains("pkg@1.2.10")); + assert!(clean.message().contains("art@v1.2x")); + assert!(clean.message().contains("eve@x.c0-m")); + assert!(!clean.message().contains("[REDACTED_EMAIL]")); +} + #[test] fn sanitized_redacts_mongodb_connection_string() { let clean = QueryError::transport("connect mongodb://admin:secret@host/db failed").sanitized(); From 94dde5fc3b8e0a2d70f4712bd66b3de3ddf3b3ad Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:32:18 +0200 Subject: [PATCH 062/111] fix: keep a late 304 refresh from clobbering a concurrent store --- crates/gpui-query-http/Cargo.toml | 2 +- crates/gpui-query-http/src/cache.rs | 120 ++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/crates/gpui-query-http/Cargo.toml b/crates/gpui-query-http/Cargo.toml index e933ef0..15089cb 100644 --- a/crates/gpui-query-http/Cargo.toml +++ b/crates/gpui-query-http/Cargo.toml @@ -28,7 +28,7 @@ thiserror = "2" [dev-dependencies] serde_json = { workspace = true } -tokio = { version = "1", features = ["macros", "rt"] } +tokio = { version = "1", features = ["macros", "rt", "sync"] } # All-features render: the reqwest-gated module and re-export would otherwise # be dead intra-doc links. diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 25bc25c..4476226 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -61,7 +61,8 @@ impl<B: HttpBackend> HttpCache<B> { /// revalidates. Returns `(body, policy, meta)`: only a cacheable `200` /// stores and yields `meta`, a `304` re-serves the cached body and /// refreshes the stored entry unless its own `Cache-Control` blocks - /// caching, and everything else is [`CachePolicy::NoCache`] with `None`. + /// caching or a concurrent fetch replaced the entry, and everything else + /// is [`CachePolicy::NoCache`] with `None`. pub async fn fetch( &self, url: &str, @@ -99,8 +100,15 @@ impl<B: HttpBackend> HttpCache<B> { && let Some(meta) = refreshed_meta(&resp.headers, old) { let mut guard = self.meta.lock().map_err(|_| HttpError::Poisoned)?; - guard.insert(url.to_string(), meta.clone()); - return Ok((body, policy_from_meta(&meta), Some(meta))); + // Lost-update guard: the entry can be replaced between the + // pre-await clone and this insert; only refresh what was validated. + if guard + .get(url) + .is_some_and(|current| same_meta(current, old)) + { + guard.insert(url.to_string(), meta.clone()); + return Ok((body, policy_from_meta(&meta), Some(meta))); + } } let policy = cached_meta .as_ref() @@ -173,6 +181,14 @@ fn stale_for_from_policy(policy: CachePolicy) -> Duration { Duration::from_millis(policy.stale_ms().unwrap_or(0)) } +fn same_meta(a: &CacheMeta, b: &CacheMeta) -> bool { + a.etag == b.etag + && a.last_modified == b.last_modified + && a.stored_at == b.stored_at + && a.fresh_for == b.fresh_for + && a.stale_for == b.stale_for +} + fn policy_from_meta(meta: &CacheMeta) -> CachePolicy { let ttl_ms = u64::try_from(meta.fresh_for.as_millis()).unwrap_or(0); let stale_ms = u64::try_from(meta.stale_for.as_millis()).unwrap_or(0); @@ -274,6 +290,47 @@ mod tests { } } + fn resp_200_with_etag(body: &str, cache_control: &str, etag: &str) -> BackendResponse { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CACHE_CONTROL, cache_control.parse().unwrap()); + headers.insert(http::header::ETAG, etag.parse().unwrap()); + BackendResponse { + status: 200, + headers, + body: Bytes::copy_from_slice(body.as_bytes()), + } + } + + enum Step { + Gated(tokio::sync::oneshot::Receiver<BackendResponse>), + Ready(BackendResponse), + } + + struct InterleavedBackend { + steps: Mutex<VecDeque<Step>>, + started: tokio::sync::mpsc::UnboundedSender<String>, + } + + impl HttpBackend for InterleavedBackend { + type Error = MockError; + + fn fetch( + &self, + url: &str, + _conditionals: Conditionals, + ) -> impl Future<Output = Result<BackendResponse, MockError>> + MaybeSend { + let step = self.steps.lock().unwrap().pop_front(); + let _ = self.started.send(url.to_string()); + async move { + match step { + Some(Step::Ready(resp)) => Ok(resp), + Some(Step::Gated(rx)) => rx.await.map_err(|_| MockError), + None => Err(MockError), + } + } + } + } + fn resp_304(cache_control: Option<&str>, etag: Option<&str>) -> BackendResponse { let mut headers = HeaderMap::new(); if let Some(cache_control) = cache_control { @@ -289,8 +346,8 @@ mod tests { } } - fn seed_entry( - cache: &HttpCache<MockBackend>, + fn seed_entry<B: HttpBackend>( + cache: &HttpCache<B>, url: &str, body: &'static [u8], fresh_for: Duration, @@ -533,4 +590,57 @@ mod tests { let stored = cache.meta.lock().unwrap().get(url).cloned().unwrap(); assert_eq!(stored.stored_at, stored_at); } + + #[tokio::test] + async fn not_modified_refresh_does_not_clobber_concurrent_store() { + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel(); + let backend = InterleavedBackend { + steps: Mutex::new( + vec![ + Step::Gated(release_rx), + Step::Ready(resp_200_with_etag("new", "max-age=600", "\"v2\"")), + ] + .into(), + ), + started: started_tx, + }; + let cache = std::sync::Arc::new(HttpCache::new(backend)); + let url = "https://example.test/lost-update"; + seed_entry( + &cache, + url, + b"old", + Duration::from_secs(60), + SystemTime::now() - Duration::from_secs(3600), + ); + + let racing = { + let cache = std::sync::Arc::clone(&cache); + tokio::spawn(async move { cache.fetch(url).await }) + }; + assert_eq!( + started_rx.recv().await.as_deref(), + Some(url), + "racing fetch must be parked inside the backend before the writer runs" + ); + + let (_, _, writer_meta) = cache.fetch(url).await.unwrap(); + assert_eq!( + writer_meta.unwrap().etag.as_deref(), + Some("\"v2\""), + "writer fetch stores the newer validator first" + ); + + release_tx.send(resp_304(None, None)).unwrap(); + racing.await.unwrap().unwrap(); + + let stored = cache.meta.lock().unwrap().get(url).cloned().unwrap(); + assert_eq!( + stored.etag.as_deref(), + Some("\"v2\""), + "a late 304 must not roll the entry back to the pre-await clone" + ); + assert_eq!(stored.fresh_for, Duration::from_secs(600)); + } } From 61e7f0c4aa5b9c5c396c9737f38fd6f8e79eb081 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:32:21 +0200 Subject: [PATCH 063/111] refactor: remove deprecated use_mutation_with_options --- .../src/hook/mutation_hooks/hooks.rs | 21 ------------------- .../gpui-query/src/hook/mutation_hooks/mod.rs | 3 --- 2 files changed, 24 deletions(-) diff --git a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs index 69a1a5a..d4e73c7 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/hooks.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/hooks.rs @@ -73,27 +73,6 @@ where (entity, subscription) } -/// Deprecated alias of [`use_mutation`], which now takes `MutationOptions` -/// via `Into`. -#[deprecated( - since = "0.2.0", - note = "Use `use_mutation(options, cx)` instead — it now accepts MutationOptions via Into" -)] -// Not re-exported: pub inside a private module, so no caller can reach it. -#[allow(dead_code)] -pub fn use_mutation_with_options<V, T, E, C>( - options: &MutationOptions, - cx: &mut Context<C>, -) -> (Entity<MutationResource<V, T, E>>, Subscription) -where - V: Clone + Send + Sync + 'static, - T: Clone + Send + Sync + 'static, - E: Clone + Send + Sync + 'static, - C: 'static, -{ - use_mutation(options.clone(), cx) -} - /// All registered mutations for the `(V, T, E)` triple; empty when none exist or no [`QueryClient`] is set. /// /// # Example diff --git a/crates/gpui-query/src/hook/mutation_hooks/mod.rs b/crates/gpui-query/src/hook/mutation_hooks/mod.rs index e69cfa6..fc94075 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/mod.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/mod.rs @@ -1,7 +1,4 @@ //! Mutation hooks and internal retry loops. -//! -//! `use_mutation_with_options` is deprecated and deliberately not re-exported: -//! the `pub use` would fire the deprecation lint on every import. mod hooks; mod internals; From 61ffff2fbec0db546aec095544bd806f9e41da71 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:32:21 +0200 Subject: [PATCH 064/111] chore: bind the unused-in-release persist save error --- crates/gpui-query/src/client/persist.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 9c9f99f..2a010a2 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -325,9 +325,9 @@ impl QueryClient { }; // Collect on the main thread (entity reads), save on background (IO). bg.spawn(async move { - if let Err(err) = persister.save(&snapshot).await { + if let Err(_err) = persister.save(&snapshot).await { #[cfg(debug_assertions)] - eprintln!("persist_with: save failed: {err}"); + eprintln!("persist_with: save failed: {_err}"); } }) .detach(); From a1385cfdcf0404b2a120444d6c1d2db10c16b684 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:51:43 +0200 Subject: [PATCH 065/111] fix: redact digit-first and trailing-dot tld emails --- crates/gpui-query/src/core/error/sanitize.rs | 15 ++++++-- crates/gpui-query/src/tests/core_error/mod.rs | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 5a7da38..6025ef0 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -183,7 +183,7 @@ fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { result.into() } -/// TLD contract: at least 2 chars, letter-first, alphanumeric; `c0m` and `c0` redact, while digit-first tails (`2x`, `1.2.10`, version and scale notation) pass through. +/// TLD contract: >= 2 chars, all-alphanumeric, letter-first or >= 2 letters (`c0m`/`c0`/`0rg` redact; `2x`, `1.2.10` pass); a trailing FQDN dot is trimmed for the slice but stays inside the redaction. fn try_match_email(chars: &[char], start: usize) -> Option<usize> { let len = chars.len(); if start >= len { @@ -209,14 +209,23 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { i += 1; } + let scan_end = i; + while i > start && chars[i - 1] == '.' { + i -= 1; + } + let domain_end = i; if domain_end <= start + 2 { return None; } let dot_pos = (start..domain_end).rev().find(|&j| chars[j] == '.')?; let tld = &chars[dot_pos + 1..domain_end]; - if tld.len() >= 2 && tld[0].is_alphabetic() && tld.iter().all(|c| c.is_alphanumeric()) { - Some(domain_end) + let letters = tld.iter().filter(|c| c.is_alphabetic()).count(); + if tld.len() >= 2 + && tld.iter().all(|c| c.is_alphanumeric()) + && (tld[0].is_alphabetic() || letters >= 2) + { + Some(scan_end) } else { None } diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 35244a5..01b1b64 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -114,6 +114,42 @@ fn sanitized_leaves_digit_first_tld_notation_untouched() { assert!(!clean.message().contains("[REDACTED_EMAIL]")); } +#[test] +fn sanitized_redacts_email_with_digit_first_obfuscated_tld() { + let clean = QueryError::response("login eve@x.0rg denied").sanitized(); + assert!(!clean.message().contains("eve@x.0rg")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_email_with_digit_first_multi_letter_tld() { + let clean = QueryError::response("user admin@corp.1nfo not found").sanitized(); + assert!(!clean.message().contains("admin@corp.1nfo")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_email_with_trailing_fqdn_dot() { + let clean = QueryError::response("contact alice@corp.com. now").sanitized(); + assert!(!clean.message().contains("alice@corp.com.")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_email_with_digit_tld_and_trailing_dot() { + let clean = QueryError::response("leak bob@corp.c0m. here").sanitized(); + assert!(!clean.message().contains("bob@corp.c0m.")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_leaves_trailing_dot_notation_untouched() { + let clean = QueryError::response("seen user@2x. and host foo@bar.").sanitized(); + assert!(clean.message().contains("user@2x.")); + assert!(clean.message().contains("foo@bar.")); + assert!(!clean.message().contains("[REDACTED_EMAIL]")); +} + #[test] fn sanitized_redacts_mongodb_connection_string() { let clean = QueryError::transport("connect mongodb://admin:secret@host/db failed").sanitized(); From 3375fa1134f33a9323171138826184fc8e1e28da Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 19:51:46 +0200 Subject: [PATCH 066/111] docs: correct web mutation docs to the real use_mutation api --- .../content/docs/docs/advanced/migration.mdx | 14 +++---- web/src/content/docs/docs/api/mutations.mdx | 41 ++++++++----------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/web/src/content/docs/docs/advanced/migration.mdx b/web/src/content/docs/docs/advanced/migration.mdx index f02d984..8ea4c51 100644 --- a/web/src/content/docs/docs/advanced/migration.mdx +++ b/web/src/content/docs/docs/advanced/migration.mdx @@ -79,19 +79,19 @@ let (entity, _sub) = use_mutation( ); ``` -## 5. `use_mutation_with_options` is deprecated +## 5. Mutation options moved into `use_mutation` -`use_mutation_with_options(options, cx)` is deprecated since `0.2.0`. It now delegates to `use_mutation`, which accepts `MutationOptions` via `Into`. Replace it: +The old options-taking mutation hook, deprecated in `0.2.0`, has been removed. `use_mutation` accepts `MutationOptions` via `Into`, so one hook covers both shapes: ```rust -// v1 -// let (entity, sub) = use_mutation_with_options(&opts, cx); - -// v2 +// with options let (entity, sub) = use_mutation(opts, cx); + +// with defaults (no retries, 5 minute GC) +let (entity, sub) = use_mutation((), cx); ``` -The deprecated function is retained (and still works) so existing callers compile, but it is no longer re-exported from the crate root. Move off it to clear the deprecation warning. +If you were still on the deprecated hook, switch to the `opts` form above; the call reads the same and nothing else changes. ## 6. Bound infinite queries with max_pages diff --git a/web/src/content/docs/docs/api/mutations.mdx b/web/src/content/docs/docs/api/mutations.mdx index 65899d2..96bc1e9 100644 --- a/web/src/content/docs/docs/api/mutations.mdx +++ b/web/src/content/docs/docs/api/mutations.mdx @@ -11,40 +11,33 @@ A mutation does not touch the query cache on its own. If you need to invalidate ## `use_mutation` ```rust -pub fn use_mutation<V, T, E, C>(cx: &mut Context<C>) -> Entity<MutationResource<V, T, E>> +pub fn use_mutation<V, T, E, C>( + options: impl Into<MutationOptions>, + cx: &mut Context<C>, +) -> (Entity<MutationResource<V, T, E>>, Subscription) ``` -Creates a new `MutationResource` entity and returns it. The entity starts in the `Idle` state with a no-retry policy. Store it on your view struct and read from it during render to show loading spinners, error messages, or success state. +Creates a new `MutationResource` entity and returns it together with the `Subscription` that keeps its observer alive; store both on your view struct. The entity starts in the `Idle` state configured from the options: pass `()` for the defaults (no retries, 5 minute GC) or a `MutationOptions` to change them. Read from the entity during render to show loading spinners, error messages, or success state. ```rust +use gpui::{Entity, Subscription}; use gpui_query::{use_mutation, MutationResource}; struct UserForm { create_user: Entity<MutationResource<NewUser, User>>, + _mutation_sub: Subscription, } impl UserForm { fn new(cx: &mut Context<Self>) -> Self { - Self { - create_user: use_mutation(cx), - } + let (create_user, _mutation_sub) = use_mutation((), cx); + Self { create_user, _mutation_sub } } } ``` The returned entity is a GPUI `Entity`, so you observe it like any other entity: call `entity.read(cx)` to inspect status, data, and error fields during render. -### `use_mutation_with_options` - -```rust -pub fn use_mutation_with_options<V, T, E, C>( - options: &MutationOptions<V, T, E>, - cx: &mut Context<C>, -) -> Entity<MutationResource<V, T, E>> -``` - -Same as `use_mutation` but accepts a `MutationOptions` to configure retry policy and garbage collection time. See the `MutationOptions` section below. - ## `mutate` ```rust @@ -207,9 +200,9 @@ pub enum MutationStatus { Each variant has a `label()` method returning a `&'static str` (`"Idle"`, `"Loading"`, `"Success"`, `"Failure"`). -## `MutationOptions<V, T, E>` +## `MutationOptions` -Configuration struct passed to `use_mutation_with_options`. +Options `use_mutation` accepts through `Into`: pass `()` for the defaults in the table, or build a custom set and hand it to `use_mutation(opts, cx)`. | Field | Type | Default | Description | |---|---|---|---| @@ -221,9 +214,9 @@ Builder methods: ```rust use gpui_query::{MutationOptions, RetryPolicy}; -let opts: MutationOptions<NewUser, User> = MutationOptions::new() +let opts = MutationOptions::default() .retry_policy(RetryPolicy::new(3).with_exponential_backoff()) - .gc_time_ms(10 * 60 * 1_000); // 10 minutes + .gc_time(10 * 60 * 1_000); // 10 minutes ``` ## `MutationCallbacks<T, E>` @@ -277,7 +270,7 @@ let policy = RetryPolicy::new(3) A complete example of a view that creates a user and invalidates the user list on success: ```rust -use gpui::{Context, Entity, View}; +use gpui::{Context, Entity, Subscription, View}; use gpui_query::{ use_mutation, mutate_with_callbacks, MutationCallbacks, MutationResource, @@ -285,13 +278,13 @@ use gpui_query::{ struct UserForm { create_user: Entity<MutationResource<NewUser, User>>, + _mutation_sub: Subscription, } impl UserForm { fn new(cx: &mut Context<Self>) -> Self { - Self { - create_user: use_mutation(cx), - } + let (create_user, _mutation_sub) = use_mutation((), cx); + Self { create_user, _mutation_sub } } fn handle_submit(&mut self, name: String, cx: &mut Context<Self>) { From f7b513c066c1168eb11cd8109a42afea0f005c82 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 23:57:49 +0200 Subject: [PATCH 067/111] test: wire READMEs as doctests and fix drifted fences --- README.md | 133 +++++++++++++++++++++++---- crates/gpui-query-http/README.md | 19 +++- crates/gpui-query-http/src/lib.rs | 6 ++ crates/gpui-query-persist/Cargo.toml | 1 + crates/gpui-query-persist/README.md | 14 ++- crates/gpui-query-persist/src/lib.rs | 6 ++ crates/gpui-query/README.md | 39 ++++++-- crates/gpui-query/src/lib.rs | 12 +++ 8 files changed, 199 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e7d6664..d8a7865 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,12 @@ The `core` layer also builds for `wasm32-unknown-unknown`; the wasm-specific set Set up the `QueryClient` as a GPUI global when your app starts: -```rust -use gpui::App; +```rust,no_run +use gpui::Application; +# use gpui::BorrowAppContext; use gpui_query::QueryClient; -App::new().run(|cx| { +Application::new().run(|cx| { cx.set_global(QueryClient::new()); // ... your views }); @@ -55,8 +56,18 @@ App::new().run(|cx| { Fetch data with `use_query`: -```rust -use gpui_query::{use_query, QueryOptions}; +```rust,no_run +use gpui_query::use_query; +# use gpui::{Context, Entity, Subscription}; +# use gpui_query::QueryResource; +# struct MyView; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# async fn fetch_users() -> Result<Vec<User>, MyError> { +# Ok(vec![]) +# } fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { use_query( @@ -73,13 +84,27 @@ fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyE Read the state in your render method: -```rust -let label = self.query_entity.read_with(cx, |resource| match resource.status() { +```rust,no_run +# use gpui::{Context, Entity}; +# use gpui_query::{QueryResource, QueryStatus}; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# struct MyView { +# query_entity: Entity<QueryResource<Vec<User>, MyError>>, +# } +# impl MyView { +# fn label(&self, cx: &Context<Self>) -> &'static str { +let label = self.query_entity.read_with(cx, |resource, _| match resource.status() { QueryStatus::LoadingEmpty => "Loading...", QueryStatus::Success => "Got data", QueryStatus::Failure => "Error", _ => "Idle", }); +# label +# } +# } ``` ## architecture @@ -107,8 +132,19 @@ persist = ["client", "hook", "dep:serde_json", "dep:thiserror"] The primary hook. Pass a key (string or `QueryOptions`), a fetcher function, and the view context. The fetcher receives a `QuerySignal` for cooperative cancellation. -```rust +```rust,no_run use gpui_query::{use_query, QueryOptions, CachePolicy, RetryPolicy}; +# use gpui::Context; +# use gpui_query::QuerySignal; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# fn doc<C: 'static, F, Fut>(cx: &mut Context<C>, fetcher: F) +# where +# F: Fn(QuerySignal) -> Fut + Copy + Send + 'static, +# Fut: std::future::Future<Output = Result<Vec<User>, MyError>> + Send + 'static, +# { // Simple key let (entity, sub) = use_query("users", fetcher, cx); @@ -121,6 +157,8 @@ let (entity, sub) = use_query( fetcher, cx, ); +# let _ = (entity, sub); +# } ``` `QueryResource<T,E>` tracks the full lifecycle: idle, loading (with or without previous data), success, failure, or cancelled. You get `data()`, `error()`, `status()`, `is_loading()`, `has_data()`, `cache_age_ms(now_ms)`, and `retry_count()`. @@ -129,8 +167,26 @@ For manual control with no auto-fetch, use `use_query_manual` and trigger fetche ## mutations -```rust +```rust,no_run use gpui_query::{use_mutation, mutate, MutationCallbacks}; +# use gpui::Context; +# use gpui_query::mutate_with_callbacks; +# #[derive(Clone)] +# struct NewUser { +# name: &'static str, +# } +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# async fn create_user(vars: NewUser) -> User { +# let _ = vars; +# User +# } +# fn doc<C: 'static>(cx: &mut Context<C>) { +# let variables = NewUser { name: "Ada" }; +# let mutator = +# |vars: NewUser| async move { Ok::<User, MyError>(create_user(vars).await) }; let (entity, sub) = use_mutation((), cx); @@ -149,6 +205,7 @@ mutate_with_callbacks( .on_error(|err| eprintln!("mutation failed: {err:?}")), cx, ); +# } ``` Mutations track their own state in `MutationResource<V,T,E>` with a begin/complete/retry/reset lifecycle. They don't touch the query cache unless you explicitly invalidate queries in an `on_success` callback. @@ -157,18 +214,42 @@ Mutations track their own state in `MutationResource<V,T,E>` with a begin/comple For paginated data. The fetcher receives the last page (or `None` for the first request) and returns `(page_data, has_more)`. -```rust +```rust,no_run use gpui_query::{use_infinite_query, InfiniteQueryOptions}; +# use gpui::Context; +# #[derive(Clone, Debug)] +# struct MyError; +# #[derive(Clone)] +# struct Page { +# cursor: u32, +# } +# impl Page { +# fn cursor(&self) -> u32 { +# self.cursor +# } +# } +# struct FetchedPage { +# items: Page, +# has_more: bool, +# } +# async fn fetch_page(cursor: Option<u32>) -> Result<FetchedPage, MyError> { +# let _ = cursor; +# Ok(FetchedPage { items: Page { cursor: 0 }, has_more: false }) +# } +# fn doc<C: 'static>(cx: &mut Context<C>) { let (entity, sub) = use_infinite_query( InfiniteQueryOptions::new("feed").max_pages(10), - |last_page| async move { + |last_page: Option<&Page>| { let cursor = last_page.map(|p| p.cursor()); - let page = fetch_page(cursor).await?; - Ok::<_, MyError>((page.items, page.has_more)) + async move { + let page = fetch_page(cursor).await?; + Ok::<_, MyError>((page.items, page.has_more)) + } }, cx, ); +# } ``` Pages are stored in a `VecDeque`. Default cap is 50 pages, configurable via `max_pages()`. Supports bidirectional fetching with `fetch_next_page_infinite` and `fetch_previous_page_infinite`. @@ -183,7 +264,17 @@ Three policies: Bulk operations on the `QueryClient`: -```rust +```rust,no_run +# use gpui::App; +# use gpui::AppContext; +# use gpui::BorrowAppContext; +# use gpui_query::client::QueryClient; +# use gpui_query::{QueryKey, QueryKeyFilter}; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# fn doc(cx: &mut App, new_user: User) { cx.update_global::<QueryClient, _>(|client, cx| { // Invalidate all queries with a matching key prefix client.invalidate_queries(&QueryKeyFilter::Prefix(&QueryKey::from(["users"])), cx); @@ -195,6 +286,7 @@ cx.update_global::<QueryClient, _>(|client, cx| { // rollback_to_previous() client.set_query_data::<Vec<User>, MyError>("users", vec![new_user], cx); }); +# } ``` Invalidation matching supports `Exact`, `Prefix`, and `All` filters via `QueryKeyFilter`. @@ -220,19 +312,25 @@ Retry delay is `base * 2^attempt`, capped at `max_delay`. The fetcher's `QuerySi Enable the `persist` feature to save and restore the cache across restarts. Implement the async `Persister` trait, then drive it with `QueryClient::persist_with` (debounced snapshot saves) and the free `hydrate` function (cold-start restore): -```rust +```rust,no_run use std::time::Duration; use gpui_query::client::{ QueryClient, Persister, PersistSnapshot, PersistError, PersistOptions, PersistFilter, }; +# use gpui::App; struct MyPersister; // your backend: file, db, kv, … impl Persister for MyPersister { - async fn load(&self) -> Result<PersistSnapshot, PersistError> { /* … */ } - async fn save(&self, _snapshot: &PersistSnapshot) -> Result<(), PersistError> { /* … */ } + async fn load(&self) -> Result<PersistSnapshot, PersistError> { + Ok(PersistSnapshot::new()) + } + async fn save(&self, _snapshot: &PersistSnapshot) -> Result<(), PersistError> { + Ok(()) + } } +# async fn doc(mut client: &mut QueryClient, cx: &mut App) { // Debounced saves: coalesces bursts of cache mutations into one snapshot. let _handle = client.persist_with(MyPersister, PersistOptions::default(), cx); @@ -240,6 +338,7 @@ let _handle = client.persist_with(MyPersister, PersistOptions::default(), cx); gpui_query::client::hydrate( &mut client, &MyPersister, &PersistFilter::All, Duration::from_secs(86_400), cx, ).await.ok(); +# } ``` Only `Success` entries with a registered serializer are persisted; the typed round-trip is driven by `QueryClient::register_serializer` / `register_deserializer`. The companion crate **`gpui-query-persist`** ships a ready-made atomic disk adapter (`FilePersister`). See the [Persistence guide](https://gpui-query.freeoxide.com/docs/guides/persistence). diff --git a/crates/gpui-query-http/README.md b/crates/gpui-query-http/README.md index dc6e5ab..23f951d 100644 --- a/crates/gpui-query-http/README.md +++ b/crates/gpui-query-http/README.md @@ -58,13 +58,17 @@ Return that `Fetched<T>` from a fetcher passed to [`gpui_query::use_query_with_p For the cache layer, wrap any `HttpBackend`. With the `reqwest` feature: -```rust +```rust,no_run use gpui_query_http::{HttpCache, ReqwestBackend}; +# async fn doc() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let cache = HttpCache::new(ReqwestBackend::from_client(reqwest::Client::new())); // Fresh hit → no network call; stale → conditional GET; 304 → cached body. let (body, policy, meta) = cache.fetch("https://example.test/data").await?; +# let _ = (body, policy, meta); +# Ok(()) +# } ``` ## WebAssembly @@ -73,9 +77,17 @@ The crate compiles for `wasm32-unknown-unknown` with the default feature set and For custom backends, `HttpBackend::fetch` bounds its returned future with `MaybeSend` (exported at the crate root) instead of `Send`. On native targets `MaybeSend` is exactly `Send`, so an existing impl written with `+ Send` keeps compiling unchanged. Use `+ MaybeSend` only when a backend must compile on both native and wasm: browser-fetch futures on `wasm32` (reqwest's included) hold JS values and are therefore `!Send`, so a `+ Send` bound would not compile there. -```rust +```rust,no_run use std::future::Future; use gpui_query_http::{BackendResponse, Conditionals, HttpBackend, MaybeSend}; +# #[derive(Debug)] +# struct MyError; +# impl std::fmt::Display for MyError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +# f.write_str("GET failed") +# } +# } +# impl std::error::Error for MyError {} struct MyBackend; @@ -87,7 +99,8 @@ impl HttpBackend for MyBackend { url: &str, conditionals: Conditionals, ) -> impl Future<Output = Result<BackendResponse, MyError>> + MaybeSend { - // perform the conditional GET ... + // perform the conditional GET (stubbed here) + async { Err(MyError) } } } ``` diff --git a/crates/gpui-query-http/src/lib.rs b/crates/gpui-query-http/src/lib.rs index 7cbb34a..756e0b0 100644 --- a/crates/gpui-query-http/src/lib.rs +++ b/crates/gpui-query-http/src/lib.rs @@ -451,3 +451,9 @@ mod tests { assert_eq!(back.stored_at, meta.stored_at); } } + +#[cfg(all(doctest, feature = "reqwest"))] +mod readme_doctests { + #[doc = include_str!("../README.md")] + struct Readme; +} diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index 2d655b6..45218d1 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -24,6 +24,7 @@ serde = { workspace = true } serde_json = { workspace = true } [dev-dependencies] +gpui = { workspace = true } pollster = "0.4" [package.metadata.docs.rs] diff --git a/crates/gpui-query-persist/README.md b/crates/gpui-query-persist/README.md index d91e59e..696ddce 100644 --- a/crates/gpui-query-persist/README.md +++ b/crates/gpui-query-persist/README.md @@ -27,12 +27,13 @@ The crate pulls in [gpui-query](https://crates.io/crates/gpui-query) with the `p Hand a `FilePersister` to `QueryClient::persist_with` when your app starts. Keep the returned `PersistHandle` alive for as long as you want saves to continue. -```rust -use gpui::App; +```rust,no_run +use gpui::Application; +# use gpui::BorrowAppContext; use gpui_query::client::{PersistOptions, QueryClient}; use gpui_query_persist::FilePersister; -App::new().run(|cx| { +Application::new().run(|cx| { cx.set_global(QueryClient::new()); // JSON at an explicit path: @@ -49,7 +50,10 @@ App::new().run(|cx| { ### Constructors -```rust +```rust,no_run +# use gpui_query_persist::{FilePersister, PersistFormat}; +# use gpui_query::client::PersistError; +# fn doc() -> Result<(), PersistError> { // Pick a format explicitly: FilePersister::new("path/to/cache.bin", PersistFormat::Bincode); @@ -62,6 +66,8 @@ FilePersister::in_cache_dir("my-app")?; // -> Result<Self, PersistError> // Inspect the on-disk path: FilePersister::json("path/to/cache.json").path(); // -> &Path +# Ok(()) +# } ``` `PersistFormat` is `Json` (human-readable, the default for `in_cache_dir`) or `Bincode` (compact, not human-readable). `NoopPersister` is re-exported for tests or disabled modes. diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index decbbe5..4806c84 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -358,3 +358,9 @@ mod tests { ); } } + +#[cfg(doctest)] +mod readme_doctests { + #[doc = include_str!("../README.md")] + struct Readme; +} diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index be58f18..52e4d98 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -33,11 +33,12 @@ The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash Set up a `QueryClient` as a GPUI global when your app starts: -```rust -use gpui::App; +```rust,no_run +use gpui::Application; +# use gpui::BorrowAppContext; use gpui_query::QueryClient; -App::new().run(|cx| { +Application::new().run(|cx| { cx.set_global(QueryClient::new()); // ... your views }); @@ -45,8 +46,18 @@ App::new().run(|cx| { Create a query in your view: -```rust -use gpui_query::{use_query, QueryOptions}; +```rust,no_run +use gpui_query::use_query; +# use gpui::{Context, Entity, Subscription}; +# use gpui_query::QueryResource; +# struct MyView; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# async fn fetch_users() -> Result<Vec<User>, MyError> { +# Ok(vec![]) +# } fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) { use_query( @@ -62,13 +73,27 @@ fn setup_query(cx: &mut Context<MyView>) -> (Entity<QueryResource<Vec<User>, MyE Read the state in `render`: -```rust -let label = self.query_entity.read_with(cx, |resource| match resource.status() { +```rust,no_run +# use gpui::{Context, Entity}; +# use gpui_query::{QueryResource, QueryStatus}; +# #[derive(Clone)] +# struct User; +# #[derive(Clone, Debug)] +# struct MyError; +# struct MyView { +# query_entity: Entity<QueryResource<Vec<User>, MyError>>, +# } +# impl MyView { +# fn label(&self, cx: &Context<Self>) -> &'static str { +let label = self.query_entity.read_with(cx, |resource, _| match resource.status() { QueryStatus::LoadingEmpty => "Loading...", QueryStatus::Success => "Got data", QueryStatus::Failure => "Error", _ => "Idle", }); +# label +# } +# } ``` ## Feature layers diff --git a/crates/gpui-query/src/lib.rs b/crates/gpui-query/src/lib.rs index cf70279..4caa495 100644 --- a/crates/gpui-query/src/lib.rs +++ b/crates/gpui-query/src/lib.rs @@ -32,3 +32,15 @@ pub use hook::*; #[cfg(test)] mod tests; + +#[cfg(all(doctest, feature = "hook"))] +mod readme_doctests { + #[doc = include_str!("../README.md")] + struct Readme; +} + +#[cfg(all(doctest, feature = "persist"))] +mod repo_readme_doctests { + #[doc = include_str!("../../../README.md")] + struct RepoReadme; +} From 9a5e0dd4a7b55728d035deeab0846279c0c74ff0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Sun, 20 Sep 2026 23:57:51 +0200 Subject: [PATCH 068/111] docs: point web retry docs at MutationOptions::default --- web/src/content/docs/docs/api/mutations.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/content/docs/docs/api/mutations.mdx b/web/src/content/docs/docs/api/mutations.mdx index 96bc1e9..ca60097 100644 --- a/web/src/content/docs/docs/api/mutations.mdx +++ b/web/src/content/docs/docs/api/mutations.mdx @@ -248,7 +248,7 @@ Callbacks fire on the final outcome only. Intermediate retry attempts do not tri ## `RetryPolicy` -Shared between queries and mutations. See the [Queries](/docs/api/queries) page for the full reference. The defaults for mutations differ: `use_mutation` creates a resource with `RetryPolicy::no_retries()`, while `MutationOptions::new()` also defaults to no retries. +Shared between queries and mutations. See the [Queries](/docs/api/queries) page for the full reference. The defaults for mutations differ: `use_mutation` creates a resource with `RetryPolicy::no_retries()`, while `MutationOptions::default()` also defaults to no retries. To add retries: From 184144fb083c98c32d4bc0fc794d8e791268758e Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Mon, 21 Sep 2026 00:20:27 +0200 Subject: [PATCH 069/111] ci: gate the release profile and sync skill versions --- .github/workflows/cargo-test.yml | 39 ++++++++++++++++++++----- .github/workflows/changelog-release.yml | 15 +++++++--- justfile | 5 ++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/.github/workflows/cargo-test.yml b/.github/workflows/cargo-test.yml index 3c0203e..bba020d 100644 --- a/.github/workflows/cargo-test.yml +++ b/.github/workflows/cargo-test.yml @@ -1,13 +1,17 @@ name: Cargo Test -# Runs the full workspace test suite on Linux — the local mirror is -# `just test`. The GPUI-linked test binaries (client/hook/persist layers and -# the persist satellite) need X11/XCB dev libraries at link time, so the two -# zed script/linux display packages are installed first (libxkbcommon-dev and -# libxcb1-dev arrive transitively). The wasm32 boundary stays build-only in -# wasm-check.yml: the repo has no wasm test targets today and browser-driven -# wasm-bindgen-test needs runner infrastructure that is not warranted. -# Cargo.lock is not committed; rust-cache keys off the manifests that exist. +# Runs the full workspace test suite on Linux plus a release-profile +# build/clippy gate; the local mirrors are `just test` and +# `just release-check`. The release job exists because cfg(debug_assertions) +# code compiles in only one profile, so a dev-profile suite stays green while +# the release build breaks. The GPUI-linked test binaries (client/hook/persist +# layers and the persist satellite) need X11/XCB dev libraries at link time, +# so the two zed script/linux display packages are installed first +# (libxkbcommon-dev and libxcb1-dev arrive transitively). The wasm32 boundary +# stays build-only in wasm-check.yml: the repo has no wasm test targets today +# and browser-driven wasm-bindgen-test needs runner infrastructure that is +# not warranted. Cargo.lock is not committed; rust-cache keys off the +# manifests that exist. on: push: @@ -47,3 +51,22 @@ jobs: - name: Run all tests run: cargo test --all-features + + cargo-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + # No apt step and no `workspaces` override, matching wasm-check.yml: + # only test executables link against X11/XCB, and neither a release + # build (rlibs) nor clippy (check mode) links one. + - uses: Swatinem/rust-cache@v2 + + - name: Build all features (release) + run: cargo build --all-features --release + + - name: Lint all targets (release) + run: cargo clippy --all-features --all-targets --release -- -D warnings diff --git a/.github/workflows/changelog-release.yml b/.github/workflows/changelog-release.yml index ac95b56..640e7b4 100644 --- a/.github/workflows/changelog-release.yml +++ b/.github/workflows/changelog-release.yml @@ -63,21 +63,28 @@ jobs: sed -i "s/^version = \".*\"/version = \"$VERSION\"/" crates/gpui-query/Cargo.toml # Keep the version literal in sync across the install snippets that # external systems render statically (crates.io README, GitHub README, - # docs installation page). Mirrors the Cargo.toml bump above; both the - # bare `gpui-query = "x.y.z"` and table `version = "x.y.z"` forms. + # docs installation page, skill docs). Mirrors the Cargo.toml bump + # above: bare and table `version = "x.y.z"` forms plus the two skill + # prose forms `gpui-query (vx.y.z)` and `gpui-query` vx.y.z. Every + # pattern anchors on `gpui-query` itself, so the -http and -persist + # satellite pins are never touched. sed -i -E \ -e 's|gpui-query = "[^"]*"|gpui-query = "'"$VERSION"'"|g' \ -e 's|gpui-query = [{] version = "[^"]*"|gpui-query = { version = "'"$VERSION"'"|g' \ + -e 's|gpui-query \(v[0-9]+\.[0-9]+\.[0-9]+\)|gpui-query (v'"$VERSION"')|g' \ + -e 's|gpui-query` v[0-9]+\.[0-9]+\.[0-9]+|gpui-query` v'"$VERSION"'|g' \ README.md \ crates/gpui-query/README.md \ - web/src/content/docs/docs/getting-started/installation.mdx + web/src/content/docs/docs/getting-started/installation.mdx \ + skills/gpui-query/SKILL.md \ + skills/gpui-query-extensions/SKILL.md - name: Commit version bump if: steps.check_tag.outputs.exists == 'false' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add crates/gpui-query/Cargo.toml README.md crates/gpui-query/README.md web/src/content/docs/docs/getting-started/installation.mdx + git add crates/gpui-query/Cargo.toml README.md crates/gpui-query/README.md web/src/content/docs/docs/getting-started/installation.mdx skills/gpui-query/SKILL.md skills/gpui-query-extensions/SKILL.md git diff --cached --quiet || git commit -m "chore: bump gpui-query version to v${{ steps.changelog.outputs.version }}" - name: Create Git tag diff --git a/justfile b/justfile index 7d07356..7728273 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,11 @@ test: test-feature feature: cargo test --features "{{ feature }}" +# Run the release-profile gate (mirrors the cargo-release job in Cargo Test CI) +release-check: + cargo build --all-features --release + cargo clippy --all-features --all-targets --release -- -D warnings + # ---- Wasm ---- # Verify the wasm compile boundary (mirrors the Wasm Check CI workflow): From da3a24ea662f789605a45ae9db6a59a4927168e9 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Mon, 21 Sep 2026 00:20:27 +0200 Subject: [PATCH 070/111] docs: scope the core-only test command to the main crate --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3cee189..9e98f17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Task runner is `just` (justfile at repo root). Recipes shown with their raw equi - Test everything: `just test` / `cargo test --all-features`. - Test one layer: `just test-feature hook` / `cargo test --features "hook"`. -- Core-only (no GPUI): `cargo test --no-default-features --features core`. +- Core-only (no GPUI): `cargo test -p gpui-query --no-default-features --features core`. - Build all: `cargo build --all-features`. - Docs: `cargo doc --all-features`. From 04cbd17687590c3157723f260fa47be638dc1240 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Mon, 21 Sep 2026 00:36:22 +0200 Subject: [PATCH 071/111] ci: install clippy for the release job --- .github/workflows/cargo-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cargo-test.yml b/.github/workflows/cargo-test.yml index bba020d..d4cb385 100644 --- a/.github/workflows/cargo-test.yml +++ b/.github/workflows/cargo-test.yml @@ -59,6 +59,8 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: clippy # No apt step and no `workspaces` override, matching wasm-check.yml: # only test executables link against X11/XCB, and neither a release From e5524c1c232e5b2016fd880c7dc63584a8ac5fa2 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Mon, 21 Sep 2026 12:33:20 +0200 Subject: [PATCH 072/111] chore: release v0.2.2 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94811d8..ccd8a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.2] - 2026-09-21 + +> Audit-driven fixes across all three crates: a release-profile compile break, wider secret redaction, retry counters that match their docs, RFC 9111 cache refresh, and a durability fix in the file persister. + +### Fixed + +#### `gpui-query` — release-profile compile break + +- `cargo build --release` with the `hook` feature failed to compile: a release-only fallback in `use_query` called `cx.new` without the `AppContext` trait in scope. Dev-profile builds were unaffected, which is why the test suite never saw it. CI now builds and lints the release profile on every push and pull request. + +#### `gpui-query` — wider secret redaction in error text + +- The sanitizer now catches the shapes secrets arrive in: underscore-bearing email local parts (`alice@secret_word@corp.com`), `bearer` and `token` values behind any mix of whitespace and `:`/`=` separators including doubled ones (`bearer ==`), digit-bearing TLDs (`alice@corp.c0m`, `a@b.0rg`), and trailing-dot hostnames (`alice@corp.com.`). Redaction only grows — nothing that was redacted before passes through now. + +#### `gpui-query` — `retry_count` behaves the same in every hook + +- A fetch result discarded by a newer request no longer clobbers the live entry's retry counter, `use_infinite_query` increments it between attempts, and `use_mutation` resets it when a mutation succeeds. + +#### `gpui-query-http` — `304` responses refresh stored entries + +- Per RFC 9111 §4.3.4, a validated `304` updates the stored response and its timestamp, unless the 304's own `Cache-Control` blocks caching. Previously the timestamp never moved, so a revalidated entry stayed stale forever after. A late 304 also no longer clobbers a concurrent refresh: the update applies only if the stored metadata is unchanged since the fetch began. + +#### `gpui-query-http` — parse errors cap echoed header bytes + +- `ParseError` values embed at most 512 bytes of the offending header, suffixed with `...[truncated]`, so hostile `Cache-Control` fields cannot balloon error text. + +#### `gpui-query-persist` — parent directory fsynced for bare filenames + +- `FilePersister::json("cache.json")` produced an empty parent path, so the post-rename directory fsync silently did nothing. Bare and relative paths now resolve their parent correctly, and the raw `fsync` FFI call is replaced by `File::sync_all`. + +### Changed + +- READMEs are compiled as doctests, which caught every quick-start calling `App::new()` (never a method on gpui 0.2.2) plus a handful of drifted signatures, all now fixed. +- Version literals in the skill packs are synced by the release workflow, alongside the READMEs and the docs install page. + ## [0.2.1] - 2026-09-19 > Wasm32 compile support for `core` and `gpui-query-http`, publish fixes for the satellite crates, and real test coverage in CI. From 7e77690e795f12f97fb4d292729097b1fe5d5845 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:29:54 +0000 Subject: [PATCH 073/111] chore: bump gpui-query version to v0.2.2 --- README.md | 6 +++--- crates/gpui-query/Cargo.toml | 2 +- crates/gpui-query/README.md | 6 +++--- skills/gpui-query-extensions/SKILL.md | 2 +- skills/gpui-query/SKILL.md | 10 +++++----- .../content/docs/docs/getting-started/installation.mdx | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d8a7865..2173ab0 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,21 @@ The API mirrors TanStack Query: `use_query`, `use_mutation`, and `use_infinite_q ```toml [dependencies] -gpui-query = "0.2.1" +gpui-query = "0.2.2" ``` This pulls in the `client` layer (which includes `core`). To use the declarative hooks: ```toml [dependencies] -gpui-query = { version = "0.2.1", features = ["hook"] } +gpui-query = { version = "0.2.2", features = ["hook"] } ``` To use only the core state machine with no GPUI dependency: ```toml [dependencies] -gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } +gpui-query = { version = "0.2.2", default-features = false, features = ["core"] } ``` The `core` layer also builds for `wasm32-unknown-unknown`; the wasm-specific setup (ahash switches to compile-time RNG on wasm targets) is handled internally. The `client`, `hook`, and `persist` layers are native-only: they depend on `gpui`, which does not build for wasm. diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index 82e86c1..cd26aff 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui-query" -version = "0.2.1" +version = "0.2.2" edition = "2024" description = "TanStack Query-inspired async state management for GPUI" repository = "https://github.com/freeoxide/gpui-query" diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 52e4d98..426c244 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -10,21 +10,21 @@ You write a fetcher. The library manages the lifecycle. ```toml [dependencies] -gpui-query = "0.2.1" +gpui-query = "0.2.2" ``` The default feature set includes the `client` layer. To use the declarative view hooks, enable the `hook` feature: ```toml [dependencies] -gpui-query = { version = "0.2.1", features = ["hook"] } +gpui-query = { version = "0.2.2", features = ["hook"] } ``` If you only want the core state machine without pulling in GPUI: ```toml [dependencies] -gpui-query = { version = "0.2.1", default-features = false, features = ["core"] } +gpui-query = { version = "0.2.2", default-features = false, features = ["core"] } ``` The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash to compile-time RNG on wasm targets internally, so no extra configuration is needed. The `client`, `hook`, and `persist` layers are native-only because they depend on `gpui`, which does not build for wasm. diff --git a/skills/gpui-query-extensions/SKILL.md b/skills/gpui-query-extensions/SKILL.md index 77d9d78..4fa2d1b 100644 --- a/skills/gpui-query-extensions/SKILL.md +++ b/skills/gpui-query-extensions/SKILL.md @@ -22,7 +22,7 @@ Do NOT reach for them for ephemeral in-memory state, or if you only need client- ```toml [dependencies] gpui = "0.2.2" -gpui-query = { version = "0.2.1", features = ["persist"] } # enables persist layer +gpui-query = { version = "0.2.2", features = ["persist"] } # enables persist layer # HTTP cache (optional reqwest backend): gpui-query-http = { version = "0.1", features = ["reqwest"] } # drop "reqwest" to use your own HttpBackend diff --git a/skills/gpui-query/SKILL.md b/skills/gpui-query/SKILL.md index 7ecca3e..4563165 100644 --- a/skills/gpui-query/SKILL.md +++ b/skills/gpui-query/SKILL.md @@ -1,11 +1,11 @@ --- name: gpui-query -description: Use when building a GPUI app that depends on gpui-query (v0.2.1): writing use_query / use_mutation / use_infinite_query / use_query_select hooks; configuring in-memory CachePolicy (NoCache/Ttl/StaleWhileRevalidate) or RetryPolicy; constructing QueryKey / QueryKeyFilter; calling QueryClient for fetch_query / prefetch / set_query_data / invalidate_queries / cancel_queries / reset_queries / remove_queries; wiring cx.set_global(QueryClient::new()); or debugging observer re-render / stale-write / GC behavior. Do NOT use for general GPUI app work that does not involve gpui-query, for the HTTP-cache/disk-persistence satellites (use the gpui-query-extensions skill), or for editing the gpui-query crate itself (see AGENTS.md for crate-internal work). +description: Use when building a GPUI app that depends on gpui-query (v0.2.2): writing use_query / use_mutation / use_infinite_query / use_query_select hooks; configuring in-memory CachePolicy (NoCache/Ttl/StaleWhileRevalidate) or RetryPolicy; constructing QueryKey / QueryKeyFilter; calling QueryClient for fetch_query / prefetch / set_query_data / invalidate_queries / cancel_queries / reset_queries / remove_queries; wiring cx.set_global(QueryClient::new()); or debugging observer re-render / stale-write / GC behavior. Do NOT use for general GPUI app work that does not involve gpui-query, for the HTTP-cache/disk-persistence satellites (use the gpui-query-extensions skill), or for editing the gpui-query crate itself (see AGENTS.md for crate-internal work). --- # gpui-query (essential) -Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Crate: `gpui-query` v0.2.1. This skill covers the in-memory core/client/hook tiers (persistence and HTTP-cache satellites are separate). +Async state management for [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), inspired by TanStack Query v5. You write a fetcher; the library caches, retries, deduplicates, invalidates, garbage-collects, and cooperatively cancels. Crate: `gpui-query` v0.2.2. This skill covers the in-memory core/client/hook tiers (persistence and HTTP-cache satellites are separate). ## Install @@ -13,9 +13,9 @@ Three strictly-additive tiers, glob re-exported at the crate root (`pub use core | Tier | Cargo line | What you get | |---|---|---| -| core only (no GPUI) | `gpui-query = { version = "0.2.1", default-features = false, features = ["core"] }` | `QueryResource` state machine, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`. Zero GPUI dep, usable in non-GPUI libs. | -| client (DEFAULT) | `gpui-query = "0.2.1"` | + `QueryClient` GPUI `Global`: type-partitioned buckets, GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`. | -| hooks | `gpui-query = { version = "0.2.1", features = ["hook"] }` | + `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`. | +| core only (no GPUI) | `gpui-query = { version = "0.2.2", default-features = false, features = ["core"] }` | `QueryResource` state machine, `CachePolicy`, `RetryPolicy`, `QueryKey`, `QuerySignal`. Zero GPUI dep, usable in non-GPUI libs. | +| client (DEFAULT) | `gpui-query = "0.2.2"` | + `QueryClient` GPUI `Global`: type-partitioned buckets, GC, bulk invalidate/cancel/reset/remove, observers, `PreparedFetch`. | +| hooks | `gpui-query = { version = "0.2.2", features = ["hook"] }` | + `use_query` / `use_mutation` / `use_infinite_query` / `use_query_select`. | The `client` tier pulls `gpui = "0.2.2"`. macOS builds of any tier with GPUI need the Metal Toolchain installed once: `xcodebuild -downloadComponent MetalToolchain` (core-only builds need nothing). diff --git a/web/src/content/docs/docs/getting-started/installation.mdx b/web/src/content/docs/docs/getting-started/installation.mdx index df873c5..269aee8 100644 --- a/web/src/content/docs/docs/getting-started/installation.mdx +++ b/web/src/content/docs/docs/getting-started/installation.mdx @@ -18,7 +18,7 @@ or add it by hand to `Cargo.toml`: ```toml [dependencies] -gpui-query = "0.2.1" +gpui-query = "0.2.2" ``` :::note @@ -40,7 +40,7 @@ The crate is split into four layers, each behind a feature flag: ```toml [dependencies] -gpui-query = { version = "0.2.1", features = ["hook"] } +gpui-query = { version = "0.2.2", features = ["hook"] } ``` ```sh From 4a01068aa0079e0fe1fe9f26e5817e7115df3878 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Wed, 30 Sep 2026 23:07:22 +0200 Subject: [PATCH 074/111] chore: add criterion bench harness and baseline tooling --- .gitignore | 3 + crates/gpui-query-persist/Cargo.toml | 5 ++ .../benches/persist_round_trip.rs | 41 ++++++++++++++ crates/gpui-query/Cargo.toml | 17 ++++++ crates/gpui-query/benches/key_path.rs | 35 ++++++++++++ crates/gpui-query/benches/request_id.rs | 43 ++++++++++++++ crates/gpui-query/benches/request_policy.rs | 56 +++++++++++++++++++ crates/gpui-query/benches/sanitize.rs | 51 +++++++++++++++++ 8 files changed, 251 insertions(+) create mode 100644 crates/gpui-query-persist/benches/persist_round_trip.rs create mode 100644 crates/gpui-query/benches/key_path.rs create mode 100644 crates/gpui-query/benches/request_id.rs create mode 100644 crates/gpui-query/benches/request_policy.rs create mode 100644 crates/gpui-query/benches/sanitize.rs diff --git a/.gitignore b/.gitignore index 3b2c1f4..afba9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ Thumbs.db # z-proflow state .z-proflow/ +# z-flow state +.zflow/ + # Local Playwright/MCP artifacts .playwright-mcp/ diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index 45218d1..8bfd3ca 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -26,6 +26,11 @@ serde_json = { workspace = true } [dev-dependencies] gpui = { workspace = true } pollster = "0.4" +criterion = { workspace = true } + +[[bench]] +name = "persist_round_trip" +harness = false [package.metadata.docs.rs] all-features = true diff --git a/crates/gpui-query-persist/benches/persist_round_trip.rs b/crates/gpui-query-persist/benches/persist_round_trip.rs new file mode 100644 index 0000000..e518ee1 --- /dev/null +++ b/crates/gpui-query-persist/benches/persist_round_trip.rs @@ -0,0 +1,41 @@ +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui_query::client::{PersistSnapshot, PersistedEntry, Persister}; +use gpui_query::core::CachePolicy; +use gpui_query_persist::FilePersister; + +const ENTRY_COUNT: usize = 1000; + +fn thousand_entry_snapshot() -> PersistSnapshot { + let mut snapshot = PersistSnapshot::new(); + for i in 0..ENTRY_COUNT { + snapshot.entries.insert( + format!("tenant::acme::todos::{i}"), + PersistedEntry { + value: serde_json::json!({ "id": i, "title": "task item" }), + cached_at: 1_700_000_000_000, + cache_policy: CachePolicy::Ttl { ttl_ms: 60_000 }, + meta: None, + }, + ); + } + snapshot +} + +fn bench_file_persister_round_trip(c: &mut Criterion) { + let snapshot = thousand_entry_snapshot(); + let dir = tempfile::tempdir().expect("temp dir for bench"); + let persister = FilePersister::json(dir.path().join("gpui-query-bench-cache.json")); + c.bench_function("file_persister_json_round_trip_1000_entries", |b| { + b.iter(|| { + pollster::block_on(persister.save(black_box(&snapshot))).expect("save snapshot"); + let loaded = pollster::block_on(persister.load()).expect("load snapshot"); + std::fs::remove_file(persister.path()).expect("cleanup cache file"); + black_box(loaded.entries.len()) + }) + }); +} + +criterion_group!(benches, bench_file_persister_round_trip); +criterion_main!(benches); diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index cd26aff..d8fcb96 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -36,6 +36,23 @@ ahash = { version = "0.8", default-features = false, features = ["std", "compile serde_json = { workspace = true } gpui = { workspace = true, features = ["test-support"] } proptest = "1" +criterion = { workspace = true } + +[[bench]] +name = "sanitize" +harness = false + +[[bench]] +name = "key_path" +harness = false + +[[bench]] +name = "request_policy" +harness = false + +[[bench]] +name = "request_id" +harness = false [package.metadata.docs.rs] all-features = true diff --git a/crates/gpui-query/benches/key_path.rs b/crates/gpui-query/benches/key_path.rs new file mode 100644 index 0000000..9b5261f --- /dev/null +++ b/crates/gpui-query/benches/key_path.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui_query::core::QueryKey; + +const SNAPSHOT_KEYS: usize = 1000; + +fn bench_to_path(c: &mut Criterion) { + let key = QueryKey::from(["users", "42", "posts", "comments"]); + c.bench_function("query_key_to_path_4_segments", |b| { + b.iter(|| black_box(&key).to_path()) + }); +} + +fn bench_snapshot_keyed_by_to_path(c: &mut Criterion) { + let keys: Vec<QueryKey> = (0..SNAPSHOT_KEYS) + .map(|i| { + let id = i.to_string(); + QueryKey::from(["tenant", "acme", "todos", id.as_str()]) + }) + .collect(); + c.bench_function("snapshot_1000_keys_keyed_by_to_path", |b| { + b.iter(|| { + let snapshot: HashMap<String, QueryKey> = keys + .iter() + .map(|key| (key.to_path(), key.clone())) + .collect(); + black_box(snapshot.len()) + }) + }); +} + +criterion_group!(benches, bench_to_path, bench_snapshot_keyed_by_to_path); +criterion_main!(benches); diff --git a/crates/gpui-query/benches/request_id.rs b/crates/gpui-query/benches/request_id.rs new file mode 100644 index 0000000..dfeda42 --- /dev/null +++ b/crates/gpui-query/benches/request_id.rs @@ -0,0 +1,43 @@ +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui_query::core::{RequestId, RequestSequencer}; + +const WINDOW: usize = 1024; + +fn bench_request_id_mint(c: &mut Criterion) { + let mut sequencer = RequestSequencer::new(); + c.bench_function("request_sequencer_mint_loop", |b| { + b.iter(|| { + let mut checksum: u64 = 0; + for _ in 0..WINDOW { + checksum ^= black_box(&mut sequencer).next_request().value(); + } + black_box(checksum) + }) + }); +} + +fn bench_request_id_accept_equality(c: &mut Criterion) { + let mut sequencer = RequestSequencer::new(); + let current = sequencer.next_request(); + let incoming: Vec<RequestId> = (0..WINDOW).map(|_| sequencer.next_request()).collect(); + c.bench_function("request_id_accept_equality_loop", |b| { + b.iter(|| { + let mut matches: usize = 0; + for id in &incoming { + if black_box(*id) == black_box(current) { + matches += 1; + } + } + black_box(matches) + }) + }); +} + +criterion_group!( + benches, + bench_request_id_mint, + bench_request_id_accept_equality +); +criterion_main!(benches); diff --git a/crates/gpui-query/benches/request_policy.rs b/crates/gpui-query/benches/request_policy.rs new file mode 100644 index 0000000..1a9f706 --- /dev/null +++ b/crates/gpui-query/benches/request_policy.rs @@ -0,0 +1,56 @@ +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui_query::core::{CachePolicy, RetryPolicy}; + +const MAX_ATTEMPT: u32 = 64; + +fn bench_retry_backoff(c: &mut Criterion) { + let exponential = RetryPolicy::new(3).with_exponential_backoff(); + let flat = RetryPolicy::new(3); + c.bench_function("retry_policy_backoff_loop", |b| { + b.iter(|| { + let mut total: u64 = 0; + for attempt in 0..MAX_ATTEMPT { + total += black_box(&exponential).delay_for_attempt(attempt); + total += black_box(&flat).delay_for_attempt(attempt); + } + black_box(total) + }) + }); +} + +fn bench_cache_policy_staleness(c: &mut Criterion) { + let policies = [ + CachePolicy::NoCache, + CachePolicy::Ttl { ttl_ms: 60_000 }, + CachePolicy::StaleWhileRevalidate { + ttl_ms: 60_000, + stale_ms: 300_000, + }, + ]; + let ages = [0, 10_000, 90_000, 500_000, 3_600_000]; + c.bench_function("cache_policy_staleness_loop", |b| { + b.iter(|| { + let mut fresh_count: u32 = 0; + for policy in policies { + for &age in &ages { + let age = black_box(age); + if policy.is_fresh(age) { + fresh_count += 1; + } + if policy.is_stale_but_serveable(age) { + fresh_count += 1; + } + if policy.is_expired(age) { + fresh_count += 1; + } + } + } + black_box(fresh_count) + }) + }); +} + +criterion_group!(benches, bench_retry_backoff, bench_cache_policy_staleness); +criterion_main!(benches); diff --git a/crates/gpui-query/benches/sanitize.rs b/crates/gpui-query/benches/sanitize.rs new file mode 100644 index 0000000..7aad0b9 --- /dev/null +++ b/crates/gpui-query/benches/sanitize.rs @@ -0,0 +1,51 @@ +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui_query::core::QueryError; + +const KIB: usize = 1024; + +fn no_pattern_fill(size: usize) -> String { + let unit = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor "; + let mut out = unit.repeat(size / unit.len() + 1); + out.truncate(size); + out +} + +fn paths_and_schemes_fill(size: usize) -> String { + let unit = "postgres://db.internal:5432/app /home/alice/report.pdf mysql://db2.internal:3306/shop /etc/config.toml redis://cache:6379/0 /var/log/app.log mongodb://cluster.internal:27017/grid /users/bob/notes.txt "; + let mut out = unit.repeat(size / unit.len() + 1); + out.truncate(size); + out +} + +fn token_fill(size: usize) -> String { + let unit = + "login token=abc123 rejected; session token expired; bearer xyz failed for token refresh "; + let mut out = unit.repeat(size / unit.len() + 1); + out.truncate(size); + out +} + +fn bench_sanitize(c: &mut Criterion) { + let small = QueryError::unknown(no_pattern_fill(4 * KIB)); + let large = QueryError::unknown(no_pattern_fill(64 * KIB)); + let dense = QueryError::unknown(paths_and_schemes_fill(32 * KIB)); + let tokened = QueryError::unknown(token_fill(4 * KIB)); + + c.bench_function("sanitize_no_pattern_4kib", |b| { + b.iter(|| black_box(&small).sanitized()) + }); + c.bench_function("sanitize_no_pattern_64kib", |b| { + b.iter(|| black_box(&large).sanitized()) + }); + c.bench_function("sanitize_paths_and_schemes_32kib", |b| { + b.iter(|| black_box(&dense).sanitized()) + }); + c.bench_function("sanitize_token_4kib", |b| { + b.iter(|| black_box(&tokened).sanitized()) + }); +} + +criterion_group!(benches, bench_sanitize); +criterion_main!(benches); From aa1ca0fcd3601df16f6adcbb3be8c5eb2cfefe75 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 075/111] fix: reserve fallback request ids and skip duplicate swr fetches --- crates/gpui-query/src/client/lifecycle.rs | 32 +-- crates/gpui-query/src/client/mod.rs | 8 +- crates/gpui-query/src/core/request.rs | 19 +- .../gpui-query/src/core/resource/lifecycle.rs | 11 +- crates/gpui-query/src/hook/fetch_retry.rs | 11 +- .../tests/core_request/request_lifecycle.rs | 30 ++- .../query_tests/fetch_and_lifecycle/fetch.rs | 162 ++++++++++++++- .../select_and_retry/select_tests.rs | 187 +++++++++++++++++- .../src/tests/hook_tests/regression_tests.rs | 120 ++++++++++- 9 files changed, 552 insertions(+), 28 deletions(-) diff --git a/crates/gpui-query/src/client/lifecycle.rs b/crates/gpui-query/src/client/lifecycle.rs index 7678c6d..8bd4620 100644 --- a/crates/gpui-query/src/client/lifecycle.rs +++ b/crates/gpui-query/src/client/lifecycle.rs @@ -256,21 +256,27 @@ impl QueryClient { // Only Started and StaleCacheHit mean a fetch is actually wanted. let (request_id, signal) = entity.update(cx, |resource, _| { - let started = matches!( - resource.begin_request_with_id( - Some(request_id), - now_ms, - crate::core::QueryFetchMode::Normal - ), + let active_before = resource.active_request_id(); + match resource.begin_request_with_id( + Some(request_id), + now_ms, + crate::core::QueryFetchMode::Normal, + ) { + // IgnoreWhileLoading handing back the still-active id means a + // revalidate is already running; prefetch must not duplicate it. + crate::core::QueryBeginResult::StaleCacheHit { + request_id, + replaced_request_id: None, + .. + } if Some(request_id) == active_before => None, crate::core::QueryBeginResult::Started { .. } - | crate::core::QueryBeginResult::StaleCacheHit { .. } - ); - if !started { - return None; + | crate::core::QueryBeginResult::StaleCacheHit { .. } => { + let rid = resource.active_request_id()?; + let signal = resource.signal().cloned()?; + Some((rid, signal)) + } + _ => None, } - let rid = resource.active_request_id()?; - let signal = resource.signal().cloned()?; - Some((rid, signal)) })?; Some(PreparedFetch { diff --git a/crates/gpui-query/src/client/mod.rs b/crates/gpui-query/src/client/mod.rs index 8fb729c..46f2a1e 100644 --- a/crates/gpui-query/src/client/mod.rs +++ b/crates/gpui-query/src/client/mod.rs @@ -192,7 +192,10 @@ impl QueryClient { /// Mints the request id in the same bucket lookup, skipping a second /// TypeId+key hash of a follow-up `next_request_id_for_key`. - fn resource_with_request_id<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static>( + fn resource_with_request_id< + T: Clone + Send + Sync + 'static, + E: Clone + Send + Sync + 'static, + >( &mut self, key: impl Into<QueryKey>, cache_policy: CachePolicy, @@ -312,10 +315,9 @@ impl QueryClient { let entity = self.resource::<T, E>(key, cx); entity.update(cx, |resource, cx| { resource.set_data(data); + cx.notify(); #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); - #[cfg(not(feature = "persist"))] - let _ = cx; }); } } diff --git a/crates/gpui-query/src/core/request.rs b/crates/gpui-query/src/core/request.rs index 086c54c..4a9bf6d 100644 --- a/crates/gpui-query/src/core/request.rs +++ b/crates/gpui-query/src/core/request.rs @@ -52,7 +52,8 @@ impl std::fmt::Display for RequestId { /// The sequence increments from 1; at `u64::MAX` the scope advances and the /// sequence resets. If the scope itself overflows it wraps to 1, so a fresh /// id could theoretically collide with a very old one still held by a -/// long-running future. +/// long-running future. Fallback mints via `next_fallback` +/// stay in a reserved top scope, disjoint from ids minted from 1. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RequestSequencer { pub(crate) scope_id: NonZero<u64>, @@ -76,12 +77,16 @@ impl MaybeRequestId<'_> { pub(crate) fn next(&mut self, fallback: &mut RequestSequencer) -> RequestId { match self { Self::FromSequencer(sequencer) => sequencer.next_request(), - Self::Provided(maybe_id) => maybe_id.unwrap_or_else(|| fallback.next_request()), + Self::Provided(maybe_id) => maybe_id.unwrap_or_else(|| fallback.next_fallback()), } } } impl RequestSequencer { + /// Reserved scope for resource-local fallback ids; `new()` sequencers + /// start at 1, so the two id spaces stay disjoint. + pub(crate) const RESERVED_FALLBACK_SCOPE: NonZero<u64> = NonZero::<u64>::MAX; + pub fn new() -> Self { Self { scope_id: NonZero::<u64>::MIN, @@ -89,6 +94,16 @@ impl RequestSequencer { } } + /// Mints in the reserved fallback scope, re-scoping sequencers that start + /// in the shared space (fresh or deserialized resources). + pub(crate) fn next_fallback(&mut self) -> RequestId { + if self.scope_id != Self::RESERVED_FALLBACK_SCOPE { + self.scope_id = Self::RESERVED_FALLBACK_SCOPE; + self.next_request_id = 1; + } + self.next_request() + } + pub fn next_request(&mut self) -> RequestId { let request_id = RequestId::scoped(self.scope_id, self.next_request_id); if self.next_request_id == u64::MAX { diff --git a/crates/gpui-query/src/core/resource/lifecycle.rs b/crates/gpui-query/src/core/resource/lifecycle.rs index a1a403b..2d9be3e 100644 --- a/crates/gpui-query/src/core/resource/lifecycle.rs +++ b/crates/gpui-query/src/core/resource/lifecycle.rs @@ -18,8 +18,8 @@ impl<T, E> QueryResource<T, E> { } /// `Some(id)` is used as-is (bucket-scoped ids from the hook layer); - /// `None` falls back to the resource's own sequencer so ids stay - /// monotonic and collision-free. + /// `None` falls back to the resource's own sequencer, which mints from a + /// reserved scope so fallback ids never alias bucket-minted ones. pub fn begin_request_with_id( &mut self, maybe_request_id: Option<RequestId>, @@ -145,6 +145,7 @@ impl<T, E> QueryResource<T, E> { if self.data.is_some() { self.previous_data = self.data.take(); + self.data_epoch = self.data_epoch.saturating_add(1); } if let Some(signal) = self.signal.as_ref() { @@ -175,6 +176,9 @@ impl<T, E> QueryResource<T, E> { signal.cancel(); } self.status = QueryStatus::Idle; + if self.data.is_some() { + self.data_epoch = self.data_epoch.saturating_add(1); + } self.data = None; self.error = None; self.active_request_id = None; @@ -195,6 +199,7 @@ impl<T, E> QueryResource<T, E> { self.data = Some(prev); self.status = QueryStatus::Success; self.error = None; + self.data_epoch = self.data_epoch.saturating_add(1); return true; } false @@ -203,12 +208,14 @@ impl<T, E> QueryResource<T, E> { pub fn set_data(&mut self, data: T) { self.previous_data = self.data.take(); self.data = Some(data); + self.data_epoch = self.data_epoch.saturating_add(1); } /// Status drops from `Success` to `Idle` so `Success` keeps implying data /// is available. pub fn clear_data(&mut self) { self.previous_data = self.data.take(); + self.data_epoch = self.data_epoch.saturating_add(1); if self.status == QueryStatus::Success { self.status = QueryStatus::Idle; } diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index fcf9cc0..acdd3af 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -46,7 +46,8 @@ impl<T> FetchedLike<T> for Fetched<T> { } /// Freshness check, `Loading` transition, and signal read in one -/// `entity.update`; `(None, None)` means `CacheHit`/`IgnoredWhileLoading`. +/// `entity.update`; `(None, None)` means no fetch: `CacheHit`, +/// `IgnoredWhileLoading`, or a revalidate already in flight. /// /// With a [`QueryClient`], the bucket sequencer mints the `RequestId`, shared /// with `prepare_fetch_query` so the two never collide for the same key. @@ -73,7 +74,15 @@ where }; entity.update(cx, |resource, _cx| { + let active_before = resource.active_request_id(); match resource.begin_request_with_id(maybe_request_id, now_ms, fetch_mode) { + // IgnoreWhileLoading hands back the still-active id unminted; + // spawning here would race the original fetcher. + QueryBeginResult::StaleCacheHit { + request_id, + replaced_request_id: None, + .. + } if Some(request_id) == active_before => (None, None), QueryBeginResult::Started { request_id, .. } | QueryBeginResult::StaleCacheHit { request_id, .. } => { let signal = resource.signal().cloned(); diff --git a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs index 24392ab..23ea441 100644 --- a/crates/gpui-query/src/tests/core_request/request_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_request/request_lifecycle.rs @@ -1,6 +1,6 @@ use crate::core::{ CachePolicy, QueryBeginResult, QueryFetchMode, QueryResource, QueryStatus, RequestId, - RequestPolicy, + RequestPolicy, RequestSequencer, }; use crate::tests::test_support::{ TEST_NOW_MS, assert_status, begin_request_id, test_resource_with_policies, test_sequencer, @@ -127,7 +127,7 @@ fn begin_request_with_id_uses_provided_id() { } #[test] -fn begin_request_with_id_none_falls_back_to_transient_sequencer() { +fn begin_request_with_id_none_mints_in_reserved_fallback_scope() { let mut resource: QueryResource<&str> = test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); @@ -136,10 +136,34 @@ fn begin_request_with_id_none_falls_back_to_transient_sequencer() { QueryBeginResult::Started { request_id, .. } => request_id, other => panic!("expected Started, got {:?}", other), }; - assert_eq!(rid.scope_id(), NonZero::new(1).unwrap()); + assert_eq!(rid.scope_id(), RequestSequencer::RESERVED_FALLBACK_SCOPE); assert_eq!(rid.value(), 1); } +#[test] +fn fallback_ids_stay_in_reserved_scope_across_mints() { + let mut resource: QueryResource<&str> = + test_resource_with_policies("key", CachePolicy::NoCache, RequestPolicy::LatestWins); + + let first = match resource.begin_request_with_id(None, TEST_NOW_MS, QueryFetchMode::Normal) { + QueryBeginResult::Started { request_id, .. } => request_id, + other => panic!("expected Started, got {:?}", other), + }; + let _ = resource.accept_current_request(first).unwrap(); + let second = match resource.begin_request_with_id(None, TEST_NOW_MS, QueryFetchMode::Normal) { + QueryBeginResult::Started { request_id, .. } => request_id, + other => panic!("expected Started, got {:?}", other), + }; + + assert_eq!( + second.scope_id(), + RequestSequencer::RESERVED_FALLBACK_SCOPE, + "successive fallback mints must stay in the reserved scope" + ); + assert_eq!(second.value(), 2); + assert_ne!(first, second); +} + #[test] fn begin_request_with_id_swr_ignore_while_loading_keeps_active_request() { let mut r: QueryResource<&str> = QueryResource::new( diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs index 072a57d..8debea7 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/fetch_and_lifecycle/fetch.rs @@ -1,11 +1,62 @@ use std::sync::{Arc, Mutex}; -use gpui::{AppContext as _, Entity, TestAppContext}; +use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; -use crate::core::{CachePolicy, QueryError, QueryKey, QueryResource, QueryStatus, RequestPolicy}; +use crate::client::QueryClient; +use crate::core::{ + CachePolicy, QueryBeginResult, QueryError, QueryFetchMode, QueryKey, QueryResource, + QuerySignal, QueryStatus, RequestId, RequestPolicy, RequestSequencer, +}; use crate::hook::*; use crate::tests::test_support::*; +fn stale_prefetched_resource_with_inflight_revalidate( + cx: &mut TestAppContext, + key: &'static str, + request_policy: RequestPolicy, +) -> ( + Entity<QueryResource<String, QueryError>>, + RequestId, + Option<QuerySignal>, +) { + let policy = CachePolicy::StaleWhileRevalidate { + ttl_ms: 1, + stale_ms: 60_000, + }; + let entity = cx.update(|cx| { + let prepared = cx + .update_global::<QueryClient, _>(|client, cx| { + client.prepare_prefetch_query::<String, QueryError>( + QueryKey::from(key), + policy, + request_policy, + cx, + ) + }) + .expect("prefetch starts on empty cache"); + let entity = prepared.entity.clone(); + prepared.complete_success("v1".to_string(), cx); + entity.update(cx, |r, _| { + r.apply_success("v1".to_string(), current_time_ms().saturating_sub(2_000)); + }); + entity + }); + + let (inflight_id, inflight_signal) = cx.update(|cx| { + entity.update(cx, |r, _| { + let mut seq = RequestSequencer::new(); + match r.begin_request(&mut seq, current_time_ms(), QueryFetchMode::Normal) { + QueryBeginResult::Started { request_id, .. } + | QueryBeginResult::StaleCacheHit { request_id, .. } => { + (request_id, r.signal().cloned()) + } + other => panic!("expected in-flight revalidate start, got {other:?}"), + } + }) + }); + (entity, inflight_id, inflight_signal) +} + #[gpui::test] fn test_use_query_manual_no_auto_fetch_then_manual_fetch(cx: &mut TestAppContext) { setup_query_client(cx); @@ -321,3 +372,110 @@ fn test_fetch_query_with_signal_failure(cx: &mut TestAppContext) { assert!(err.to_string().contains("signal-error")); }); } + +#[gpui::test] +fn fetch_query_while_loading_ignore_policy_does_not_spawn_duplicate_fetcher( + cx: &mut TestAppContext, +) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + setup_query_client(cx); + + struct H { + entity: Entity<QueryResource<String, QueryError>>, + } + + let count = Arc::new(AtomicUsize::new(0)); + let (entity, inflight_id, inflight_signal) = stale_prefetched_resource_with_inflight_revalidate( + cx, + "dup-ignore", + RequestPolicy::IgnoreWhileLoading, + ); + let harness = cx.new(|_| H { entity }); + + let count_clone = count.clone(); + cx.update(|cx| { + harness.update(cx, |h, cx| { + fetch_query( + &h.entity, + move || { + let c = count_clone.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Ok::<_, QueryError>("v2".to_string()) + } + }, + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + let resource = harness.read(cx).entity.read(cx); + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "fetch_query spawned a duplicate fetcher while a revalidate was \ + in flight under IgnoreWhileLoading" + ); + assert!( + !inflight_signal.unwrap().is_cancelled(), + "the in-flight revalidate must keep its signal" + ); + assert_eq!(resource.active_request_id(), Some(inflight_id)); + assert_eq!(resource.data(), Some(&"v1".to_string())); + }); +} + +#[gpui::test] +fn fetch_query_while_loading_latest_wins_replaces_in_flight_request(cx: &mut TestAppContext) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + setup_query_client(cx); + + struct H { + entity: Entity<QueryResource<String, QueryError>>, + } + + let count = Arc::new(AtomicUsize::new(0)); + let (entity, _inflight_id, inflight_signal) = + stale_prefetched_resource_with_inflight_revalidate( + cx, + "dup-latest", + RequestPolicy::LatestWins, + ); + let harness = cx.new(|_| H { entity }); + + let count_clone = count.clone(); + cx.update(|cx| { + harness.update(cx, |h, cx| { + fetch_query( + &h.entity, + move || { + let c = count_clone.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Ok::<_, QueryError>("v2".to_string()) + } + }, + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + let resource = harness.read(cx).entity.read(cx); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "LatestWins must replace the in-flight revalidate with a fresh fetch" + ); + assert!( + inflight_signal.unwrap().is_cancelled(), + "LatestWins must cancel the replaced request's signal" + ); + assert_eq!(resource.data(), Some(&"v2".to_string())); + }); +} diff --git a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs index 5d42d5f..e8ac150 100644 --- a/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/query_tests/select_and_retry/select_tests.rs @@ -1,7 +1,9 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use gpui::{AppContext as _, Entity, TestAppContext}; +use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; +use crate::client::QueryClient; use crate::core::{ CachePolicy, MappedQueryResource, QueryError, QueryResource, QueryStatus, RetryPolicy, SelectTransform, @@ -302,3 +304,186 @@ fn test_use_query_select_multiple_transforms_same_query(cx: &mut TestAppContext) "only one fetch should have occurred — second select must be a cache hit" ); } + +#[gpui::test] +fn use_query_select_propagates_optimistic_set_query_data(cx: &mut TestAppContext) { + setup_query_client(cx); + + struct H { + mapped: Entity<MappedQueryResource<String, usize, QueryError>>, + _query: Entity<QueryResource<String, QueryError>>, + _subs: (gpui::Subscription, gpui::Subscription), + } + + let harness = cx.new(|cx| { + let (mapped, query, subs) = use_query_select( + QueryOptions::new("select-optimistic").cache_policy(CachePolicy::Ttl { ttl_ms: 0 }), + SelectTransform::new(|data: &String| data.len()), + |_signal| async move { Ok::<_, QueryError>("first".to_string()) }, + cx, + ); + H { + mapped, + _query: query, + _subs: subs, + } + }); + + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!(harness.read(cx).mapped.read(cx).data(), Some(5)); + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>( + "select-optimistic", + "second-value".to_string(), + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + harness.read(cx).mapped.read(cx).data(), + Some(12), + "same-status optimistic write must propagate through the select projection" + ); + }); +} + +#[gpui::test] +fn use_query_select_propagates_equal_value_write_via_data_epoch(cx: &mut TestAppContext) { + setup_query_client(cx); + + struct H { + mapped: Entity<MappedQueryResource<String, usize, QueryError>>, + _query: Entity<QueryResource<String, QueryError>>, + _subs: (gpui::Subscription, gpui::Subscription), + sub2: Option<gpui::Subscription>, + counter: Arc<AtomicUsize>, + } + + let harness = cx.new(|cx| { + let (mapped, query, subs) = use_query_select( + QueryOptions::new("select-equal-write"), + SelectTransform::new(|data: &String| data.len()), + |_signal| async move { Ok::<_, QueryError>("same".to_string()) }, + cx, + ); + H { + mapped, + _query: query, + _subs: subs, + sub2: None, + counter: Arc::new(AtomicUsize::new(0)), + } + }); + + cx.run_until_parked(); + + harness.update(cx, |h, cx| { + let counter = h.counter.clone(); + h.sub2 = Some(cx.observe(&h.mapped, move |_, _, _| { + counter.fetch_add(1, Ordering::SeqCst); + })); + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>( + "select-equal-write", + "same".to_string(), + cx, + ); + }); + }); + cx.run_until_parked(); + + assert_eq!( + harness.read_with(cx, |h, _| h.counter.load(Ordering::SeqCst)), + 1, + "an equal-value data write still moves the data epoch and must reach the mapped entity" + ); +} + +struct CloneCounting { + value: u32, + clones: Arc<AtomicUsize>, +} + +impl Clone for CloneCounting { + fn clone(&self) -> Self { + self.clones.fetch_add(1, Ordering::SeqCst); + Self { + value: self.value, + clones: Arc::clone(&self.clones), + } + } +} + +impl PartialEq for CloneCounting { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +#[gpui::test] +fn use_query_select_skips_clone_on_notify_without_data_write(cx: &mut TestAppContext) { + setup_query_client(cx); + + struct H { + mapped: Entity<MappedQueryResource<CloneCounting, u32, QueryError>>, + query: Entity<QueryResource<CloneCounting, QueryError>>, + _subs: (gpui::Subscription, gpui::Subscription), + } + + let clones = Arc::new(AtomicUsize::new(0)); + let fetched = CloneCounting { + value: 7, + clones: clones.clone(), + }; + + let harness = cx.new(|cx| { + let (mapped, query, subs) = use_query_select( + QueryOptions::new("select-no-clone"), + SelectTransform::new(|data: &CloneCounting| data.value), + move |_signal| { + let fetched = fetched.clone(); + async move { Ok::<_, QueryError>(fetched) } + }, + cx, + ); + H { + mapped, + query, + _subs: subs, + } + }); + cx.run_until_parked(); + + let baseline = clones.load(Ordering::SeqCst); + assert!( + baseline > 0, + "the fetch result was cloned into the projection" + ); + + let query_entity = cx.update(|cx| harness.read(cx).query.clone()); + for _ in 0..5 { + cx.update(|cx| { + query_entity.update(cx, |_, cx| cx.notify()); + }); + } + + assert_eq!( + clones.load(Ordering::SeqCst), + baseline, + "notifies that carry no data write must not re-clone T into the projection" + ); + cx.update(|cx| { + assert_eq!(harness.read(cx).mapped.read(cx).data(), Some(7)); + }); +} diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index deb9ed2..c17507b 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -1,7 +1,9 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use gpui::{AppContext as _, Entity, TestAppContext}; +use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; +use crate::client::QueryClient; use crate::core::{ CachePolicy, InfiniteQueryResource, MutationResource, MutationStatus, QueryError, QueryKey, QueryResource, RequestPolicy, RetryPolicy, @@ -390,3 +392,119 @@ fn hook_mutation_retry_count_reset_on_success(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +fn optimistic_set_query_data_reaches_mounted_use_query_observer(cx: &mut TestAppContext) { + setup_test(cx); + + struct H { + _entity: Entity<QueryResource<String, QueryError>>, + _sub: gpui::Subscription, + } + + let key = QueryKey::from("optimistic-notify"); + let harness = cx.new(|cx| { + let (entity, sub) = use_query_manual::<String, QueryError, _>( + key.clone(), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + cx, + ); + H { + _entity: entity, + _sub: sub, + } + }); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key.clone(), "v1".to_string(), cx); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key, "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "both optimistic writes must re-render the mounted consumer; the second \ + is a same-status write that only the data epoch lets through" + ); +} + +#[gpui::test] +fn prefetch_completion_reaches_mounted_use_query_observer(cx: &mut TestAppContext) { + setup_test(cx); + + struct H { + entity: Entity<QueryResource<String, QueryError>>, + _sub: gpui::Subscription, + } + + let key = QueryKey::from("prefetch-notify"); + let prepared = cx + .update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.prepare_fetch_query::<String, QueryError>(key.clone(), cx) + }) + }) + .expect("prepare_fetch_query should start"); + + let harness = cx.new(|cx| { + let (entity, sub) = use_query_manual::<String, QueryError, _>( + key.clone(), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + cx, + ); + assert_eq!(entity.entity_id(), prepared.entity.entity_id()); + H { entity, _sub: sub } + }); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + cx.update(|cx| { + prepared.complete_success("v1".to_string(), cx); + }); + cx.run_until_parked(); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key, "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + harness.read(cx).entity.read(cx).data(), + Some(&"v2".to_string()) + ); + }); + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "prefetch completion and the same-status optimistic write must both \ + re-render the mounted consumer" + ); +} From 0f09199ecbeef3a0b5dfd0c9c3fdebc4089f21fb Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 076/111] fix: notify observers on direct cache writes and epoch-compare select --- crates/gpui-query/src/client/observer.rs | 27 ++++++++++++++----- .../gpui-query/src/client/prepared_fetch.rs | 4 ++- crates/gpui-query/src/core/resource.rs | 5 ++++ .../gpui-query/src/core/resource/accessors.rs | 5 ++++ .../src/core/resource/completion.rs | 3 +++ crates/gpui-query/src/hook/query_hooks.rs | 2 +- .../gpui-query/src/hook/use_query_select.rs | 22 ++++++++------- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/crates/gpui-query/src/client/observer.rs b/crates/gpui-query/src/client/observer.rs index 30cc274..f976ac7 100644 --- a/crates/gpui-query/src/client/observer.rs +++ b/crates/gpui-query/src/client/observer.rs @@ -16,6 +16,12 @@ pub trait ObservableResource { type Status: PartialEq + Copy + 'static; fn observable_status(&self) -> Self::Status; + + /// Lets the status-dedup pass same-status data writes through; + /// a constant value means "data never changes". + fn data_epoch(&self) -> u64 { + 0 + } } impl<T: 'static, E: 'static> ObservableResource for QueryResource<T, E> { @@ -24,6 +30,10 @@ impl<T: 'static, E: 'static> ObservableResource for QueryResource<T, E> { fn observable_status(&self) -> QueryStatus { self.status() } + + fn data_epoch(&self) -> u64 { + QueryResource::data_epoch(self) + } } impl<T: 'static, E: 'static> ObservableResource for InfiniteQueryResource<T, E> { @@ -55,9 +65,9 @@ impl Default for ObserverConfig { } } -/// With the default config, `cx.notify()` fires only when the status -/// actually changes, so same-status updates (retry count increments, -/// `prepare_retry`) do not re-render. +/// With the default config, `cx.notify()` fires only when the status or the +/// resource's data epoch changes, so same-status no-op updates (retry count +/// increments, `prepare_retry`) do not re-render. pub struct Observer<R> { entity: gpui::WeakEntity<R>, config: ObserverConfig, @@ -82,13 +92,18 @@ impl<R: ObservableResource + 'static> Observer<R> { let upgraded = self.entity.upgrade()?; let notify_on_change = self.config.notify_on_status_change_only; let last_status: Cell<Option<R::Status>> = Cell::new(None); + let last_epoch: Cell<Option<u64>> = Cell::new(None); let subscription = cx.observe(&upgraded, move |_, entity, cx| { - let current_status = entity.read(cx).observable_status(); + let resource = entity.read(cx); + let current_status = resource.observable_status(); + let current_epoch = resource.data_epoch(); if notify_on_change { - let previous = last_status.get(); - if previous != Some(current_status) { + let status_changed = last_status.get() != Some(current_status); + let data_changed = last_epoch.get() != Some(current_epoch); + if status_changed || data_changed { last_status.set(Some(current_status)); + last_epoch.set(Some(current_epoch)); cx.notify(); } } else { diff --git a/crates/gpui-query/src/client/prepared_fetch.rs b/crates/gpui-query/src/client/prepared_fetch.rs index b5093aa..a77ed04 100644 --- a/crates/gpui-query/src/client/prepared_fetch.rs +++ b/crates/gpui-query/src/client/prepared_fetch.rs @@ -54,8 +54,10 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Prepare resource.complete_current_failure(self.request_id, error, self.now_ms) } }; - // Wake the persistence driver only when accepted; a stale no-op must not schedule a save. + // Wake observers and the persistence driver only when accepted; a + // stale no-op must not re-render or schedule a save. if accepted { + cx.notify(); #[cfg(feature = "persist")] cx.default_global::<crate::client::CacheMutation>(); } diff --git a/crates/gpui-query/src/core/resource.rs b/crates/gpui-query/src/core/resource.rs index e569766..f18f974 100644 --- a/crates/gpui-query/src/core/resource.rs +++ b/crates/gpui-query/src/core/resource.rs @@ -30,6 +30,10 @@ pub struct QueryResource<T, E = QueryError> { retry_count: u32, retry_policy: RetryPolicy, previous_data: Option<T>, + /// Counts data writes, not value changes; runtime only, so change + /// detection never deep-compares `T`. + #[serde(skip)] + data_epoch: u64, /// Runtime state, not persisted; supplies monotonic ids when no external /// sequencer is provided. #[serde(skip)] @@ -60,6 +64,7 @@ impl<T, E> QueryResource<T, E> { retry_count: 0, retry_policy: RetryPolicy::no_retries(), previous_data: None, + data_epoch: 0, transient_sequencer: RequestSequencer::new(), signal: None, } diff --git a/crates/gpui-query/src/core/resource/accessors.rs b/crates/gpui-query/src/core/resource/accessors.rs index d06d6d8..0e688f4 100644 --- a/crates/gpui-query/src/core/resource/accessors.rs +++ b/crates/gpui-query/src/core/resource/accessors.rs @@ -66,6 +66,11 @@ impl<T, E> QueryResource<T, E> { self.data.is_some() } + /// Counts data writes, not value changes; compare instead of deep-comparing `T`. + pub fn data_epoch(&self) -> u64 { + self.data_epoch + } + pub fn signal(&self) -> Option<&QuerySignal> { self.signal.as_ref() } diff --git a/crates/gpui-query/src/core/resource/completion.rs b/crates/gpui-query/src/core/resource/completion.rs index f463bfd..2e1f358 100644 --- a/crates/gpui-query/src/core/resource/completion.rs +++ b/crates/gpui-query/src/core/resource/completion.rs @@ -91,6 +91,7 @@ impl<T, E> QueryResource<T, E> { self.error = None; self.active_request_id = None; self.last_updated_at = Some(QueryTimestamp::from(now_ms)); + self.data_epoch = self.data_epoch.saturating_add(1); } pub(crate) fn apply_failure(&mut self, error: impl Into<E>, now_ms: u64) { @@ -111,6 +112,7 @@ impl<T, E> QueryResource<T, E> { self.error = None; self.active_request_id = None; self.last_updated_at = Some(QueryTimestamp::from(now_ms)); + self.data_epoch = self.data_epoch.saturating_add(1); } pub(crate) fn apply_failure_with_data(&mut self, data: T, error: impl Into<E>, now_ms: u64) { @@ -119,6 +121,7 @@ impl<T, E> QueryResource<T, E> { self.error = Some(error.into()); self.active_request_id = None; self.last_updated_at = Some(QueryTimestamp::from(now_ms)); + self.data_epoch = self.data_epoch.saturating_add(1); } /// `accept_current_request` clears `active_request_id`, so a `Some` here diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index 8346cb4..deca369 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -2,9 +2,9 @@ //! two-phase `accept_current_request` protocol, and each task holds only a //! `WeakEntity`, so it self-terminates on entity drop. -use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; #[cfg(not(debug_assertions))] use gpui::AppContext as _; +use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; use crate::client::{QueryClient, QueryObserver}; use crate::core::{ diff --git a/crates/gpui-query/src/hook/use_query_select.rs b/crates/gpui-query/src/hook/use_query_select.rs index 33acd61..9f5c64d 100644 --- a/crates/gpui-query/src/hook/use_query_select.rs +++ b/crates/gpui-query/src/hook/use_query_select.rs @@ -1,6 +1,7 @@ //! Cached data projected into a derived shape, re-running only when the data //! changes; the hook returns a `MappedQueryResource<T, U, E>`. +use std::cell::Cell; use std::sync::Arc; use gpui::{AppContext as _, Context, Entity, Subscription}; @@ -71,26 +72,27 @@ where { let (query_entity, query_subscription) = use_query(options, fetcher, cx); - let initial_data: Option<Arc<T>> = - query_entity.read_with(cx, |r, _| r.data().map(|d| Arc::new(d.clone()))); + let (initial_data, initial_epoch) = query_entity.read_with(cx, |r, _| { + (r.data().map(|d| Arc::new(d.clone())), r.data_epoch()) + }); let mapped = MappedQueryResource::new(initial_data, transform); let mapped_entity = cx.new(|_| mapped); let mapped_weak = mapped_entity.downgrade(); + let last_epoch = Cell::new(initial_epoch); let mapped_subscription = cx.observe(&query_entity, move |_, entity, cx| { let Some(mapped) = mapped_weak.upgrade() else { return; }; - let cached: Option<Arc<T>> = mapped.read_with(cx, |m, _| m.source_arc()); - // One read computes both the change verdict and the replacement value. + // The data epoch is the change verdict: no deep-compare of `T` here, + // and `T` is cloned only when a write actually moved the epoch. let update: Option<Option<Arc<T>>> = entity.read_with(cx, |r, _| { - let changed = match (&cached, r.data()) { - (Some(c), Some(fresh)) => c.as_ref() != fresh, - (None, None) => false, - _ => true, - }; - changed.then(|| r.data().map(|d| Arc::new(d.clone()))) + let epoch = r.data_epoch(); + (epoch != last_epoch.get()).then(|| { + last_epoch.set(epoch); + r.data().map(|d| Arc::new(d.clone())) + }) }); if let Some(fresh) = update { From 609263380ee8ad4971efb2918b0fc8b13404083c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 077/111] fix: dirty-track persist snapshots and make key paths injective --- crates/gpui-query-persist/src/lib.rs | 13 +- .../tests/file_persister.rs | 10 +- crates/gpui-query/src/client/persist.rs | 138 ++++-- crates/gpui-query/src/core/key.rs | 85 +++- .../client_gap_coverage/gc_coverage.rs | 149 +++++- .../client_gap_coverage/hook_coverage.rs | 3 +- .../client_operations/gc_query_operations.rs | 36 +- .../client_operations/persist_with_hydrate.rs | 468 +++++++++++++++++- 8 files changed, 834 insertions(+), 68 deletions(-) diff --git a/crates/gpui-query-persist/src/lib.rs b/crates/gpui-query-persist/src/lib.rs index 4806c84..628a1ae 100644 --- a/crates/gpui-query-persist/src/lib.rs +++ b/crates/gpui-query-persist/src/lib.rs @@ -136,9 +136,10 @@ impl FilePersister { file.read_to_end(&mut buf)?; let (label, parsed): (&str, Result<PersistSnapshot, String>) = match self.format { - PersistFormat::Json => { - ("JSON", serde_json::from_slice(&buf).map_err(|e| e.to_string())) - } + PersistFormat::Json => ( + "JSON", + serde_json::from_slice(&buf).map_err(|e| e.to_string()), + ), PersistFormat::Bincode => ("bincode", bincode_load(&buf)), }; let snapshot = match parsed { @@ -200,11 +201,7 @@ impl BincodeSnapshot { value_json: serde_json::to_string(&e.value)?, cached_at: e.cached_at, cache_policy: e.cache_policy, - meta_json: e - .meta - .as_ref() - .map(serde_json::to_string) - .transpose()?, + meta_json: e.meta.as_ref().map(serde_json::to_string).transpose()?, }, ); } diff --git a/crates/gpui-query-persist/tests/file_persister.rs b/crates/gpui-query-persist/tests/file_persister.rs index f6f734f..53fc3f0 100644 --- a/crates/gpui-query-persist/tests/file_persister.rs +++ b/crates/gpui-query-persist/tests/file_persister.rs @@ -301,7 +301,10 @@ fn file_persister_non_utf8_json_yields_empty_snapshot() { let p = FilePersister::json(&path); let loaded = pollster::block_on(p.load()).expect("tolerant load"); - assert!(loaded.entries.is_empty(), "non-UTF8 cache -> empty snapshot"); + assert!( + loaded.entries.is_empty(), + "non-UTF8 cache -> empty snapshot" + ); } #[test] @@ -354,7 +357,10 @@ fn file_persister_hostile_bincode_length_claims_yield_empty_snapshot() { let mut string_len_claim = 1u64.to_le_bytes().to_vec(); string_len_claim.extend_from_slice(&u64::MAX.to_le_bytes()); - for (claim, bytes) in [("map count", count_claim), ("string length", string_len_claim)] { + for (claim, bytes) in [ + ("map count", count_claim), + ("string length", string_len_claim), + ] { std::fs::write(&path, bytes).expect("write hostile frame"); let p = FilePersister::bincode(&path); diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 2a010a2..9a11992 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -3,10 +3,10 @@ //! [`hydrate`], and the typed serializer/deserializer registries. use std::any::TypeId; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::future::Future; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use gpui::{App, Subscription}; @@ -18,8 +18,9 @@ use crate::core::{CachePolicy, QueryKey}; use super::QueryClient; /// Bumped when the serialized shape changes incompatibly; loaders reject -/// mismatches with [`PersistError::VersionMismatch`]. -pub const PERSIST_VERSION: u32 = 1; +/// mismatches with [`PersistError::VersionMismatch`]. Version 2 made +/// [`QueryKey::to_path`] injective by escaping segments. +pub const PERSIST_VERSION: u32 = 2; #[derive(Debug, Error)] pub enum PersistError { @@ -32,10 +33,7 @@ pub enum PersistError { #[error("persistence deserialize error: {0}")] Deserialize(String), #[error("persistence version mismatch: expected {expected}, found {found}")] - VersionMismatch { - expected: u32, - found: u32, - }, + VersionMismatch { expected: u32, found: u32 }, #[error("persistence bad path: {0}")] BadPath(String), /// A required resource could not be acquired (e.g. a file lock). @@ -247,19 +245,38 @@ impl QueryClient { max_age: Duration, cx: &App, ) -> PersistSnapshot { + let flushed = HashMap::new(); + let mut out = self.collect_persist_delta(filter, max_age, &flushed, cx); + let mut snapshot = PersistSnapshot::new(); + snapshot.entries = out + .fresh + .drain(..) + .map(|collected| (collected.path, collected.entry)) + .collect(); + snapshot + } + + /// Walks the query buckets once, serializing only entries whose data + /// epoch differs from `flushed` (their last-flushed value). Live entries + /// already flushed come back as reused paths; the caller merges fresh + /// and reused entries over its accumulated store. + pub(crate) fn collect_persist_delta( + &self, + filter: &PersistFilter, + max_age: Duration, + flushed: &HashMap<String, u64>, + cx: &App, + ) -> crate::client::bucket::shared::PersistCollectOut { let Some(ref registry) = self.serializers else { - return PersistSnapshot::new(); + return crate::client::bucket::shared::PersistCollectOut::default(); }; let now_ms = crate::client::time::current_time_ms(); let max_age_ms = max_age.as_millis() as u64; let collect = crate::client::bucket::shared::PersistCollect::new( - registry, - filter, - now_ms, - max_age_ms, + registry, filter, now_ms, max_age_ms, flushed, ); - let mut out: Vec<(QueryKey, PersistedEntry)> = Vec::new(); + let mut out = crate::client::bucket::shared::PersistCollectOut::default(); for bucket in self.buckets.values() { bucket.collect_persistable_into(cx, &collect, &mut out); } @@ -268,24 +285,23 @@ impl QueryClient { } if let Some(meta_map) = &self.persisted_meta { - for (key, entry) in &mut out { - if let Some(m) = meta_map.get(key) { - entry.meta = Some(m.clone()); + for collected in &mut out.fresh { + if let Some(m) = meta_map.get(&collected.key) { + collected.entry.meta = Some(m.clone()); } } } - - let mut snapshot = PersistSnapshot::new(); - snapshot.entries = out - .into_iter() - .map(|(key, entry)| (key.to_path(), entry)) - .collect(); - snapshot + out } /// Debounced [`Persister`] driver on the [`CacheMutation`](super::CacheMutation) /// dirty signal: collection happens at drain time, so a burst of bumps /// coalesces into one save of the latest state. + /// + /// Saves carry the full accumulated store, but only dirty entries (data + /// epoch changed since the last flush) are re-serialized; entries + /// removed from the cache are pruned. A flush with no dirty entries and + /// no prunes does not save at all. pub fn persist_with<P: Persister>( &self, persister: P, @@ -296,6 +312,10 @@ impl QueryClient { let debounce = opts.debounce; let bg = cx.background_executor().clone(); let armed = Arc::new(AtomicBool::new(false)); + let flush_state = Arc::new(Mutex::new(PersistFlushState { + flushed: HashMap::new(), + store: Arc::new(PersistSnapshot::new()), + })); let _ = cx.default_global::<super::CacheMutation>(); @@ -304,6 +324,7 @@ impl QueryClient { let armed = armed.clone(); let filter = opts.filter; let max_age = opts.max_age; + let flush_state = flush_state.clone(); cx.observe_global::<super::CacheMutation>(move |cx| { if armed.swap(true, Ordering::AcqRel) { return; @@ -311,6 +332,7 @@ impl QueryClient { let persister = persister.clone(); let filter = filter.clone(); let armed = armed.clone(); + let flush_state = flush_state.clone(); let bg = bg.clone(); cx.spawn(async move |cx| { if !debounce.is_zero() { @@ -318,14 +340,52 @@ impl QueryClient { } // Disarm before collecting: a bump landing now arms a fresh task. armed.store(false, Ordering::Release); - let Ok(snapshot) = cx.update_global::<QueryClient, _>(|client, cx| { - client.collect_persist_snapshot(&filter, max_age, cx) - }) else { + let delta = cx.update_global::<QueryClient, _>(|client, cx| { + let Ok(state) = flush_state.lock() else { + return None; + }; + Some(client.collect_persist_delta(&filter, max_age, &state.flushed, cx)) + }); + let Some(delta) = delta.ok().flatten() else { + return; + }; + let Ok(mut state) = flush_state.lock() else { return; }; + let live: HashSet<String> = delta + .fresh + .iter() + .map(|c| c.path.clone()) + .chain(delta.reused.iter().cloned()) + .collect(); + let prune = state.store.entries.keys().any(|path| !live.contains(path)); + if delta.fresh.is_empty() && !prune { + return; + } + // Clone-on-write only while a previous save is still in flight. + let fresh_epochs: Vec<(String, u64)> = delta + .fresh + .iter() + .map(|c| (c.path.clone(), c.epoch)) + .collect(); + { + let snapshot = Arc::make_mut(&mut state.store); + if prune { + snapshot.entries.retain(|path, _| live.contains(path)); + } + for collected in delta.fresh { + snapshot.entries.insert(collected.path, collected.entry); + } + } + if prune { + state.flushed.retain(|path, _| live.contains(path)); + } + state.flushed.extend(fresh_epochs); + let out = state.store.clone(); + drop(state); // Collect on the main thread (entity reads), save on background (IO). bg.spawn(async move { - if let Err(_err) = persister.save(&snapshot).await { + if let Err(_err) = persister.save(&out).await { #[cfg(debug_assertions)] eprintln!("persist_with: save failed: {_err}"); } @@ -342,6 +402,14 @@ impl QueryClient { } } +/// Per-driver flush state: the data epoch each path was last flushed at and +/// the full store as of the last save. Main-thread only; the mutex guards +/// the handoff of an in-flight save's snapshot. +struct PersistFlushState { + flushed: HashMap<String, u64>, + store: Arc<PersistSnapshot>, +} + /// Persists nothing; loads an empty snapshot. pub struct NoopPersister; @@ -358,13 +426,11 @@ impl Persister for NoopPersister { /// Load a snapshot and re-prime the live cache with it: the value-carrying /// counterpart to the metadata-only /// [`QueryClient::hydrate`](super::QueryClient::hydrate). Stored `to_path()` -/// keys are split back on `"::"` so `Exact`/`Prefix` filters match live -/// multi-segment keys; the split is lossy (a segment containing `"::"` -/// hydrates as multiple segments, and escaping it needs a `PERSIST_VERSION` -/// bump). Returns the post-filter snapshot so callers can inspect entries or -/// prime types with no registered deserializer. The persister's output is -/// trusted beyond the version check: one reading untrusted storage must -/// validate payloads itself. +/// keys are decoded with `QueryKey::from_path` so `Exact`/`Prefix` filters +/// match live multi-segment keys. Returns the post-filter snapshot so +/// callers can inspect entries or prime types with no registered +/// deserializer. The persister's output is trusted beyond the version +/// check: one reading untrusted storage must validate payloads itself. pub async fn hydrate<P: Persister>( client: &mut QueryClient, persister: &P, @@ -390,7 +456,7 @@ pub async fn hydrate<P: Persister>( let steps: Vec<HydrateStep> = deserializers.iter().cloned().collect(); for (key_path, entry) in &snapshot.entries { - let key = QueryKey::new(key_path.split("::")); + let key = QueryKey::from_path(key_path); if !filter.matches(&key) { continue; } diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 0e333dd..557a1d8 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -59,21 +59,62 @@ impl QueryKey { } } - /// Joins with `"::"` so segments containing forward slashes stay unambiguous. + /// Joins with `"::"`, escaping `\` and `:` inside segments so distinct + /// keys never map to one path; `from_path` inverts it. pub fn to_path(&self) -> String { const SEP: &str = "::"; + let escapes: usize = self + .0 + .iter() + .map(|s| s.chars().filter(|c| matches!(c, '\\' | ':')).count()) + .sum(); let len = self.0.iter().map(|s| s.len()).sum::<usize>() + + escapes + SEP.len() * self.0.len().saturating_sub(1); let mut path = String::with_capacity(len); for (i, segment) in self.0.iter().enumerate() { if i > 0 { path.push_str(SEP); } - path.push_str(segment); + for ch in segment.chars() { + if matches!(ch, '\\' | ':') { + path.push('\\'); + } + path.push(ch); + } } path } + /// Inverse of [`to_path`](Self::to_path); any input yields at least one + /// segment and never panics. + pub(crate) fn from_path(path: &str) -> Self { + let mut segments: Vec<String> = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + let mut chars = path.chars(); + while let Some(ch) = chars.next() { + if escaped { + current.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == ':' { + segments.push(std::mem::take(&mut current)); + match chars.next() { + Some(':') => {} + Some('\\') => escaped = true, + Some(other) => current.push(other), + None => {} + } + } else { + current.push(ch); + } + } + segments.push(current); + Self::new(segments) + } + /// An empty `prefix` matches every valid key; an empty `self` never matches. pub fn starts_with(&self, prefix: &QueryKey) -> bool { if prefix.0.is_empty() { @@ -264,4 +305,44 @@ mod tests { let key = QueryKey::from(["", ""]); assert_eq!(key.to_path(), "::"); } + + #[test] + fn to_path_escapes_colon_and_backslash_in_segments() { + let key = QueryKey::from(["a\\b", "c:d"]); + assert_eq!(key.to_path(), "a\\\\b::c\\:d"); + } + + #[test] + fn to_path_collision_pair_maps_to_distinct_paths() { + let first = QueryKey::from(["a::", ""]); + let second = QueryKey::from(["a", "::"]); + assert_ne!(first.to_path(), second.to_path()); + assert_eq!(first.to_path(), "a\\:\\:::"); + assert_eq!(second.to_path(), "a::\\:\\:"); + } + + #[test] + fn from_path_inverts_to_path_for_escaped_segments() { + for parts in [ + vec!["a::", ""], + vec!["a", "::"], + vec!["a\\b", "c:d"], + vec!["::"], + vec![""], + vec!["\\"], + ] { + let key = QueryKey::from(parts); + assert_eq!(QueryKey::from_path(&key.to_path()), key); + } + } + + #[test] + fn from_path_hostile_input_yields_at_least_one_segment() { + for path in ["", "::", "\\", "a\\", "a:b", "a:::b", "a:::", ":\\:"] { + assert!( + !QueryKey::from_path(path).parts().is_empty(), + "from_path({path:?}) must yield at least one segment" + ); + } + } } diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index 11e7b83..6bc7d14 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -1,6 +1,6 @@ use gpui::{AppContext as _, BorrowAppContext as _, TestAppContext}; -use crate::client::QueryClient; +use crate::client::{QueryBucket, QueryClient}; use crate::core::*; use crate::tests::test_support::*; @@ -99,7 +99,8 @@ fn test_gc_evicts_idle_infinite_query_with_realistic_timing(cx: &mut TestAppCont let key = QueryKey::from("inf_gc_idle"); let _entity = client.infinite_resource::<String, QueryError>(key.clone(), cx); - client.gc_with_time(100_000, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 100_000, cx); assert!( client.infinite_query::<String, QueryError>(&key).is_none(), @@ -293,3 +294,147 @@ fn test_mutation_bucket_evict_oldest_keeps_count_bounded(cx: &mut TestAppContext }); }); } + +fn create_evict_entry( + bucket: &mut QueryBucket<String, QueryError>, + key: &str, + cx: &mut gpui::App, +) -> gpui::Entity<QueryResource<String, QueryError>> { + bucket.get_or_create( + QueryKey::from(key), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::LatestWins, + cx, + ) +} + +fn stamp_and_refresh( + bucket: &mut QueryBucket<String, QueryError>, + entity: &gpui::Entity<QueryResource<String, QueryError>>, + key: &str, + updated_at_ms: u64, + cx: &mut gpui::App, +) { + entity.update(cx, |r, _| r.apply_success(key.to_string(), updated_at_ms)); + bucket.get_or_create( + QueryKey::from(key), + CachePolicy::Ttl { ttl_ms: 60_000 }, + RequestPolicy::LatestWins, + cx, + ); +} + +#[gpui::test] +fn test_evict_oldest_removes_oldest_live_entry_at_capacity(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut bucket = QueryBucket::<String, QueryError>::new(); + bucket.inner.max_entries = 3; + + let a = create_evict_entry(&mut bucket, "evict_a", cx); + stamp_and_refresh(&mut bucket, &a, "evict_a", 1_000, cx); + let b = create_evict_entry(&mut bucket, "evict_b", cx); + stamp_and_refresh(&mut bucket, &b, "evict_b", 2_000, cx); + let c = create_evict_entry(&mut bucket, "evict_c", cx); + stamp_and_refresh(&mut bucket, &c, "evict_c", 3_000, cx); + + let _d = create_evict_entry(&mut bucket, "evict_d", cx); + + assert!( + !bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_a")), + "oldest live entry should be evicted at capacity" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_b")) + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_c")) + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_d")) + ); + }); +} + +#[gpui::test] +fn test_evict_oldest_prefers_dead_entry_over_live_at_capacity(cx: &mut TestAppContext) { + let mut bucket = QueryBucket::<String, QueryError>::new(); + bucket.inner.max_entries = 2; + + let live = cx.update(|cx| { + let dead = create_evict_entry(&mut bucket, "evict_dead", cx); + stamp_and_refresh(&mut bucket, &dead, "evict_dead", 1_000, cx); + drop(dead); + + let live = create_evict_entry(&mut bucket, "evict_live", cx); + stamp_and_refresh(&mut bucket, &live, "evict_live", 2_000, cx); + live + }); + + cx.update(|cx| { + create_evict_entry(&mut bucket, "evict_new", cx); + + assert!( + !bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_dead")), + "collected entry should be evicted before any live entry" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_live")), + "older live entry should be kept while a collected entry can go" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_new")) + ); + }); + drop(live); +} + +#[gpui::test] +fn test_evict_oldest_with_only_dead_entries_keeps_bucket_bounded(cx: &mut TestAppContext) { + let mut bucket = QueryBucket::<String, QueryError>::new(); + bucket.inner.max_entries = 3; + + cx.update(|cx| { + for i in 0..3 { + create_evict_entry(&mut bucket, &format!("evict_dead_{i}"), cx); + } + assert_eq!(bucket.inner.entries.len(), 3); + }); + + cx.update(|cx| { + create_evict_entry(&mut bucket, "evict_live_key", cx); + + assert_eq!( + bucket.inner.entries.len(), + 3, + "a bucket of collected entries must still evict on insert instead of \ + growing past max_entries" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_live_key")) + ); + }); +} diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs index 7da2c45..bad9747 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/hook_coverage.rs @@ -5,8 +5,7 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use crate::core::*; use crate::hook::{ InfiniteQueryOptions, MutationOptions, QueryOptions, fetch_next_page_infinite, fetch_query, - fetch_query_with_signal, use_infinite_query, use_mutation, use_query_manual, - use_query_select, + fetch_query_with_signal, use_infinite_query, use_mutation, use_query_manual, use_query_select, }; use crate::tests::test_support::*; diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs index 0e90962..411d122 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs @@ -12,12 +12,13 @@ fn test_gc_with_zero_time_clamped_evicts_idle(cx: &mut TestAppContext) { let _entity = client.resource::<String, QueryError>("gc_zero", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); - client.gc_with_time(0, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 2_000, cx); assert_eq!( client.all_queries::<String, QueryError>().len(), 0, - "Idle resource with no snapshot timestamp should be evicted \ - even at gc_with_time(0) because its age defaults to the clamped gc_threshold" + "Idle resource with no snapshot timestamp should be evicted once \ + its age (2000ms) exceeds the clamped gc_threshold (1000ms)" ); }); }); @@ -32,22 +33,30 @@ fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { let _e2 = client.resource::<String, QueryError>("gc_evict2", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 2); - client.gc_with_time(500, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 500, cx); + assert_eq!( + client.all_queries::<String, QueryError>().len(), + 2, + "never-fetched resources should survive gc_with_time(now+500): \ + their age (500ms) is below the clamped gc_threshold (1000ms)" + ); + + client.gc_with_time(now + 100_000, cx); assert_eq!( client.all_queries::<String, QueryError>().len(), 0, - "Idle resources with no snapshot timestamp should be evicted \ - at gc_with_time(500) — their age defaults to the clamped gc_threshold" + "Idle resources should be evicted at gc_with_time(now+100_000)" ); let _e3 = client.resource::<String, QueryError>("gc_big_time", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); - client.gc_with_time(100_000, cx); + client.gc_with_time(now + 100_000, cx); assert_eq!( client.all_queries::<String, QueryError>().len(), 0, - "Idle resource should also be evicted at gc_with_time(100_000)" + "Idle resource should also be evicted at gc_with_time(now+100_000)" ); let diag = client.diagnostics(cx); @@ -83,22 +92,25 @@ fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { assert_eq!(client.all_infinite_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_mutations::<String, User, QueryError>().len(), 2); - client.gc_with_time(100_000, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 100_000, cx); assert!( client.all_queries::<String, QueryError>().is_empty(), "idle query with no snapshot should be evicted by GC" ); assert!( - client.all_infinite_queries::<String, QueryError>().is_empty(), + client + .all_infinite_queries::<String, QueryError>() + .is_empty(), "idle infinite query with no snapshot should be evicted by GC" ); let mutations = client.all_mutations::<String, User, QueryError>(); assert_eq!( mutations.len(), - 2, - "both mutations should survive GC (one loading, one idle but retained by strong Entity ref)" + 1, + "only the loading mutation should survive GC; the idle one aged past gc_threshold" ); }); }); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index 45bcfc2..1d575c5 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -1,5 +1,6 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; @@ -9,7 +10,8 @@ use crate::client::{ PersistSnapshot, PersistedEntry, Persister, QueryClient, hydrate, }; use crate::core::{ - InfiniteQueryResource, MutationResource, QueryError, QueryKey, QueryResource, QueryStatus, + InfiniteQueryResource, MutationResource, QueryError, QueryKey, QueryKeyFilter, QueryResource, + QueryStatus, }; use crate::hook::{ InfiniteQueryOptions, fetch_query, mutate, use_infinite_query, use_mutation, use_query_manual, @@ -100,7 +102,8 @@ fn test_collect_persist_snapshot_skips_unregistered_types(cx: &mut TestAppContex r.apply_success("data".to_string(), crate::client::current_time_ms()) }); - let snap = client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(3600), cx); + let snap = + client.collect_persist_snapshot(&PersistFilter::All, Duration::from_secs(3600), cx); assert!( snap.entries.is_empty(), "unregistered type -> no value-carrying entry" @@ -289,7 +292,11 @@ fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { block_on_ready(hydrate(client, &persister, &prefix, DAY, cx)) }) }); - assert!(outcome.is_ok(), "hydrate should succeed: {:?}", outcome.err()); + assert!( + outcome.is_ok(), + "hydrate should succeed: {:?}", + outcome.err() + ); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -313,7 +320,11 @@ fn test_hydrate_rebuilds_multi_segment_keys(cx: &mut TestAppContext) { block_on_ready(hydrate(client, &persister, &exact, DAY, cx)) }) }); - assert!(outcome.is_ok(), "hydrate should succeed: {:?}", outcome.err()); + assert!( + outcome.is_ok(), + "hydrate should succeed: {:?}", + outcome.err() + ); cx.update(|cx| { cx.update_global::<QueryClient, _>(|client, cx| { @@ -780,3 +791,452 @@ fn block_on_ready<R>(fut: impl std::future::Future<Output = R>) -> R { } } } + +#[gpui::test] +fn hydrate_primed_value_reaches_mounted_use_query_observer(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + + let mut snap = PersistSnapshot { + entries: Default::default(), + version: crate::client::PERSIST_VERSION, + }; + snap.entries.insert( + "hydrate-notify".to_string(), + PersistedEntry { + value: serde_json::json!("hydrated"), + cached_at: crate::client::current_time_ms(), + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + *persister.load_value.lock().unwrap() = Some(snap); + + struct H { + _entity: Entity<QueryResource<String, QueryError>>, + _sub: gpui::Subscription, + } + let harness = cx.new(|cx| { + cx.update_global::<QueryClient, _>(|client, _cx| { + client + .register_deserializer::<String, QueryError>(|v| v.as_str().map(|s| s.to_string())); + }); + let (entity, sub) = use_query_manual::<String, QueryError, _>( + QueryKey::from("hydrate-notify"), + crate::core::CachePolicy::NoCache, + crate::core::RequestPolicy::LatestWins, + cx, + ); + H { + _entity: entity, + _sub: sub, + } + }); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + let outcome = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + block_on_ready(hydrate(client, &persister, &PersistFilter::All, DAY, cx)) + }) + }); + assert!( + outcome.is_ok(), + "hydrate should succeed: {:?}", + outcome.err() + ); + cx.run_until_parked(); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let data = + client.get_query_data::<String, QueryError>(&QueryKey::from("hydrate-notify"), cx); + assert_eq!( + data, + Some("hydrated".to_string()), + "hydrate should have primed the value" + ); + }); + }); + assert!( + hits.load(Ordering::SeqCst) >= 1, + "hydrate priming via set_query_data must re-render a mounted use_query consumer" + ); +} + +#[gpui::test] +fn flush_serializes_only_dirty_entries(cx: &mut TestAppContext) { + static SERIALIZATIONS: AtomicUsize = AtomicUsize::new(0); + + fn counting_serialize(value: &String) -> serde_json::Value { + SERIALIZATIONS.fetch_add(1, Ordering::SeqCst); + serde_json::to_value(value).expect("serialize") + } + + setup_query_client(cx); + let persister = MemPersister::default(); + let captured = persister.last_saved.clone(); + + struct H { + dirty_a: Entity<QueryResource<String, QueryError>>, + dirty_b: Entity<QueryResource<String, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, dirty_a, dirty_b) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(counting_serialize); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + let dirty_a = client.resource::<String, QueryError>(QueryKey::from("dirty_a"), cx); + let dirty_b = client.resource::<String, QueryError>(QueryKey::from("dirty_b"), cx); + (handle, dirty_a, dirty_b) + }); + H { + dirty_a, + dirty_b, + _handle: handle, + } + }); + cx.update(|cx| { + let (dirty_a, dirty_b) = + harness.read_with(cx, |h, _| (h.dirty_a.clone(), h.dirty_b.clone())); + cx.update_global::<QueryClient, _>(|_client, cx| { + for entity in [dirty_a, dirty_b] { + entity.update(cx, |r, _| { + r.apply_success("v1".to_string(), crate::client::current_time_ms()) + }); + } + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + SERIALIZATIONS.load(Ordering::SeqCst), + 2, + "the first flush serializes both live entries" + ); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>("dirty_b", "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + assert_eq!( + SERIALIZATIONS.load(Ordering::SeqCst), + 3, + "unchanged dirty_a must not be re-serialized" + ); + let saved = captured.lock().unwrap().clone().expect("flush 2 saved"); + assert_eq!( + saved.entries.get("dirty_b").map(|e| &e.value), + Some(&serde_json::json!("v2")) + ); + assert_eq!( + saved.entries.get("dirty_a").map(|e| &e.value), + Some(&serde_json::json!("v1")), + "the unchanged entry stays in the saved store" + ); + let _ = harness; +} + +#[gpui::test] +fn unchanged_cache_flushes_nothing(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let save_count = persister.save_count.clone(); + + struct H { + steady: Entity<QueryResource<String, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, steady) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + let steady = client.resource::<String, QueryError>(QueryKey::from("steady"), cx); + (handle, steady) + }); + H { + steady, + _handle: handle, + } + }); + cx.update(|cx| { + let entity = harness.read(cx).steady.clone(); + cx.update_global::<QueryClient, _>(|_client, cx| { + entity.update(cx, |r, _| { + r.apply_success("v".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + *save_count.lock().unwrap(), + 1, + "the data write flushed exactly once" + ); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|_client, cx| { + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + *save_count.lock().unwrap(), + 1, + "a bump without a data write must not save" + ); + let _ = harness; +} + +#[gpui::test] +fn set_query_data_write_flushes_without_touching_timestamp(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let captured = persister.last_saved.clone(); + let save_count = persister.save_count.clone(); + + struct H { + epoch_key: Entity<QueryResource<String, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, epoch_key) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + let epoch_key = client.resource::<String, QueryError>(QueryKey::from("epoch_key"), cx); + (handle, epoch_key) + }); + H { + epoch_key, + _handle: handle, + } + }); + cx.update(|cx| { + let entity = harness.read(cx).epoch_key.clone(); + cx.update_global::<QueryClient, _>(|_client, cx| { + entity.update(cx, |r, _| { + r.apply_success("v1".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + let first_cached_at = captured + .lock() + .unwrap() + .as_ref() + .and_then(|s| s.entries.get("epoch_key")) + .map(|e| e.cached_at) + .expect("the first flush stored epoch_key"); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>("epoch_key", "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + assert_eq!(*save_count.lock().unwrap(), 2); + let saved = captured.lock().unwrap().clone().expect("flush 2 saved"); + let entry = saved.entries.get("epoch_key").expect("epoch_key persisted"); + assert_eq!(entry.value, serde_json::json!("v2")); + assert_eq!( + entry.cached_at, first_cached_at, + "set_data leaves last_updated_at untouched; the data epoch is the flush signal" + ); + let _ = harness; +} + +#[gpui::test] +fn removed_key_is_pruned_from_the_saved_store(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let captured = persister.last_saved.clone(); + + struct H { + pruned_out: Entity<QueryResource<String, QueryError>>, + pruned_stay: Entity<QueryResource<String, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, pruned_out, pruned_stay) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + let pruned_out = + client.resource::<String, QueryError>(QueryKey::from("pruned_out"), cx); + let pruned_stay = + client.resource::<String, QueryError>(QueryKey::from("pruned_stay"), cx); + (handle, pruned_out, pruned_stay) + }); + H { + pruned_out, + pruned_stay, + _handle: handle, + } + }); + cx.update(|cx| { + let (out, stay) = + harness.read_with(cx, |h, _| (h.pruned_out.clone(), h.pruned_stay.clone())); + cx.update_global::<QueryClient, _>(|_client, cx| { + for entity in [out, stay] { + entity.update(cx, |r, _| { + r.apply_success("v1".to_string(), crate::client::current_time_ms()) + }); + } + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + let first = captured.lock().unwrap().clone().expect("flush 1 saved"); + assert!(first.entries.contains_key("pruned_out")); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.remove_queries(&QueryKeyFilter::Exact(&QueryKey::from("pruned_out"))); + client.set_query_data::<String, QueryError>("pruned_stay", "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + + let saved = captured.lock().unwrap().clone().expect("flush 2 saved"); + assert!( + !saved.entries.contains_key("pruned_out"), + "a removed key must not resurrect in the saved store: {:?}", + saved.entries.keys().collect::<Vec<_>>() + ); + assert_eq!( + saved.entries.get("pruned_stay").map(|e| &e.value), + Some(&serde_json::json!("v2")) + ); + let _ = harness; +} + +#[gpui::test] +fn colon_collision_keys_persist_distinct_and_hydrate_exact(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let key_a = QueryKey::from(["a::", ""]); + let key_b = QueryKey::from(["a", "::"]); + + let snapshot = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + for (key, value) in [(&key_a, "alpha"), (&key_b, "beta")] { + let e = client.resource::<String, QueryError>(key.clone(), cx); + e.update(cx, |r, _| { + r.apply_success(value.to_string(), crate::client::current_time_ms()) + }); + } + client.collect_persist_snapshot(&PersistFilter::All, DAY, cx) + }) + }); + assert_ne!(key_a.to_path(), key_b.to_path()); + assert_eq!( + snapshot.entries.len(), + 2, + "the collision pair must map to distinct paths: {:?}", + snapshot.entries.keys().collect::<Vec<_>>() + ); + assert_eq!( + snapshot.entries.get(&key_a.to_path()).map(|e| &e.value), + Some(&serde_json::json!("alpha")) + ); + assert_eq!( + snapshot.entries.get(&key_b.to_path()).map(|e| &e.value), + Some(&serde_json::json!("beta")) + ); + *persister.load_value.lock().unwrap() = Some(snapshot); + + for (filter_key, expected, other) in [ + (key_a.clone(), "alpha", &key_b), + (key_b.clone(), "beta", &key_a), + ] { + let mut fresh = QueryClient::new(); + fresh.register_deserializer::<String, QueryError>(|v| v.as_str().map(|s| s.to_string())); + let held = cx.update(|cx| { + vec![ + fresh.resource::<String, QueryError>(key_a.clone(), cx), + fresh.resource::<String, QueryError>(key_b.clone(), cx), + ] + }); + let outcome = cx.update(|cx| { + block_on_ready(hydrate( + &mut fresh, + &persister, + &PersistFilter::Exact(filter_key.clone()), + DAY, + cx, + )) + }); + assert!( + outcome.is_ok(), + "hydrate should succeed: {:?}", + outcome.err() + ); + cx.update(|cx| { + assert_eq!( + fresh.get_query_data::<String, QueryError>(&filter_key, cx), + Some(expected.to_string()), + "Exact must match the reconstructed segments" + ); + assert_eq!( + fresh.get_query_data::<String, QueryError>(other, cx), + None, + "the other collision key must not be primed by this Exact filter" + ); + }); + drop(held); + } +} + +#[gpui::test] +fn hydrate_discards_previous_format_version(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let mut stale = PersistSnapshot { + entries: Default::default(), + version: crate::client::PERSIST_VERSION - 1, + }; + stale.entries.insert( + "old_format_key".to_string(), + PersistedEntry { + value: serde_json::json!("stale"), + cached_at: crate::client::current_time_ms(), + cache_policy: crate::core::CachePolicy::default(), + meta: None, + }, + ); + *persister.load_value.lock().unwrap() = Some(stale); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, _cx| { + client + .register_deserializer::<String, QueryError>(|v| v.as_str().map(|s| s.to_string())); + }); + let outcome = cx.update_global::<QueryClient, _>(|client, cx| { + block_on_ready(hydrate(client, &persister, &PersistFilter::All, DAY, cx)) + }); + match outcome { + Err(PersistError::VersionMismatch { expected, found }) => { + assert_eq!(expected, crate::client::PERSIST_VERSION); + assert_eq!(found, crate::client::PERSIST_VERSION - 1); + } + other => panic!("expected VersionMismatch, got {other:?}"), + } + cx.update_global::<QueryClient, _>(|client, cx| { + assert_eq!( + client.get_query_data::<String, QueryError>(&QueryKey::from("old_format_key"), cx), + None, + "a previous-format snapshot must not prime any value" + ); + }); + }); +} From eba2f7f81ba7686c32bc449a512eddefeab4db1b Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 078/111] fix: keep gc and eviction from dropping live bucket entries --- .../src/client/bucket/erased_ops.rs | 8 +- crates/gpui-query/src/client/bucket/ops.rs | 7 +- crates/gpui-query/src/client/bucket/shared.rs | 121 +++++++++++++----- crates/gpui-query/src/client/bucket/types.rs | 9 +- .../gpui-query/src/client/mutation_bucket.rs | 4 +- .../tests/integration_client/data_access.rs | 52 ++++++++ .../invalidation_reset_gc.rs | 73 ++++++++++- 7 files changed, 226 insertions(+), 48 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/erased_ops.rs b/crates/gpui-query/src/client/bucket/erased_ops.rs index bcdf566..2291c2e 100644 --- a/crates/gpui-query/src/client/bucket/erased_ops.rs +++ b/crates/gpui-query/src/client/bucket/erased_ops.rs @@ -60,11 +60,9 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB &self, cx: &App, collect: &crate::client::bucket::shared::PersistCollect<'_>, - out: &mut Vec<( - crate::core::QueryKey, - crate::client::persist::PersistedEntry, - )>, + out: &mut crate::client::bucket::shared::PersistCollectOut, ) { - self.inner.collect_persistable_into(cx, collect, out, |r| r.data()); + self.inner + .collect_persistable_into(cx, collect, out, |r| r.data()); } } diff --git a/crates/gpui-query/src/client/bucket/ops.rs b/crates/gpui-query/src/client/bucket/ops.rs index 73e6741..dd20f17 100644 --- a/crates/gpui-query/src/client/bucket/ops.rs +++ b/crates/gpui-query/src/client/bucket/ops.rs @@ -2,9 +2,7 @@ use gpui::{App, Entity}; -use crate::core::{ - CachePolicy, QueryKey, QueryResource, RequestPolicy, RequestSequencer, -}; +use crate::core::{CachePolicy, QueryKey, QueryResource, RequestPolicy, RequestSequencer}; use super::shared::ResourceBucket; @@ -26,7 +24,8 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> QueryBu request_policy: RequestPolicy, cx: &mut App, ) -> Entity<QueryResource<T, E>> { - self.inner.get_or_create(key, cache_policy, request_policy, cx) + self.inner + .get_or_create(key, cache_policy, request_policy, cx) } pub(crate) fn get_or_create_with_request_id( diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index c343cdb..34c27cb 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -14,6 +14,7 @@ use crate::core::{ }; use super::types::{BucketEntry, DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; +use crate::client::time::current_time_ms; /// Runs GC every this many resource operations, so it fires in production /// without anyone calling `gc()` by hand. @@ -22,8 +23,11 @@ pub(crate) const GC_INTERVAL: usize = 64; /// The resource surface `ResourceBucket` needs for both query kinds; /// prefixed names keep the delegating impls unambiguous. pub(crate) trait BucketResource { - fn new_resource(key: QueryKey, cache_policy: CachePolicy, request_policy: RequestPolicy) - -> Self; + fn new_resource( + key: QueryKey, + cache_policy: CachePolicy, + request_policy: RequestPolicy, + ) -> Self; fn resource_status(&self) -> QueryStatus; fn resource_is_loading(&self) -> bool; fn resource_last_updated(&self) -> Option<u64>; @@ -34,6 +38,9 @@ pub(crate) trait BucketResource { fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64>; fn resource_cache_hits(&self) -> u64; fn resource_retry_count(&self) -> u32; + /// `None` means the resource has no data-write epoch, so persistence + /// collection always treats it as dirty. + fn resource_data_epoch(&self) -> Option<u64>; fn resource_invalidate(&mut self); fn resource_reset(&mut self); fn resource_cancel_inflight(&mut self); @@ -77,6 +84,9 @@ impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + fn resource_data_epoch(&self) -> Option<u64> { + Some(self.data_epoch()) + } fn resource_invalidate(&mut self) { self.invalidate(); } @@ -129,6 +139,9 @@ impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + fn resource_data_epoch(&self) -> Option<u64> { + None + } fn resource_invalidate(&mut self) { self.invalidate(); } @@ -178,7 +191,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { ) -> (Entity<R>, RequestId) { let (entity, request_id) = self.get_or_create_impl(key, cache_policy, request_policy, cx, true); - (entity, request_id.expect("impl inserts the entry before returning")) + ( + entity, + request_id.expect("impl inserts the entry before returning"), + ) } /// Live entries refresh differing policies in place; a dead weak @@ -194,18 +210,18 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { ) -> (Entity<R>, Option<RequestId>) { if let Some(entry) = self.entries.get_mut(&key) { if let Some(entity) = entry.entity.upgrade() { - let (needs_update, last_updated, loading) = - entity.read_with(cx, |resource, _| { - let needs_update = resource.resource_cache_policy() != cache_policy - || resource.resource_request_policy() != request_policy; - ( - needs_update, - resource.resource_last_updated(), - resource.resource_is_loading(), - ) - }); + let (needs_update, last_updated, loading) = entity.read_with(cx, |resource, _| { + let needs_update = resource.resource_cache_policy() != cache_policy + || resource.resource_request_policy() != request_policy; + ( + needs_update, + resource.resource_last_updated(), + resource.resource_is_loading(), + ) + }); entry.last_updated_ms = last_updated; entry.loading = loading; + entry.updated_at = current_time_ms(); if needs_update { entity.update(cx, |resource, _| { resource.set_resource_cache_policy(cache_policy); @@ -227,6 +243,7 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { BucketEntry { entity: entity.downgrade(), sequencer, + updated_at: current_time_ms(), last_updated_ms: None, loading: false, }, @@ -234,10 +251,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { (entity, request_id) } - /// Scans only the mirrors plus weak-ref liveness, then confirms - /// `!is_loading()` on the winner with one entity read (the mirror can be - /// stale if a fetch began after the last refresh). Each retry marks the - /// stale mirror and re-picks, so the candidate set strictly shrinks. + /// Scans only the stored mirrors, then confirms the winner with a single + /// entity read (the mirror can be stale if a fetch began after the last + /// refresh). Each retry marks the stale mirror and re-picks; a collected + /// weak ref fails the confirm and is removed in place. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self @@ -247,13 +264,12 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { if entry.loading { return None; } - entry.entity.upgrade()?; - Some((key, entry.last_updated_ms.unwrap_or(0))) + Some((key, entry.last_updated_ms.unwrap_or(entry.updated_at))) }) .min_by_key(|&(_, age)| age); let Some((key, _)) = target else { - return; // every live entry is loading: nothing safe to evict + return; // only reachable when every entry is loading }; let key = key.clone(); @@ -281,7 +297,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { self.entries.get(key).and_then(|e| e.entity.upgrade()) } - pub(crate) fn sequencer_mut(&mut self, key: &QueryKey) -> Option<&mut crate::core::RequestSequencer> { + pub(crate) fn sequencer_mut( + &mut self, + key: &QueryKey, + ) -> Option<&mut crate::core::RequestSequencer> { self.entries.get_mut(key).map(|e| &mut e.sequencer) } @@ -348,7 +367,7 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { /// Loading always survives; `Success` survives while its cache policy /// can still serve it and until `SUCCESS_GC_MULTIPLIER * gc_time_ms`; /// `Idle`/`Failure`/`Cancelled` survive `gc_time_ms`. Entries without a - /// completion timestamp count as fully aged. + /// completion timestamp age from the entry's `updated_at` baseline. pub(crate) fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); @@ -370,7 +389,7 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { let status = resource.resource_status(); let age_ms = last_updated .map(|updated| now_ms.saturating_sub(updated)) - .unwrap_or(gc_threshold); + .unwrap_or_else(|| now_ms.saturating_sub(entry.updated_at)); if status == QueryStatus::Success { let cache_policy = resource.resource_cache_policy(); @@ -426,14 +445,16 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } - /// Filter and max-age run before the serializer so skipped entries cost - /// nothing. Only `Success` entries are pushed. + /// Filter, max-age, and the caller's last-flushed data epochs all run + /// before the serializer so skipped entries cost nothing. Only `Success` + /// entries are collected; entries whose epoch matches `flushed` are + /// reported as reused paths instead of re-serialized. #[cfg(feature = "persist")] pub(crate) fn collect_persistable_into<S>( &self, cx: &App, collect: &PersistCollect<'_>, - out: &mut Vec<(QueryKey, PersistedEntry)>, + out: &mut PersistCollectOut, value_of: impl Fn(&R) -> Option<&S>, ) where S: 'static, @@ -464,30 +485,66 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { let Some(value_ref) = value_of(resource) else { continue; }; + let path = key.to_path(); + let epoch = resource.resource_data_epoch(); + if let Some(epoch) = epoch + && collect.flushed.get(&path) == Some(&epoch) + { + out.reused.push(path); + continue; + } // Downcast failure is unreachable by construction; skip rather than persist junk. let Some(value) = serialize_fn(value_ref as &dyn std::any::Any) else { continue; }; - out.push(( - key.clone(), - PersistedEntry { + out.fresh.push(PersistCollected { + key: key.clone(), + path, + epoch: epoch.unwrap_or(PERSIST_NO_DATA_EPOCH), + entry: PersistedEntry { value, cached_at, cache_policy: resource.resource_cache_policy(), meta: None, }, - )); + }); } } } -/// Per-sweep inputs for [`ResourceBucket::collect_persistable_into`]. +/// Epoch sentinel for resources that cannot report a data epoch; no real +/// resource reaches it, so such entries are always treated as dirty. +#[cfg(feature = "persist")] +pub(crate) const PERSIST_NO_DATA_EPOCH: u64 = u64::MAX; + +/// One live persistable entry produced by +/// [`ResourceBucket::collect_persistable_into`]. +#[cfg(feature = "persist")] +pub(crate) struct PersistCollected { + pub(crate) key: QueryKey, + pub(crate) path: String, + pub(crate) epoch: u64, + pub(crate) entry: PersistedEntry, +} + +/// Per-sweep output: freshly serialized entries plus the paths of live +/// entries reused from the persist driver's store. +#[cfg(feature = "persist")] +#[derive(Default)] +pub(crate) struct PersistCollectOut { + pub(crate) fresh: Vec<PersistCollected>, + pub(crate) reused: Vec<String>, +} + +/// Per-sweep inputs for [`ResourceBucket::collect_persistable_into`]; +/// `flushed` maps paths to the data epoch at their last flush. #[cfg(feature = "persist")] pub(crate) struct PersistCollect<'a> { serializers: &'a SerializerRegistry, filter: &'a PersistFilter, now_ms: u64, max_age_ms: u64, + flushed: &'a std::collections::HashMap<String, u64>, } #[cfg(feature = "persist")] @@ -497,12 +554,14 @@ impl<'a> PersistCollect<'a> { filter: &'a PersistFilter, now_ms: u64, max_age_ms: u64, + flushed: &'a std::collections::HashMap<String, u64>, ) -> Self { Self { serializers, filter, now_ms, max_age_ms, + flushed, } } } diff --git a/crates/gpui-query/src/client/bucket/types.rs b/crates/gpui-query/src/client/bucket/types.rs index 3418b67..7766f14 100644 --- a/crates/gpui-query/src/client/bucket/types.rs +++ b/crates/gpui-query/src/client/bucket/types.rs @@ -14,12 +14,15 @@ pub(crate) const DEFAULT_MAX_ENTRIES: usize = 10_000; /// `gc_time_ms`. pub(crate) const SUCCESS_GC_MULTIPLIER: u32 = 2; -/// `last_updated_ms` / `loading` mirror the entity, refreshed wherever the -/// bucket already reads it, so `evict_oldest` scans cheap fields and -/// confirms its winner with a single entity read. +/// `updated_at` is the bucket's recency baseline for entities that have no +/// completion timestamp of their own; `last_updated_ms` / `loading` mirror +/// the entity, refreshed wherever the bucket already reads it, so +/// `evict_oldest` scans cheap fields and confirms its winner with a single +/// entity read. pub(crate) struct BucketEntry<R> { pub entity: WeakEntity<R>, pub sequencer: RequestSequencer, + pub(crate) updated_at: u64, pub(crate) last_updated_ms: Option<u64>, pub(crate) loading: bool, } diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 999caff..6b6ad44 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -44,7 +44,8 @@ impl< /// Skips loading entries; the winner gets one confirming entity read /// (the mirror can be stale if a fetch began after the last refresh), - /// and each stale re-check marks the mirror and re-picks. + /// and each stale re-check marks the mirror and re-picks. A collected + /// weak ref fails the confirm and is removed in place. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self @@ -54,7 +55,6 @@ impl< if entry.loading { return None; } - entry.entity.upgrade()?; Some((*id, entry.last_updated_ms.unwrap_or(entry.updated_at))) }) .min_by_key(|&(_, age)| age); diff --git a/crates/gpui-query/src/tests/integration_client/data_access.rs b/crates/gpui-query/src/tests/integration_client/data_access.rs index 8aa074b..2bd11fa 100644 --- a/crates/gpui-query/src/tests/integration_client/data_access.rs +++ b/crates/gpui-query/src/tests/integration_client/data_access.rs @@ -343,6 +343,58 @@ fn test_prepare_prefetch_query_starts_for_stale_resource(cx: &mut TestAppContext }); } +#[gpui::test] +fn remove_queries_with_fetch_in_flight_keeps_bucket_and_fallback_ids_disjoint( + cx: &mut TestAppContext, +) { + setup_query_client(cx); + let (in_flight_id, entity) = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("request_id_alias"); + let prepared = client + .prepare_fetch_query::<String, QueryError>(key, cx) + .expect("prepare_fetch_query should start"); + let in_flight_id = prepared.request_id; + let entity = prepared.entity.clone(); + client.remove_queries(&QueryKeyFilter::All); + (in_flight_id, entity) + }) + }); + + let (fallback_id, stale_rejected, fallback_accepted, ignored) = cx.update(|cx| { + entity.update(cx, |resource, _| { + let fallback_id = + match resource.begin_request_with_id(None, 1_000, QueryFetchMode::Force) { + QueryBeginResult::Started { request_id, .. } => request_id, + other => panic!("expected Started after remove_queries, got {other:?}"), + }; + let stale_rejected = resource.accept_current_request(in_flight_id).is_none(); + let fallback_accepted = resource.accept_current_request(fallback_id).is_some(); + ( + fallback_id, + stale_rejected, + fallback_accepted, + resource.ignored_results(), + ) + }) + }); + + assert_ne!( + in_flight_id, fallback_id, + "bucket-sequencer id and fallback id aliased: the stale in-flight \ + result would be accepted as fresh" + ); + assert!( + stale_rejected, + "the stale in-flight completion must be discarded as ignored" + ); + assert_eq!(ignored, 1); + assert!( + fallback_accepted, + "the fallback request must still be accepted" + ); +} + #[cfg(feature = "persist")] #[gpui::test] fn test_persist_and_restore_cycle(cx: &mut TestAppContext) { diff --git a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs index d280a26..ffa66d2 100644 --- a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs +++ b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs @@ -164,12 +164,78 @@ fn test_gc_evicts_idle_resources_with_no_snapshot(cx: &mut TestAppContext) { let _entity = client.resource::<String, QueryError>("idle_key", cx); assert_eq!(client.all_queries::<String, QueryError>().len(), 1); - client.gc_with_time(1_500, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 1_500, cx); let queries = client.all_queries::<String, QueryError>(); assert!( queries.is_empty(), - "idle resource with no snapshot should be evicted" + "idle resource with no snapshot should be evicted once older than gc_time" + ); + }); + }); +} + +#[gpui::test] +fn test_gc_preserves_live_never_fetched_resource(cx: &mut TestAppContext) { + setup_query_client(cx); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc/live_never_fetched"); + let e1 = client.resource::<String, QueryError>(key.clone(), cx); + + client.gc(cx); + + assert!( + client.query::<String, QueryError>(&key).is_some(), + "GC must keep the bucket entry of a live never-fetched resource" + ); + let e2 = client.resource::<String, QueryError>(key, cx); + assert_eq!(e1, e2, "resource() after GC must return the same entity"); + }); + }); +} + +#[gpui::test] +fn test_gc_preserves_set_query_data_primed_entry(cx: &mut TestAppContext) { + setup_query_client(cx); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc/primed"); + client.set_query_data::<String, QueryError>(key.clone(), "v".to_string(), cx); + + client.gc(cx); + + assert_eq!( + client + .get_query_data::<String, QueryError>(&key, cx) + .as_deref(), + Some("v"), + "set_query_data-primed data must survive GC" + ); + }); + }); +} + +#[gpui::test] +fn test_gc_preserves_completed_entry(cx: &mut TestAppContext) { + setup_query_client(cx); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc/completed"); + let prepared = client + .prepare_fetch_query::<String, QueryError>(key.clone(), cx) + .expect("prepare_fetch_query should start"); + prepared.complete_success("v".to_string(), cx); + + client.gc(cx); + + assert_eq!( + client + .get_query_data::<String, QueryError>(&key, cx) + .as_deref(), + Some("v"), + "completed entry must survive GC" ); }); }); @@ -315,7 +381,8 @@ fn test_gc_across_multiple_type_buckets(cx: &mut TestAppContext) { assert_eq!(client.all_queries::<String, QueryError>().len(), 1); assert_eq!(client.all_queries::<u32, QueryError>().len(), 1); - client.gc_with_time(3_000, cx); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 3_000, cx); assert!( client.all_queries::<String, QueryError>().is_empty(), From 61ce548f7c08e2b4c97df2828a786b49437ebf15 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 079/111] fix: clear mutation data on cancel --- crates/gpui-query/src/core/mutation.rs | 5 +++-- .../src/tests/core_mutation/cancellation.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 5f8f98c..08df84a 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -241,14 +241,15 @@ impl<V, T, E> MutationResource<V, T, E> { self.retry_count = 0; } - /// No-op unless `Loading`; when effective, sets `Failure` and increments - /// `cancelled_count`. + /// No-op unless `Loading`; when effective, clears data, sets `Failure`, + /// and increments `cancelled_count`. pub fn cancel(&mut self, error: E) { if self.status != MutationStatus::Loading { return; } self.cancelled_count = self.cancelled_count.saturating_add(1); self.status = MutationStatus::Failure; + self.data = None; self.error = Some(error); if let Some(signal) = self.signal.as_ref() { signal.cancel(); diff --git a/crates/gpui-query/src/tests/core_mutation/cancellation.rs b/crates/gpui-query/src/tests/core_mutation/cancellation.rs index 6f27f2b..720e056 100644 --- a/crates/gpui-query/src/tests/core_mutation/cancellation.rs +++ b/crates/gpui-query/src/tests/core_mutation/cancellation.rs @@ -57,6 +57,21 @@ fn cancel_on_failure_is_noop() { assert_eq!(m.cancelled_count(), 0); } +#[test] +fn cancel_clears_data_from_previous_success() { + let mut m: MutationResource<&'static str, i32> = + MutationResource::new(RetryPolicy::no_retries()); + m.begin("vars"); + m.complete_success(42); + m.begin("vars2"); + m.cancel(QueryError::cancelled("x")); + assert!(m.is_failure()); + assert!( + m.data().is_none(), + "data from previous success must be cleared on cancel" + ); +} + #[test] fn cancelled_count_increments_across_mutations() { let mut m: MutationResource<&'static str, i32> = From a7dade1540f192ca8649d63d27c7ebc79c427c52 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Thu, 1 Oct 2026 22:42:01 +0200 Subject: [PATCH 080/111] fix: close redaction gaps and bound sanitize cost --- crates/gpui-query/src/core/error/sanitize.rs | 138 ++++++++++++++++-- crates/gpui-query/src/tests/core_error/mod.rs | 27 +++- 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 6025ef0..c4ba207 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -5,9 +5,29 @@ use std::borrow::Cow; pub const SANITIZE_MAX_LEN: usize = 512; -const SCHEME_NEEDLES: [&str; 4] = ["postgres://", "mysql://", "mongodb://", "redis://"]; - -const PATH_NEEDLES: [&str; 4] = ["/home/", "/users/", "/etc/", "/var/"]; +const SCHEME_NEEDLES: [&str; 10] = [ + "postgres://", + "postgresql://", + "mysql://", + "mysql2://", + "mongodb://", + "mongodb+srv://", + "redis://", + "rediss://", + "amqp://", + "mssql://", +]; + +const PATH_NEEDLES: [&str; 8] = [ + "/home/", + "/users/", + "/etc/", + "/var/", + "\\home\\", + "\\users\\", + "\\etc\\", + "\\var\\", +]; pub(crate) fn sanitize_message(msg: &str) -> String { let out = redact_connections(Cow::Borrowed(msg)); @@ -54,19 +74,15 @@ fn redact_paths(input: Cow<'_, str>) -> Cow<'_, str> { redact_until_whitespace(&input, &lower, &PATH_NEEDLES, "[REDACTED_PATH]").into() } -fn redact_until_whitespace( - text: &str, - lower: &str, - needles: &[&str], - replacement: &str, -) -> String { +/// Per-needle cursors only ever advance: a `find` returning `None` at some +/// offset stays `None` for every later offset, so each needle scans the +/// message at most once in total. +fn redact_until_whitespace(text: &str, lower: &str, needles: &[&str], replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let mut offset = 0; + let mut next_match: Vec<Option<usize>> = needles.iter().map(|n| lower.find(n)).collect(); loop { - let earliest = needles - .iter() - .filter_map(|n| lower[offset..].find(n).map(|rel| offset + rel)) - .min(); + let earliest = next_match.iter().flatten().copied().min(); match earliest { Some(start) => { let end = text[start..] @@ -78,6 +94,11 @@ fn redact_until_whitespace( if offset >= text.len() { break; } + for (needle, next) in needles.iter().zip(next_match.iter_mut()) { + if next.is_some_and(|pos| pos < offset) { + *next = lower[offset..].find(needle).map(|rel| offset + rel); + } + } } None => { result.push_str(&text[offset..]); @@ -183,7 +204,7 @@ fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { result.into() } -/// TLD contract: >= 2 chars, all-alphanumeric, letter-first or >= 2 letters (`c0m`/`c0`/`0rg` redact; `2x`, `1.2.10` pass); a trailing FQDN dot is trimmed for the slice but stays inside the redaction. +/// TLD contract: >= 2 chars, all-alphanumeric, letter-first or >= 2 letters (`c0m`/`c0`/`0rg` redact; `2x`, `1.2.10` pass); the TLD slice is bounded by the last domain dot, falling back to `@` for dotless domains (`user@intranet` redacts), and a trailing FQDN dot is trimmed for the slice but stays inside the redaction. fn try_match_email(chars: &[char], start: usize) -> Option<usize> { let len = chars.len(); if start >= len { @@ -200,6 +221,7 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { if i >= len || chars[i] != '@' { return None; } + let at = i; i += 1; if i >= len || !chars[i].is_alphanumeric() { @@ -218,7 +240,10 @@ fn try_match_email(chars: &[char], start: usize) -> Option<usize> { if domain_end <= start + 2 { return None; } - let dot_pos = (start..domain_end).rev().find(|&j| chars[j] == '.')?; + let dot_pos = (at + 1..domain_end) + .rev() + .find(|&j| chars[j] == '.') + .unwrap_or(at); let tld = &chars[dot_pos + 1..domain_end]; let letters = tld.iter().filter(|c| c.is_alphabetic()).count(); if tld.len() >= 2 @@ -357,4 +382,87 @@ mod tests { assert!(out.ends_with("...[truncated]")); assert!(out.len() <= SANITIZE_MAX_LEN + "...[truncated]".len()); } + + #[test] + fn sanitize_message_redacts_canonical_and_tls_scheme_variants() { + for scheme in [ + "postgresql://", + "rediss://", + "mongodb+srv://", + "mysql2://", + "amqp://", + "mssql://", + ] { + let out = sanitize_message(&format!("connect {scheme}user:pass@host/db failed")); + assert!(out.contains("[REDACTED_CONNECTION]"), "{scheme}"); + assert!(!out.contains("user:pass@host"), "{scheme}"); + assert!(!out.contains(scheme), "{scheme}"); + } + } + + #[test] + fn sanitize_message_redacts_uppercase_scheme_spellings() { + for scheme in [ + "POSTGRES://", + "POSTGRESQL://", + "REDISS://", + "MONGODB+SRV://", + ] { + let out = sanitize_message(&format!("CONNECT {scheme}user:pass@host/db FAILED")); + assert!(out.contains("[REDACTED_CONNECTION]"), "{scheme}"); + assert!(!out.contains("user:pass@host"), "{scheme}"); + } + } + + #[test] + fn sanitize_message_redacts_windows_style_paths() { + for (path, secret) in [ + (r"C:\Users\alice\.env", "alice"), + (r"C:\home\dev\.aws", ".aws"), + (r"copied C:\etc\secrets.conf", "secrets"), + (r"C:\var\log\app.log", "app.log"), + ] { + let out = sanitize_message(path); + assert!(out.contains("[REDACTED_PATH]"), "{path}"); + assert!(!out.contains(secret), "{path}"); + } + } + + #[test] + fn sanitize_message_redacts_connection_password_without_partial_leak() { + let out = sanitize_message("rediss://admin:P@ssw0rd!@cache.internal:6379/0 refused"); + assert_eq!(out, "[REDACTED_CONNECTION] refused"); + } + + #[test] + fn redact_until_whitespace_redacts_each_needle_match_once() { + let text = "a /etc/x b /var/y c"; + let lower = text.to_ascii_lowercase(); + let out = redact_until_whitespace(text, &lower, &PATH_NEEDLES, "[REDACTED_PATH]"); + assert_eq!(out, "a [REDACTED_PATH] b [REDACTED_PATH] c"); + } + + #[test] + fn redact_until_whitespace_consumes_matches_inside_redacted_span() { + let text = "x /var//home y"; + let lower = text.to_ascii_lowercase(); + let out = redact_until_whitespace(text, &lower, &PATH_NEEDLES, "[REDACTED_PATH]"); + assert_eq!(out, "x [REDACTED_PATH] y"); + } + + #[test] + fn sanitize_message_stays_linear_on_large_pathological_input() { + let unit = "postgres://u:p@h/db /home/alice/.env rejected. "; + let msg = unit.repeat(256 * 1024 / unit.len()); + assert!(msg.len() >= 256 * 1024 - unit.len()); + let start = std::time::Instant::now(); + let out = sanitize_message(&msg); + assert!( + start.elapsed().as_secs_f64() < 1.0, + "sanitizing 256KiB took {:?}", + start.elapsed() + ); + assert!(out.contains("[REDACTED_CONNECTION]")); + assert!(out.contains("[REDACTED_PATH]")); + } } diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 01b1b64..0c293b9 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -143,11 +143,32 @@ fn sanitized_redacts_email_with_digit_tld_and_trailing_dot() { } #[test] -fn sanitized_leaves_trailing_dot_notation_untouched() { +fn sanitized_redacts_trailing_dot_hostname_but_keeps_short_digit_tld() { let clean = QueryError::response("seen user@2x. and host foo@bar.").sanitized(); assert!(clean.message().contains("user@2x.")); - assert!(clean.message().contains("foo@bar.")); - assert!(!clean.message().contains("[REDACTED_EMAIL]")); + assert!(clean.message().contains("host [REDACTED_EMAIL]")); + assert!(!clean.message().contains("foo@bar.")); +} + +#[test] +fn sanitized_redacts_dotted_local_part_with_dotless_domain() { + let clean = QueryError::response("login failed for j.smith@intranet").sanitized(); + assert!(!clean.message().contains("j.smith@intranet")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_dotless_domain_email() { + let clean = QueryError::response("user user@intranet not found").sanitized(); + assert!(!clean.message().contains("user@intranet")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_dotless_domain_email_with_trailing_dot() { + let clean = QueryError::response("contact user@host. now").sanitized(); + assert!(!clean.message().contains("user@host")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); } #[test] From fd7a1324b36b0533411c46d52ef248a9e4bd1ff6 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 00:33:44 +0200 Subject: [PATCH 081/111] fix: apply round 1 review findings to requests --- crates/gpui-query/src/core/infinite_query/lifecycle.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpui-query/src/core/infinite_query/lifecycle.rs b/crates/gpui-query/src/core/infinite_query/lifecycle.rs index 388248f..8d5eeb1 100644 --- a/crates/gpui-query/src/core/infinite_query/lifecycle.rs +++ b/crates/gpui-query/src/core/infinite_query/lifecycle.rs @@ -148,6 +148,7 @@ impl<T, E> InfiniteQueryResource<T, E> { self.has_previous_page = has_more; self.enforce_max_pages_remove_back(); } + self.data_epoch = self.data_epoch.saturating_add(1); self.status = QueryStatus::Success; self.error = None; @@ -203,6 +204,9 @@ impl<T, E> InfiniteQueryResource<T, E> { if let Some(signal) = self.signal.as_ref() { signal.cancel(); } + if !self.pages.is_empty() { + self.data_epoch = self.data_epoch.saturating_add(1); + } self.pages.clear(); self.status = QueryStatus::Idle; self.error = None; From f1dc70e6404ee2961ffc4080f1735e52c1faece9 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 00:33:44 +0200 Subject: [PATCH 082/111] fix: apply round 1 review findings to notify --- crates/gpui-query/src/core/resource.rs | 48 +++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/gpui-query/src/core/resource.rs b/crates/gpui-query/src/core/resource.rs index f18f974..ea3d521 100644 --- a/crates/gpui-query/src/core/resource.rs +++ b/crates/gpui-query/src/core/resource.rs @@ -13,7 +13,7 @@ mod lifecycle; /// Framework-free state machine for one query: data, error, status, retries, /// and a cooperative cancellation signal. Lifecycle: `begin_request` → /// `accept_current_request` → `complete_*`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct QueryResource<T, E = QueryError> { key: QueryKey, status: QueryStatus, @@ -42,6 +42,52 @@ pub struct QueryResource<T, E = QueryError> { signal: Option<QuerySignal>, } +/// Observable state only; `data_epoch` is bookkeeping and must not split two +/// otherwise-equal resources. +impl<T: PartialEq, E: PartialEq> PartialEq for QueryResource<T, E> { + fn eq(&self, other: &Self) -> bool { + let QueryResource { + key, + status, + data, + error, + active_request_id, + cache_policy, + request_policy, + started_at, + last_updated_at, + cache_hits, + cancelled_count, + ignored_results, + retry_count, + retry_policy, + previous_data, + data_epoch: _, + transient_sequencer, + signal, + } = self; + *key == other.key + && *status == other.status + && *data == other.data + && *error == other.error + && *active_request_id == other.active_request_id + && *cache_policy == other.cache_policy + && *request_policy == other.request_policy + && *started_at == other.started_at + && *last_updated_at == other.last_updated_at + && *cache_hits == other.cache_hits + && *cancelled_count == other.cancelled_count + && *ignored_results == other.ignored_results + && *retry_count == other.retry_count + && *retry_policy == other.retry_policy + && *previous_data == other.previous_data + && *transient_sequencer == other.transient_sequencer + && *signal == other.signal + } +} + +impl<T: PartialEq + Eq, E: PartialEq + Eq> Eq for QueryResource<T, E> {} + impl<T, E> QueryResource<T, E> { pub fn new( key: impl Into<QueryKey>, From 6e2ea777b14f936ec1c7e548a32f9d07ddabf72f Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 00:33:44 +0200 Subject: [PATCH 083/111] fix: apply round 1 review findings to persist --- crates/gpui-query/src/client/persist.rs | 19 +- .../client_operations/persist_with_hydrate.rs | 217 ++++++++++++++++++ 2 files changed, 226 insertions(+), 10 deletions(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 9a11992..09017dd 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -264,7 +264,7 @@ impl QueryClient { &self, filter: &PersistFilter, max_age: Duration, - flushed: &HashMap<String, u64>, + flushed: &HashMap<String, (gpui::EntityId, u64)>, cx: &App, ) -> crate::client::bucket::shared::PersistCollectOut { let Some(ref registry) = self.serializers else { @@ -363,10 +363,10 @@ impl QueryClient { return; } // Clone-on-write only while a previous save is still in flight. - let fresh_epochs: Vec<(String, u64)> = delta + let fresh_epochs: Vec<(String, (gpui::EntityId, u64))> = delta .fresh .iter() - .map(|c| (c.path.clone(), c.epoch)) + .map(|c| (c.path.clone(), (c.entity_id, c.epoch))) .collect(); { let snapshot = Arc::make_mut(&mut state.store); @@ -385,9 +385,8 @@ impl QueryClient { drop(state); // Collect on the main thread (entity reads), save on background (IO). bg.spawn(async move { - if let Err(_err) = persister.save(&out).await { - #[cfg(debug_assertions)] - eprintln!("persist_with: save failed: {_err}"); + if let Err(err) = persister.save(&out).await { + eprintln!("persist_with: save failed: {err}"); } }) .detach(); @@ -402,11 +401,11 @@ impl QueryClient { } } -/// Per-driver flush state: the data epoch each path was last flushed at and -/// the full store as of the last save. Main-thread only; the mutex guards -/// the handoff of an in-flight save's snapshot. +/// Per-driver flush state: the owning entity id and data epoch each path was +/// last flushed at, plus the full store as of the last save. Main-thread +/// only; the mutex guards the handoff of an in-flight save's snapshot. struct PersistFlushState { - flushed: HashMap<String, u64>, + flushed: HashMap<String, (gpui::EntityId, u64)>, store: Arc<PersistSnapshot>, } diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index 1d575c5..62970f4 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -1240,3 +1240,220 @@ fn hydrate_discards_previous_format_version(cx: &mut TestAppContext) { }); }); } + +#[gpui::test] +fn infinite_first_page_reuses_flushed_payload_until_pages_change(cx: &mut TestAppContext) { + static SERIALIZATIONS: AtomicUsize = AtomicUsize::new(0); + + fn counting_serialize(value: &Vec<String>) -> serde_json::Value { + SERIALIZATIONS.fetch_add(1, Ordering::SeqCst); + serde_json::to_value(value).expect("serialize") + } + + setup_query_client(cx); + let persister = MemPersister::default(); + let save_count = persister.save_count.clone(); + + struct H { + feed: Entity<InfiniteQueryResource<Vec<String>, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, feed) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<Vec<String>, QueryError>(counting_serialize); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + let feed = client + .infinite_resource::<Vec<String>, QueryError>(QueryKey::from("inf-flush"), cx); + (handle, feed) + }); + H { + feed, + _handle: handle, + } + }); + cx.update(|cx| { + let feed = harness.read_with(cx, |h, _| h.feed.clone()); + cx.update_global::<QueryClient, _>(|_client, cx| { + feed.update(cx, |r, _| { + let mut seq = crate::core::RequestSequencer::new(); + let now = crate::client::current_time_ms(); + let id = r.begin_fetch_next(&mut seq, now).expect("fetch starts"); + assert!(r.complete_page_success(id, vec!["page-0".to_string()], true, true, now)); + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + SERIALIZATIONS.load(Ordering::SeqCst), + 1, + "the first flush serializes the fresh first page" + ); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|_client, cx| { + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + SERIALIZATIONS.load(Ordering::SeqCst), + 1, + "an unchanged first page must not be re-serialized" + ); + assert_eq!( + *save_count.lock().unwrap(), + 1, + "nothing dirty means no save" + ); + + cx.update(|cx| { + let feed = harness.read_with(cx, |h, _| h.feed.clone()); + cx.update_global::<QueryClient, _>(|_client, cx| { + feed.update(cx, |r, _| { + let mut seq = crate::core::RequestSequencer::new(); + let now = crate::client::current_time_ms(); + r.set_has_previous_page(true); + let id = r + .begin_fetch_previous(&mut seq, now) + .expect("previous fetch starts"); + assert!(r.complete_page_success(id, vec!["page-1".to_string()], false, false, now)); + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + SERIALIZATIONS.load(Ordering::SeqCst), + 2, + "a page write must re-serialize" + ); + assert_eq!(*save_count.lock().unwrap(), 2); + let saved = persister + .last_saved + .lock() + .unwrap() + .clone() + .expect("flush 2 saved"); + assert_eq!( + saved.entries.get("inf-flush").map(|e| &e.value), + Some(&serde_json::json!(["page-1"])), + "the re-serialized payload must carry the new first page" + ); + let _ = harness; +} + +#[gpui::test] +#[ignore = "probe: quantitative, run with --ignored"] +fn integration_persist_collect_delta_vs_full_cost(cx: &mut TestAppContext) { + use std::collections::HashMap; + use std::time::Instant; + + setup_query_client(cx); + let held: Vec<Entity<QueryResource<String, QueryError>>> = cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + (0..2_000usize) + .map(|i| { + let e = client + .resource::<String, QueryError>(QueryKey::from(format!("probe/{i}")), cx); + e.update(cx, |r, _| { + r.apply_success(format!("value-{i}"), crate::client::current_time_ms()) + }); + e + }) + .collect() + }) + }); + + let (full, delta, reused, flushed) = cx.update(|cx| { + let filter = PersistFilter::All; + let day = DAY; + let mut flushed: HashMap<String, (gpui::EntityId, u64)> = HashMap::new(); + cx.update_global::<QueryClient, _>(|client, cx| { + let first = client.collect_persist_delta(&filter, day, &flushed, cx); + for c in &first.fresh { + flushed.insert(c.path.clone(), (c.entity_id, c.epoch)); + } + let full_start = Instant::now(); + for _ in 0..100 { + std::hint::black_box(client.collect_persist_snapshot(&filter, day, cx)); + } + let full = full_start.elapsed() / 100; + let delta_start = Instant::now(); + let mut reused_count = 0usize; + for _ in 0..100 { + let out = client.collect_persist_delta(&filter, day, &flushed, cx); + reused_count = out.reused.len(); + std::hint::black_box(out); + } + let delta = delta_start.elapsed() / 100; + (full, delta, reused_count, flushed.len()) + }) + }); + println!( + "probe persist-collect over 2000 live Success entries, 100 sweeps: full-collect {full:?}/sweep, delta-collect {delta:?}/sweep (reused={reused}, flushed={flushed})" + ); + let _ = held; +} + +#[gpui::test] +fn evicted_and_recreated_entry_reserializes_at_matching_write_count(cx: &mut TestAppContext) { + setup_query_client(cx); + let persister = MemPersister::default(); + let captured = persister.last_saved.clone(); + let save_count = persister.save_count.clone(); + + struct H { + _first: Entity<QueryResource<String, QueryError>>, + second: Option<Entity<QueryResource<String, QueryError>>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (first, handle) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + let first = client.resource::<String, QueryError>(QueryKey::from("recreated"), cx); + let handle = client.persist_with(persister.clone(), zero_debounce(), cx); + (first, handle) + }); + H { + _first: first, + second: None, + _handle: handle, + } + }); + + cx.update(|cx| { + let first = harness.read_with(cx, |h, _| h._first.clone()); + cx.update_global::<QueryClient, _>(|_client, cx| { + first.update(cx, |r, _| { + r.apply_success("v1".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!(*save_count.lock().unwrap(), 1); + + harness.update(cx, |h, cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.remove_queries(&QueryKeyFilter::Exact(&QueryKey::from("recreated"))); + let second = client.resource::<String, QueryError>(QueryKey::from("recreated"), cx); + second.update(cx, |r, _| { + r.apply_success("v2".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + h.second = Some(second); + }); + }); + cx.run_until_parked(); + + let saved = captured.lock().unwrap().clone().expect("flush 2 saved"); + assert_eq!( + saved.entries.get("recreated").map(|e| &e.value), + Some(&serde_json::json!("v2")), + "a recreated entry whose write count matches its pre-eviction flushed \ + epoch must still re-serialize; the flushed gate needs entity identity" + ); + let _ = harness; +} From ad94d648773d6af188c599ef8a2fc7bfeb219944 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 00:33:44 +0200 Subject: [PATCH 084/111] fix: apply round 1 review findings to buckets --- crates/gpui-query/src/client/bucket/shared.rs | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 34c27cb..28c5195 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -38,9 +38,9 @@ pub(crate) trait BucketResource { fn resource_cache_age_ms(&self, now_ms: u64) -> Option<u64>; fn resource_cache_hits(&self) -> u64; fn resource_retry_count(&self) -> u32; - /// `None` means the resource has no data-write epoch, so persistence - /// collection always treats it as dirty. - fn resource_data_epoch(&self) -> Option<u64>; + /// Counts data writes; persistence collection skips entries whose epoch + /// is unchanged since the last flush. + fn resource_data_epoch(&self) -> u64; fn resource_invalidate(&mut self); fn resource_reset(&mut self); fn resource_cancel_inflight(&mut self); @@ -84,8 +84,8 @@ impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } - fn resource_data_epoch(&self) -> Option<u64> { - Some(self.data_epoch()) + fn resource_data_epoch(&self) -> u64 { + self.data_epoch() } fn resource_invalidate(&mut self) { self.invalidate(); @@ -139,8 +139,8 @@ impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } - fn resource_data_epoch(&self) -> Option<u64> { - None + fn resource_data_epoch(&self) -> u64 { + self.data_epoch() } fn resource_invalidate(&mut self) { self.invalidate(); @@ -486,10 +486,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { continue; }; let path = key.to_path(); - let epoch = resource.resource_data_epoch(); - if let Some(epoch) = epoch - && collect.flushed.get(&path) == Some(&epoch) - { + // Epochs restart at 0 on a recreated entry, so identity rides + // alongside the epoch in the flush gate. + let identity = (entity.entity_id(), resource.resource_data_epoch()); + if collect.flushed.get(&path) == Some(&identity) { out.reused.push(path); continue; } @@ -500,7 +500,8 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { out.fresh.push(PersistCollected { key: key.clone(), path, - epoch: epoch.unwrap_or(PERSIST_NO_DATA_EPOCH), + entity_id: identity.0, + epoch: identity.1, entry: PersistedEntry { value, cached_at, @@ -512,17 +513,13 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } -/// Epoch sentinel for resources that cannot report a data epoch; no real -/// resource reaches it, so such entries are always treated as dirty. -#[cfg(feature = "persist")] -pub(crate) const PERSIST_NO_DATA_EPOCH: u64 = u64::MAX; - /// One live persistable entry produced by /// [`ResourceBucket::collect_persistable_into`]. #[cfg(feature = "persist")] pub(crate) struct PersistCollected { pub(crate) key: QueryKey, pub(crate) path: String, + pub(crate) entity_id: gpui::EntityId, pub(crate) epoch: u64, pub(crate) entry: PersistedEntry, } @@ -537,14 +534,15 @@ pub(crate) struct PersistCollectOut { } /// Per-sweep inputs for [`ResourceBucket::collect_persistable_into`]; -/// `flushed` maps paths to the data epoch at their last flush. +/// `flushed` maps paths to the owning entity id and data epoch at their +/// last flush. #[cfg(feature = "persist")] pub(crate) struct PersistCollect<'a> { serializers: &'a SerializerRegistry, filter: &'a PersistFilter, now_ms: u64, max_age_ms: u64, - flushed: &'a std::collections::HashMap<String, u64>, + flushed: &'a std::collections::HashMap<String, (gpui::EntityId, u64)>, } #[cfg(feature = "persist")] @@ -554,7 +552,7 @@ impl<'a> PersistCollect<'a> { filter: &'a PersistFilter, now_ms: u64, max_age_ms: u64, - flushed: &'a std::collections::HashMap<String, u64>, + flushed: &'a std::collections::HashMap<String, (gpui::EntityId, u64)>, ) -> Self { Self { serializers, From ea74fc046a1f3b6df29b0fa33833f9d483a6e7b2 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 00:33:44 +0200 Subject: [PATCH 085/111] fix: apply round 1 review findings to sanitize --- crates/gpui-query/src/core/error/sanitize.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index c4ba207..2b592a8 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -74,9 +74,7 @@ fn redact_paths(input: Cow<'_, str>) -> Cow<'_, str> { redact_until_whitespace(&input, &lower, &PATH_NEEDLES, "[REDACTED_PATH]").into() } -/// Per-needle cursors only ever advance: a `find` returning `None` at some -/// offset stays `None` for every later offset, so each needle scans the -/// message at most once in total. +/// Per-needle cursors only advance: a `find` returning `None` stays `None` for every later offset, so each needle scans the message once in total. fn redact_until_whitespace(text: &str, lower: &str, needles: &[&str], replacement: &str) -> String { let mut result = String::with_capacity(text.len()); let mut offset = 0; From f52e48891f436e1ddc02bd8be2bd60c1b3972ecb Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 01:34:19 +0200 Subject: [PATCH 086/111] fix: apply round 2 review findings to requests --- .../src/tests/hook_tests/regression_tests.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index c17507b..986ce3a 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -508,3 +508,53 @@ fn prefetch_completion_reaches_mounted_use_query_observer(cx: &mut TestAppContex re-render the mounted consumer" ); } + +#[gpui::test] +fn manual_append_page_reaches_mounted_use_infinite_query_observer(cx: &mut TestAppContext) { + setup_test(cx); + + struct H { + entity: Entity<InfiniteQueryResource<Vec<u32>, QueryError>>, + _sub: gpui::Subscription, + } + + let harness = cx.new(|cx| { + let (entity, sub) = use_infinite_query( + InfiniteQueryOptions::new("infinite-append-notify"), + |_last: Option<&Vec<u32>>| async move { Ok::<_, QueryError>((vec![1], false)) }, + cx, + ); + H { entity, _sub: sub } + }); + + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + harness.read(cx).entity.read(cx).status(), + crate::core::QueryStatus::Success + ); + }); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + harness.update(cx, |h, cx| { + h.entity.update(cx, |r, cx| { + r.append_page(vec![2]); + cx.notify(); + }); + }); + cx.run_until_parked(); + + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "a same-status manual page write must re-render the mounted infinite consumer" + ); +} From 72afc23edfc46e16c922bab4eec4a67d207506ce Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 01:34:19 +0200 Subject: [PATCH 087/111] fix: apply round 2 review findings to notify --- crates/gpui-query/src/client/observer.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpui-query/src/client/observer.rs b/crates/gpui-query/src/client/observer.rs index f976ac7..6138143 100644 --- a/crates/gpui-query/src/client/observer.rs +++ b/crates/gpui-query/src/client/observer.rs @@ -42,6 +42,10 @@ impl<T: 'static, E: 'static> ObservableResource for InfiniteQueryResource<T, E> fn observable_status(&self) -> QueryStatus { self.status() } + + fn data_epoch(&self) -> u64 { + InfiniteQueryResource::data_epoch(self) + } } impl<V: 'static, T: 'static, E: 'static> ObservableResource for MutationResource<V, T, E> { From 5c91cd4637f24897d797acb71a17baac820292d9 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 01:34:20 +0200 Subject: [PATCH 088/111] fix: apply round 2 review findings to persist --- .../client_gap_coverage/gc_coverage.rs | 45 +++++++++++++++++++ .../client_operations/gc_query_operations.rs | 20 +++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index 6bc7d14..4569530 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -438,3 +438,48 @@ fn test_evict_oldest_with_only_dead_entries_keeps_bucket_bounded(cx: &mut TestAp ); }); } + +#[gpui::test] +fn test_evict_oldest_prefers_dead_entry_with_newest_mirror_at_capacity(cx: &mut TestAppContext) { + let mut bucket = QueryBucket::<String, QueryError>::new(); + bucket.inner.max_entries = 2; + + let live = cx.update(|cx| { + let live = create_evict_entry(&mut bucket, "evict_live_older", cx); + stamp_and_refresh(&mut bucket, &live, "evict_live_older", 1_000, cx); + live + }); + + cx.update(|cx| { + let dead = create_evict_entry(&mut bucket, "evict_dead_newest", cx); + stamp_and_refresh(&mut bucket, &dead, "evict_dead_newest", 2_000, cx); + drop(dead); + }); + + cx.update(|cx| { + create_evict_entry(&mut bucket, "evict_after_dead", cx); + + assert!( + !bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_dead_newest")), + "collected entry must be evicted first even when its mirror age is \ + the newest in the bucket" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_live_older")), + "older live entry must survive while a collected entry can go" + ); + assert!( + bucket + .inner + .entries + .contains_key(&QueryKey::from("evict_after_dead")) + ); + }); + drop(live); +} diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs index 411d122..a52751d 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/gc_query_operations.rs @@ -68,6 +68,26 @@ fn test_gc_with_time_explicit_time_value(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_gc_with_time_before_entry_baseline_keeps_never_fetched_resource(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc_past_now"); + let _e = client.resource::<String, QueryError>(key.clone(), cx); + + let now = crate::client::current_time_ms(); + client.gc_with_time(now.saturating_sub(60_000), cx); + + assert!( + client.query::<String, QueryError>(&key).is_some(), + "a gc time older than the entry baseline must not evict a never-fetched \ + resource: age saturates to 0, below the gc_threshold" + ); + }); + }); +} + #[gpui::test] fn test_gc_runs_across_all_bucket_types(cx: &mut TestAppContext) { setup_query_client_with_gc(cx, 1_000); From 91bc0f1b58b0f52968de94adeb0c2e78217abc80 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 01:34:20 +0200 Subject: [PATCH 089/111] fix: apply round 2 review findings to buckets --- crates/gpui-query/src/client/bucket/shared.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 28c5195..3ded978 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -251,16 +251,20 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { (entity, request_id) } - /// Scans only the stored mirrors, then confirms the winner with a single - /// entity read (the mirror can be stale if a fetch began after the last - /// refresh). Each retry marks the stale mirror and re-picks; a collected - /// weak ref fails the confirm and is removed in place. + /// Collected entries are age-zero candidates so they evict before any + /// live entry; the winner gets one confirming entity read (the mirror + /// can be stale if a fetch began after the last refresh). Each retry + /// marks the stale mirror and re-picks; a dead winner is removed in + /// place by the failed confirm. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self .entries .iter() .filter_map(|(key, entry)| { + if !entry.entity.is_upgradable() { + return Some((key, 0)); + } if entry.loading { return None; } From f8fda88a0e498aa7fc634cbf2b832d4df43d7966 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 01:34:20 +0200 Subject: [PATCH 090/111] fix: apply round 2 review findings to sanitize --- crates/gpui-query/src/tests/core_error/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/gpui-query/src/tests/core_error/mod.rs b/crates/gpui-query/src/tests/core_error/mod.rs index 0c293b9..a53e10a 100644 --- a/crates/gpui-query/src/tests/core_error/mod.rs +++ b/crates/gpui-query/src/tests/core_error/mod.rs @@ -171,6 +171,20 @@ fn sanitized_redacts_dotless_domain_email_with_trailing_dot() { assert!(clean.message().contains("[REDACTED_EMAIL]")); } +#[test] +fn sanitized_redacts_version_like_local_at_letter_domain_token() { + let clean = QueryError::response("build 1.0@beta failed").sanitized(); + assert!(!clean.message().contains("1.0@beta")); + assert!(clean.message().contains("[REDACTED_EMAIL]")); +} + +#[test] +fn sanitized_redacts_password_shaped_at_token_without_scheme() { + let clean = QueryError::response("admin:P@ssw0rd! refused").sanitized(); + assert!(!clean.message().contains("P@ssw0rd")); + assert_eq!(clean.message(), "admin:[REDACTED_EMAIL]! refused"); +} + #[test] fn sanitized_redacts_mongodb_connection_string() { let clean = QueryError::transport("connect mongodb://admin:secret@host/db failed").sanitized(); From 09b3274ff7337fab763c74d080b648c48d31b9e7 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 02:53:35 +0200 Subject: [PATCH 091/111] fix: apply round 3 review findings to persist --- crates/gpui-query/src/client/persist.rs | 37 ++++++++++++++++++- crates/gpui-query/src/core/key.rs | 1 + .../client_gap_coverage/gc_coverage.rs | 37 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 09017dd..feef75c 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -386,7 +386,7 @@ impl QueryClient { // Collect on the main thread (entity reads), save on background (IO). bg.spawn(async move { if let Err(err) = persister.save(&out).await { - eprintln!("persist_with: save failed: {err}"); + eprintln!("persist_with: save failed: {}", save_failure_log_text(&err)); } }) .detach(); @@ -409,6 +409,12 @@ struct PersistFlushState { store: Arc<PersistSnapshot>, } +/// Persister errors can embed payload or path detail (custom `Deserialize` +/// strings, `Permission` text), so log output passes the shared redactor. +pub(crate) fn save_failure_log_text(err: &PersistError) -> String { + crate::core::error::sanitize::sanitize_message(&err.to_string()) +} + /// Persists nothing; loads an empty snapshot. pub struct NoopPersister; @@ -469,3 +475,32 @@ pub async fn hydrate<P: Persister>( Ok(snapshot) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_failure_log_text_redacts_hostile_detail_and_keeps_benign() { + let hostile = + PersistError::Permission("cache write denied for token= supersecretvalue".to_string()); + let logged = save_failure_log_text(&hostile); + assert!(logged.contains("[REDACTED_TOKEN]"), "{logged}"); + assert!(!logged.contains("supersecretvalue")); + + let path = PersistError::BadPath("no cache dir for /home/alice/app".to_string()); + let logged = save_failure_log_text(&path); + assert!(logged.contains("[REDACTED_PATH]"), "{logged}"); + assert!(!logged.contains("/home/alice/app")); + + let benign = PersistError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "cache file missing", + )); + assert_eq!( + save_failure_log_text(&benign), + benign.to_string(), + "benign io diagnostics must pass through unredacted" + ); + } +} diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 557a1d8..5cbdf49 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -88,6 +88,7 @@ impl QueryKey { /// Inverse of [`to_path`](Self::to_path); any input yields at least one /// segment and never panics. + #[cfg(any(feature = "client", test))] pub(crate) fn from_path(path: &str) -> Self { let mut segments: Vec<String> = Vec::new(); let mut current = String::new(); diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs index 4569530..3e7720a 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_gap_coverage/gc_coverage.rs @@ -483,3 +483,40 @@ fn test_evict_oldest_prefers_dead_entry_with_newest_mirror_at_capacity(cx: &mut }); drop(live); } + +#[gpui::test] +fn test_gc_drops_dead_mutation_with_stale_loading_mirror(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let entity = cx.new(|_| { + MutationResource::<String, String, QueryError>::new(RetryPolicy::no_retries()) + }); + client.register_mutation::<String, String, QueryError>(&entity, cx); + + entity.update(cx, |m, _| m.begin("vars".to_string())); + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 1_000_000, cx); + + assert_eq!( + client.diagnostics(cx).mutation_count, + 1, + "loading mutation must survive GC while its entity is alive" + ); + drop(entity); + }); + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let now = crate::client::current_time_ms(); + client.gc_with_time(now + 1_000_000, cx); + + assert_eq!( + client.diagnostics(cx).mutation_count, + 0, + "a dead mutation with a stale loading mirror must be dropped by GC" + ); + }); + }); +} From 96a5308c6a888833ad4831c807213caa8d5a8163 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 02:53:35 +0200 Subject: [PATCH 092/111] fix: apply round 3 review findings to buckets --- .../gpui-query/src/client/mutation_bucket.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 6b6ad44..7bc5e84 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -42,16 +42,20 @@ impl< } } - /// Skips loading entries; the winner gets one confirming entity read - /// (the mirror can be stale if a fetch began after the last refresh), - /// and each stale re-check marks the mirror and re-picks. A collected - /// weak ref fails the confirm and is removed in place. + /// Collected entries are age-zero candidates so they evict before any + /// live entry; the winner gets one confirming entity read (the mirror + /// can be stale if a fetch began after the last refresh), and each + /// stale re-check marks the mirror and re-picks. A dead winner is + /// removed in place by the failed confirm. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self .resources .iter() .filter_map(|(id, entry)| { + if !entry.entity.is_upgradable() { + return Some((*id, 0)); + } if entry.loading { return None; } @@ -133,17 +137,13 @@ impl< /// Loading always survives; `Success` survives /// `SUCCESS_GC_MULTIPLIER * gc_time_ms`, `Idle`/`Failure` survive - /// `gc_time_ms`. The `loading` mirror is checked first so a mid-flight - /// mutation whose weak ref cannot upgrade survives one cycle. + /// `gc_time_ms`. Dead weak refs are dropped regardless of the loading + /// mirror. fn gc(&mut self, now_ms: u64, gc_time_ms: u64, cx: &App) { let gc_threshold = gc_time_ms.max(MIN_GC_TIME_MS); let success_threshold = gc_threshold.saturating_mul(SUCCESS_GC_MULTIPLIER as u64); self.resources.retain(|_id, entry| { - if entry.loading { - return true; - } - let Some(entity) = entry.entity.upgrade() else { return false; }; From 1f89989360b68bcd4934c722105b264458c87b1b Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 02:53:35 +0200 Subject: [PATCH 093/111] fix: close redaction gaps and bound sanitize cost --- crates/gpui-query/src/core/error/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-query/src/core/error/mod.rs b/crates/gpui-query/src/core/error/mod.rs index 4f85d7a..1ec66a1 100644 --- a/crates/gpui-query/src/core/error/mod.rs +++ b/crates/gpui-query/src/core/error/mod.rs @@ -3,7 +3,7 @@ //! use [`QueryError::sanitized`] on server responses. mod convert; -mod sanitize; +pub(crate) mod sanitize; mod serde; mod types; From 72502ff362907d99924e05762fe2428e991e1865 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 03:32:46 +0200 Subject: [PATCH 094/111] chore: finish bench harness plumbing --- Cargo.toml | 1 + crates/gpui-query-http/Cargo.toml | 3 +++ crates/gpui-query-persist/Cargo.toml | 7 +++++++ crates/gpui-query/Cargo.toml | 7 +++++++ 4 files changed, 18 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 8e0f813..dae0a91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,5 @@ resolver = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" gpui = { version = "0.2.2" } +criterion = { version = "0.8", features = ["html_reports"] } # gpui = { git = "https://github.com/zed-industries/zed", rev = "501ab50f9b03f1c3a13df11ade804bbdf11146ff" } diff --git a/crates/gpui-query-http/Cargo.toml b/crates/gpui-query-http/Cargo.toml index 15089cb..c6c19a2 100644 --- a/crates/gpui-query-http/Cargo.toml +++ b/crates/gpui-query-http/Cargo.toml @@ -17,6 +17,9 @@ default = [] ## [`crate::backend::HttpBackend`] instead. reqwest = ["dep:reqwest"] +[lib] +bench = false + [dependencies] # `version` must stay alongside `path`: `cargo publish` strips the path override. gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["core"] } diff --git a/crates/gpui-query-persist/Cargo.toml b/crates/gpui-query-persist/Cargo.toml index 8bfd3ca..afda0cb 100644 --- a/crates/gpui-query-persist/Cargo.toml +++ b/crates/gpui-query-persist/Cargo.toml @@ -14,6 +14,9 @@ authors = ["hmziqrs"] [features] default = [] +[lib] +bench = false + [dependencies] # `version` is required alongside `path`: `cargo publish` strips the path override. gpui-query = { path = "../gpui-query", version = "0.2", default-features = false, features = ["persist", "client", "hook"] } @@ -28,6 +31,10 @@ gpui = { workspace = true } pollster = "0.4" criterion = { workspace = true } +[[test]] +name = "file_persister" +bench = false + [[bench]] name = "persist_round_trip" harness = false diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index d8fcb96..f1bfb46 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -18,6 +18,9 @@ client = ["core", "dep:gpui"] hook = ["client"] persist = ["client", "hook", "dep:serde_json", "dep:thiserror"] +[lib] +bench = false + [dependencies] serde = { workspace = true } serde_json = { workspace = true, optional = true } @@ -38,6 +41,10 @@ gpui = { workspace = true, features = ["test-support"] } proptest = "1" criterion = { workspace = true } +[[test]] +name = "edge_cases" +bench = false + [[bench]] name = "sanitize" harness = false From 14246618616ea76b4a68a20d998ecc094d8939ee Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 03:33:14 +0200 Subject: [PATCH 095/111] chore: apply rustfmt to files outside the campaign areas --- crates/gpui-query-http/src/cache.rs | 27 ++++-- crates/gpui-query/src/client/erased.rs | 16 ++-- .../gpui-query/src/client/infinite_bucket.rs | 8 +- .../src/core/infinite_query/accessors.rs | 6 ++ .../core/infinite_query/page_management.rs | 8 +- .../src/core/infinite_query/resource.rs | 50 +++++++++- crates/gpui-query/src/core/mod.rs | 2 +- crates/gpui-query/src/core/policy.rs | 13 ++- .../tests/core_infinite_query/max_pages.rs | 8 +- .../tests/core_infinite_query/page_fetch.rs | 55 +++++++++-- .../core_lifecycle/data_and_lifecycle.rs | 96 +++++++++++++++++++ .../core_lifecycle/policies_and_cache.rs | 6 +- .../gpui-query/src/tests/core_select/mod.rs | 1 - .../property_tests/query_key/proptests.rs | 11 ++- 14 files changed, 263 insertions(+), 44 deletions(-) diff --git a/crates/gpui-query-http/src/cache.rs b/crates/gpui-query-http/src/cache.rs index 4476226..081e3ec 100644 --- a/crates/gpui-query-http/src/cache.rs +++ b/crates/gpui-query-http/src/cache.rs @@ -74,7 +74,10 @@ impl<B: HttpBackend> HttpCache<B> { // checked_add: an extreme serde-hydrated stored_at must not panic. if let Some(meta) = cached_meta.as_ref() - && meta.stored_at.checked_add(meta.fresh_for).is_none_or(|t| t > SystemTime::now()) + && meta + .stored_at + .checked_add(meta.fresh_for) + .is_none_or(|t| t > SystemTime::now()) && let Some(body) = self.cached_body(url)? { let policy = policy_from_meta(meta); @@ -133,9 +136,7 @@ impl<B: HttpBackend> HttpCache<B> { url: &str, resp: BackendResponse, ) -> Result<(Bytes, CachePolicy, Option<CacheMeta>), HttpError> { - let BackendResponse { - headers, body, .. - } = resp; + let BackendResponse { headers, body, .. } = resp; let Ok(policy) = cache_policy_from_headers(&headers) else { return Ok((body, CachePolicy::NoCache, None)); }; @@ -396,11 +397,19 @@ mod tests { assert_eq!(policy1, CachePolicy::Ttl { ttl_ms: 600_000 }); let (body2, policy2, _) = cache.fetch("https://example.test/b").await.unwrap(); - assert_eq!(body2, Bytes::from_static(b"first"), "fresh hit serves cached body"); + assert_eq!( + body2, + Bytes::from_static(b"first"), + "fresh hit serves cached body" + ); assert_eq!(policy2, CachePolicy::Ttl { ttl_ms: 600_000 }); assert_eq!(cache.backend.calls(), 1); - assert_eq!(cache.backend.remaining(), 1, "second canned response untouched"); + assert_eq!( + cache.backend.remaining(), + 1, + "second canned response untouched" + ); } #[tokio::test] @@ -415,7 +424,11 @@ mod tests { assert_eq!(body1, Bytes::from_static(b"payload")); let (body2, _, meta2) = cache.fetch("https://example.test/c").await.unwrap(); - assert_eq!(body2, Bytes::from_static(b"payload"), "304 served cached body"); + assert_eq!( + body2, + Bytes::from_static(b"payload"), + "304 served cached body" + ); assert!(meta2.is_some(), "304 still yields cached meta"); } diff --git a/crates/gpui-query/src/client/erased.rs b/crates/gpui-query/src/client/erased.rs index 2944799..1aa740e 100644 --- a/crates/gpui-query/src/client/erased.rs +++ b/crates/gpui-query/src/client/erased.rs @@ -2,8 +2,6 @@ //! in `AHashMap<TypeId, Box<dyn Erased*>>` maps. use crate::client::devtools::{MutationDiagnostic, QueryDiagnostic}; -#[cfg(feature = "persist")] -use crate::client::persist::PersistedEntry; use crate::core::QueryKeyFilter; #[cfg(feature = "persist")] use crate::core::{MutationStatus, QueryStatus}; @@ -26,15 +24,15 @@ pub(crate) trait ErasedBucket { /// diagnostics; used by `dehydrate`. #[cfg(feature = "persist")] fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(String, QueryStatus)>); - /// Entries whose `T` has no registered serializer are skipped; filter - /// and max-age are checked before serializing so skipped entries cost - /// nothing. + /// Entries whose `T` has no registered serializer are skipped; filter, + /// max-age, and the driver's last-flushed epochs are checked before + /// serializing so skipped entries cost nothing. #[cfg(feature = "persist")] fn collect_persistable_into( &self, cx: &gpui::App, collect: &crate::client::bucket::shared::PersistCollect<'_>, - out: &mut Vec<(crate::core::QueryKey, PersistedEntry)>, + out: &mut crate::client::bucket::shared::PersistCollectOut, ); /// Prunes the persisted-meta map of keys whose entries were evicted. #[cfg(feature = "persist")] @@ -49,7 +47,11 @@ pub(crate) trait ErasedMutationBucket { fn collect_diagnostics_into(&self, cx: &gpui::App, out: &mut Vec<MutationDiagnostic>); /// `key` is `None` for keyless mutations. #[cfg(feature = "persist")] - fn collect_key_status_into(&self, cx: &gpui::App, out: &mut Vec<(Option<String>, MutationStatus)>); + fn collect_key_status_into( + &self, + cx: &gpui::App, + out: &mut Vec<(Option<String>, MutationStatus)>, + ); } /// Legacy metadata-only persistence: entries serialize as JSON strings, diff --git a/crates/gpui-query/src/client/infinite_bucket.rs b/crates/gpui-query/src/client/infinite_bucket.rs index 4e61f60..ca18e12 100644 --- a/crates/gpui-query/src/client/infinite_bucket.rs +++ b/crates/gpui-query/src/client/infinite_bucket.rs @@ -30,7 +30,8 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> Infinit request_policy: RequestPolicy, cx: &mut App, ) -> Entity<InfiniteQueryResource<T, E>> { - self.entries.get_or_create(key, cache_policy, request_policy, cx) + self.entries + .get_or_create(key, cache_policy, request_policy, cx) } pub(crate) fn get(&self, key: &QueryKey) -> Option<Entity<InfiniteQueryResource<T, E>>> { @@ -101,10 +102,7 @@ impl<T: Clone + Send + Sync + 'static, E: Clone + Send + Sync + 'static> ErasedB &self, cx: &App, collect: &super::bucket::shared::PersistCollect<'_>, - out: &mut Vec<( - crate::core::QueryKey, - crate::client::persist::PersistedEntry, - )>, + out: &mut super::bucket::shared::PersistCollectOut, ) { self.entries .collect_persistable_into(cx, collect, out, |r| r.first_page()); diff --git a/crates/gpui-query/src/core/infinite_query/accessors.rs b/crates/gpui-query/src/core/infinite_query/accessors.rs index 697386b..72a9ddd 100644 --- a/crates/gpui-query/src/core/infinite_query/accessors.rs +++ b/crates/gpui-query/src/core/infinite_query/accessors.rs @@ -118,6 +118,12 @@ impl<T, E> InfiniteQueryResource<T, E> { self.last_updated_at.map(QueryTimestamp::as_millis) } + /// Counts page writes, not value changes; compare instead of + /// deep-comparing page contents. + pub fn data_epoch(&self) -> u64 { + self.data_epoch + } + /// `None` when nothing was recorded or on clock skew (`checked_sub`). pub fn cache_age_ms(&self, now_ms: u64) -> Option<u64> { QueryTimestamp::from(now_ms).elapsed_since(self.last_updated_at?) diff --git a/crates/gpui-query/src/core/infinite_query/page_management.rs b/crates/gpui-query/src/core/infinite_query/page_management.rs index 0744f41..0cd5523 100644 --- a/crates/gpui-query/src/core/infinite_query/page_management.rs +++ b/crates/gpui-query/src/core/infinite_query/page_management.rs @@ -23,18 +23,24 @@ impl<T, E> InfiniteQueryResource<T, E> { Some(0) => None, other => other, }; - self.enforce_max_pages_remove_front() + let evicted = self.enforce_max_pages_remove_front(); + if !evicted.is_empty() { + self.data_epoch = self.data_epoch.saturating_add(1); + } + evicted } /// Returns evicted pages (if any) as `Arc` handles. pub fn append_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_back(Arc::new(page)); + self.data_epoch = self.data_epoch.saturating_add(1); self.enforce_max_pages_remove_front() } /// Returns evicted pages (if any) as `Arc` handles. pub fn prepend_page(&mut self, page: T) -> Vec<Arc<T>> { self.pages.push_front(Arc::new(page)); + self.data_epoch = self.data_epoch.saturating_add(1); self.enforce_max_pages_remove_back() } diff --git a/crates/gpui-query/src/core/infinite_query/resource.rs b/crates/gpui-query/src/core/infinite_query/resource.rs index eb8de1b..3046985 100644 --- a/crates/gpui-query/src/core/infinite_query/resource.rs +++ b/crates/gpui-query/src/core/infinite_query/resource.rs @@ -22,7 +22,7 @@ pub enum FetchDirection { /// Pages are stored as `Arc<T>` so the `*_arc` accessors can hand the fetcher /// a cheap clone instead of copying the page. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(bound(serialize = "T: serde::Serialize, E: serde::Serialize"))] #[serde(bound(deserialize = "T: serde::de::DeserializeOwned, E: serde::de::DeserializeOwned"))] pub struct InfiniteQueryResource<T, E = QueryError> { @@ -36,6 +36,10 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(super) request_policy: RequestPolicy, pub(super) started_at: Option<QueryTimestamp>, pub(super) last_updated_at: Option<QueryTimestamp>, + /// Counts page writes, not value changes; runtime only, so change + /// detection never deep-compares `T`. + #[serde(skip)] + pub(super) data_epoch: u64, pub(super) cache_hits: u64, pub(super) cancelled_count: u64, pub(super) ignored_results: u64, @@ -59,6 +63,49 @@ pub struct InfiniteQueryResource<T, E = QueryError> { pub(crate) current_task: crate::core::current_task::CurrentTask, } +/// Observable state only; `data_epoch` is bookkeeping and must not split two +/// otherwise-equal resources, matching `QueryResource`. +impl<T: PartialEq, E: PartialEq> PartialEq for InfiniteQueryResource<T, E> { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + && self.pages == other.pages + && self.status == other.status + && self.error == other.error + && self.active_request_id == other.active_request_id + && self.cache_policy == other.cache_policy + && self.request_policy == other.request_policy + && self.started_at == other.started_at + && self.last_updated_at == other.last_updated_at + && self.cache_hits == other.cache_hits + && self.cancelled_count == other.cancelled_count + && self.ignored_results == other.ignored_results + && self.retry_count == other.retry_count + && self.has_next_page == other.has_next_page + && self.has_previous_page == other.has_previous_page + && self.fetching_direction == other.fetching_direction + && self.max_pages == other.max_pages + && self.direction == other.direction + && self.retry_policy == other.retry_policy + && self.transient_sequencer == other.transient_sequencer + && self.signal == other.signal + && self.current_task_eq(other) + } +} + +impl<T: PartialEq + Eq, E: PartialEq + Eq> Eq for InfiniteQueryResource<T, E> {} + +impl<T, E> InfiniteQueryResource<T, E> { + #[cfg(feature = "client")] + fn current_task_eq(&self, other: &Self) -> bool { + self.current_task == other.current_task + } + + #[cfg(not(feature = "client"))] + fn current_task_eq(&self, _other: &Self) -> bool { + true + } +} + /// The wire format is a plain sequence, identical to the old `Vec<T>` /// representation, so previously persisted data stays readable. pub(super) mod vec_deque_serde { @@ -143,6 +190,7 @@ impl<T, E> InfiniteQueryResource<T, E> { request_policy, started_at: None, last_updated_at: None, + data_epoch: 0, cache_hits: 0, cancelled_count: 0, ignored_results: 0, diff --git a/crates/gpui-query/src/core/mod.rs b/crates/gpui-query/src/core/mod.rs index 8860d10..9a510eb 100644 --- a/crates/gpui-query/src/core/mod.rs +++ b/crates/gpui-query/src/core/mod.rs @@ -2,7 +2,7 @@ //! Fetch protocol: `begin_request` → `accept_current_request` (single-use //! `RequestGuard`) → `complete_success`/`complete_failure`. -mod error; +pub(crate) mod error; mod fetched; mod infinite_query; mod key; diff --git a/crates/gpui-query/src/core/policy.rs b/crates/gpui-query/src/core/policy.rs index 8a1af33..33f9462 100644 --- a/crates/gpui-query/src/core/policy.rs +++ b/crates/gpui-query/src/core/policy.rs @@ -9,12 +9,17 @@ pub enum CachePolicy { NoCache, /// `ttl_ms = 0` behaves like `NoCache` (data is only "fresh" at the /// instant it is stored); only `debug_assert`ed, not validated in release. - Ttl { ttl_ms: u64 }, + Ttl { + ttl_ms: u64, + }, /// Fresh within `ttl_ms`; between `ttl_ms` and `ttl_ms + stale_ms` the /// stale data is served and a background revalidation is triggered; past /// that, a normal fetch runs. Zero values degenerate as in [`Ttl`](Self::Ttl); /// only `debug_assert`ed, not validated in release. - StaleWhileRevalidate { ttl_ms: u64, stale_ms: u64 }, + StaleWhileRevalidate { + ttl_ms: u64, + stale_ms: u64, + }, } impl Default for CachePolicy { @@ -166,7 +171,9 @@ pub enum QueryBeginResult { status: QueryStatus, replaced_request_id: Option<RequestId>, }, - IgnoredWhileLoading { active_request_id: RequestId }, + IgnoredWhileLoading { + active_request_id: RequestId, + }, } #[cfg(test)] diff --git a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs index 44a4f68..e448a0a 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/max_pages.rs @@ -86,13 +86,7 @@ fn max_pages_50_allows_50_pages_and_evicts_on_51st() { for (i, label) in P_LABELS.iter().enumerate().take(50) { let id = r.begin_fetch_next(&mut seq, (i * 100) as u64).unwrap(); - r.complete_page_success( - id, - vec![*label], - true, - true, - ((i + 1) * 100) as u64, - ); + r.complete_page_success(id, vec![*label], true, true, ((i + 1) * 100) as u64); } assert_eq!(r.page_count(), 50); assert_eq!(r.first_page(), Some(&vec!["p0"])); diff --git a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs index d0b4a11..3d34003 100644 --- a/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs +++ b/crates/gpui-query/src/tests/core_infinite_query/page_fetch.rs @@ -54,13 +54,7 @@ fn fetch_previous_page_prepends_page() { let id2 = r.begin_fetch_previous(&mut seq, 3_000).unwrap(); assert!(r.is_fetching_previous_page()); - let accepted = r.complete_page_success( - id2, - vec!["page0"], - false, - false, - 4_000, - ); + let accepted = r.complete_page_success(id2, vec!["page0"], false, false, 4_000); assert!(accepted); assert_eq!(r.page_count(), 2); assert_eq!(r.first_page(), Some(&vec!["page0"])); @@ -248,3 +242,50 @@ fn ignore_while_loading_allows_cross_direction_fetch() { assert!(r.is_fetching_previous_page()); assert!(!r.is_fetching_next_page()); } + +#[test] +fn data_epoch_counts_page_writes_not_value_reads() { + let mut r = make_resource(); + let mut seq = RequestSequencer::new(); + assert_eq!(r.data_epoch(), 0); + + let id = r.begin_fetch_next(&mut seq, 1_000).unwrap(); + assert!(r.complete_page_success(id, vec!["a"], true, true, 2_000)); + assert_eq!(r.data_epoch(), 1); + + r.append_page(vec!["b"]); + assert_eq!(r.data_epoch(), 2); + r.prepend_page(vec!["c"]); + assert_eq!(r.data_epoch(), 3); + + r.invalidate(); + assert_eq!(r.data_epoch(), 3, "invalidate is not a data write"); + + r.set_max_pages(Some(1)); + assert_eq!(r.data_epoch(), 4, "evicting pages is a data change"); + r.set_max_pages(Some(10)); + assert_eq!(r.data_epoch(), 4, "no eviction is not a data write"); + + r.reset(); + assert_eq!(r.data_epoch(), 5); + r.reset(); + assert_eq!(r.data_epoch(), 5, "reset without pages is not a data write"); +} + +#[test] +fn equality_ignores_the_data_epoch() { + let mut a = make_resource(); + a.set_max_pages(Some(1)); + a.append_page(vec!["a"]); + let mut b = a.clone(); + assert_eq!(a, b); + + a.append_page(vec!["a"]); + assert_eq!( + a, b, + "a page write that lands the same observable state must not split equality" + ); + + b.append_page(vec!["b"]); + assert_ne!(a, b); +} diff --git a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs index 0674082..f0f6a47 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/data_and_lifecycle.rs @@ -175,3 +175,99 @@ fn full_lifecycle_round_trip() { assert_eq!(r.data(), None); assert_eq!(r.cancelled_count(), 0); } + +#[test] +fn data_epoch_counts_writes_not_value_changes() { + let mut r = resource(); + assert_eq!(r.data_epoch(), 0); + + r.set_data("v"); + assert_eq!(r.data_epoch(), 1); + r.set_data("v"); + assert_eq!( + r.data_epoch(), + 2, + "equal-value overwrite still counts as a write" + ); + + r.clear_data(); + assert_eq!(r.data_epoch(), 3); + + assert!(r.rollback_to_previous()); + assert_eq!(r.data_epoch(), 4); +} + +#[test] +fn data_epoch_bumps_on_success_and_failure_with_data_paths() { + let mut r = nocache_resource("epoch-completion"); + let mut s = seq(); + + let (rid, _) = begin(&mut r, &mut s, 100); + r.complete_current_optional_success(rid, Some("a"), 200); + assert_eq!(r.data_epoch(), 1); + + let (rid2, _) = begin(&mut r, &mut s, 300); + r.complete_current_failure_with_data(rid2, "b", QueryError::response("x"), 400); + assert_eq!(r.data_epoch(), 2); + assert_eq!(r.data(), Some(&"b")); +} + +#[test] +fn data_epoch_unchanged_by_failure_without_data() { + let mut r = nocache_resource("epoch-failure"); + let mut s = seq(); + + let (rid, _) = begin(&mut r, &mut s, 100); + r.complete_current_failure(rid, QueryError::response("x"), 200); + + assert_eq!(r.data_epoch(), 0); + assert_eq!(r.status(), QueryStatus::Failure); +} + +#[test] +fn data_epoch_bumps_when_cancel_or_reset_moves_data_out() { + let mut r = nocache_resource("epoch-cancel-reset"); + let mut s = seq(); + + let (rid, _) = begin(&mut r, &mut s, 100); + assert!(r.complete_current_success(rid, "v", 200)); + assert_eq!(r.data_epoch(), 1); + + let (_rid2, _) = begin(&mut r, &mut s, 300); + assert!(r.cancel(QueryError::response("c"))); + assert_eq!(r.data(), None); + assert_eq!(r.data_epoch(), 2); + + assert!(r.rollback_to_previous()); + assert_eq!(r.data(), Some(&"v")); + assert_eq!(r.data_epoch(), 3); + + r.reset(); + assert_eq!(r.data(), None); + assert_eq!(r.data_epoch(), 4); + + r.reset(); + assert_eq!(r.data_epoch(), 4, "reset without data is not a data write"); +} + +#[test] +fn equality_ignores_the_data_epoch() { + let mut a = resource(); + let mut s = seq(); + + let (rid, _) = begin(&mut a, &mut s, 100); + assert!(a.complete_current_success(rid, "v", 200)); + let mut b = a.clone(); + assert_eq!(a, b); + + a.set_data("v"); + assert!(a.rollback_to_previous()); + assert_eq!( + a, b, + "a same-value write + rollback leaves equal observable state despite \ + different data epochs" + ); + + b.set_data("w"); + assert_ne!(a, b); +} diff --git a/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs b/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs index de1c2eb..39c1720 100644 --- a/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs +++ b/crates/gpui-query/src/tests/core_lifecycle/policies_and_cache.rs @@ -221,7 +221,11 @@ fn begin_request_with_id_none_uses_transient_sequencer() { match result { QueryBeginResult::Started { request_id, .. } => { - assert_eq!(request_id, RequestId::scoped(NonZero::new(1).unwrap(), 1)); + assert_eq!( + request_id.scope_id(), + RequestSequencer::RESERVED_FALLBACK_SCOPE + ); + assert_eq!(request_id.value(), 1); } _ => panic!("expected Started"), } diff --git a/crates/gpui-query/src/tests/core_select/mod.rs b/crates/gpui-query/src/tests/core_select/mod.rs index 9923491..79136d1 100644 --- a/crates/gpui-query/src/tests/core_select/mod.rs +++ b/crates/gpui-query/src/tests/core_select/mod.rs @@ -102,7 +102,6 @@ fn mapped_resource_update_source_replaces_previous() { #[test] fn mapped_resource_data_applies_transform_lazily() { - let transform = SelectTransform::new(|v: &Vec<i32>| v.len()); let mut mapped: MappedQueryResource<Vec<i32>, usize, ()> = diff --git a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs index 02fb088..ef5e35c 100644 --- a/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs +++ b/crates/gpui-query/src/tests/property_tests/query_key/proptests.rs @@ -134,11 +134,16 @@ proptest! { #![proptest_config(test_config())] #[test] - fn key_to_path_format(segments in arb_key()) { + fn key_to_path_roundtrips_through_from_path(segments in arb_key()) { let key = make_key(&segments); let path = key.to_path(); - let expected = segments.join("::"); - prop_assert_eq!(path, expected); + prop_assert_eq!(QueryKey::from_path(&path), key); + } + + #[test] + fn key_to_path_distinct_for_distinct_keys(a in arb_key(), b in arb_key()) { + prop_assume!(a != b); + prop_assert_ne!(make_key(&a).to_path(), make_key(&b).to_path()); } } From b37de8f45429584a464821aff7644ec0be543238 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 03:33:14 +0200 Subject: [PATCH 096/111] docs: note that mutation cancel clears stored data --- web/src/content/docs/docs/api/mutations.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/content/docs/docs/api/mutations.mdx b/web/src/content/docs/docs/api/mutations.mdx index ca60097..62149c0 100644 --- a/web/src/content/docs/docs/api/mutations.mdx +++ b/web/src/content/docs/docs/api/mutations.mdx @@ -181,7 +181,7 @@ If the entity has a retry policy and retries remain after a failure, the interna **`retry() -> bool`** attempts to transition from `Failure` back to `Loading`. Only succeeds when `should_retry()` is `true` and the current status is `Failure`. Preserves the original variables and creates a fresh signal. Returns `true` if the retry was initiated. -**`cancel(error)`** forces the mutation into `Failure` with the given error. Cancels the signal so any in-flight async work can observe it. +**`cancel(error)`** forces the mutation into `Failure` with the given error. Clears any stored data and cancels the signal so in-flight async work can observe it. **`reset()`** returns the resource to `Idle` and clears everything: data, error, variables, retry count, signal, and timestamp. From 73138557205d01079d0f7b068b3cfe1e935da48b Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 03:33:14 +0200 Subject: [PATCH 097/111] chore: ignore .zcode workflow drafts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index afba9ee..82b90d6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ Thumbs.db .wrangler/cache .astro +.zcode/ From 9d91b29926d942433ec76f30158960760d5ceb7f Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 04:10:01 +0200 Subject: [PATCH 098/111] fix: stop prepared fetch from binding a foreign in-flight request --- crates/gpui-query/src/client/lifecycle.rs | 19 ++++++--- .../fetch_prefetch_cancel.rs | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/gpui-query/src/client/lifecycle.rs b/crates/gpui-query/src/client/lifecycle.rs index 8bd4620..71d2b13 100644 --- a/crates/gpui-query/src/client/lifecycle.rs +++ b/crates/gpui-query/src/client/lifecycle.rs @@ -178,7 +178,8 @@ impl QueryClient { /// Imperative fetch (TanStack `fetchQuery`): no observer is attached; /// the caller runs the fetcher and completes the request via - /// `complete_success` / `complete_failure`. + /// `complete_success` / `complete_failure`. Returns `None` when the + /// request policy ignored the start, leaving the in-flight fetcher in place. /// /// # Example /// @@ -218,14 +219,20 @@ impl QueryClient { ); let (request_id, signal) = entity.update(cx, |resource, _| { - let _ = resource.begin_request_with_id( + match resource.begin_request_with_id( Some(request_id), now_ms, crate::core::QueryFetchMode::Force, - ); - let rid = resource.active_request_id()?; - let signal = resource.signal().cloned()?; - Some((rid, signal)) + ) { + // Force still defers to IgnoreWhileLoading: the still-active id + // belongs to a foreign fetcher, so binding would duplicate its completion. + crate::core::QueryBeginResult::Started { .. } => { + let rid = resource.active_request_id()?; + let signal = resource.signal().cloned()?; + Some((rid, signal)) + } + _ => None, + } })?; Some(PreparedFetch { diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs index b500639..d695be7 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs @@ -32,6 +32,48 @@ fn test_prepare_fetch_query_uses_force_mode_always_starts(cx: &mut TestAppContex }); } +#[gpui::test] +fn test_prepare_fetch_query_ignored_while_loading_returns_none(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(QueryClient::with_policies( + CachePolicy::NoCache, + RequestPolicy::IgnoreWhileLoading, + )); + }); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("pf_ignore_foreign"); + let entity = client.resource::<String, QueryError>(key.clone(), cx); + let rid = client + .next_request_id_for_key::<String, QueryError>(&key) + .expect("rid"); + entity.update(cx, |r, _| { + let _ = r.begin_request_with_id(Some(rid), 1_000, QueryFetchMode::Normal); + }); + + let prepared = client.prepare_fetch_query::<String, QueryError>(key.clone(), cx); + assert!( + prepared.is_none(), + "IgnoreWhileLoading with a request in flight must not hand out a prepared fetch" + ); + assert!( + entity.read(cx).is_current_request(rid), + "the original in-flight request must still own the resource" + ); + + let accepted = entity.update(cx, |r, _| { + r.complete_current_success(rid, "original".to_string(), 2_000) + }); + assert!( + accepted, + "the original fetcher's completion must still be accepted" + ); + let data = client.get_query_data::<String, QueryError>(&key, cx); + assert_eq!(data, Some("original".to_string())); + }); + }); +} + #[gpui::test] fn test_prepare_fetch_query_refetch_after_ttl(cx: &mut TestAppContext) { cx.update(|cx| { From d6479e265b8702fb76b586c25135df47c0d388a4 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 04:10:01 +0200 Subject: [PATCH 099/111] fix: refresh bucket baselines on invalidate and reset --- crates/gpui-query/src/client/bucket/shared.rs | 20 ++++-- .../invalidation_reset_gc.rs | 64 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 3ded978..5ea789d 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -251,11 +251,8 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { (entity, request_id) } - /// Collected entries are age-zero candidates so they evict before any - /// live entry; the winner gets one confirming entity read (the mirror - /// can be stale if a fetch began after the last refresh). Each retry - /// marks the stale mirror and re-picks; a dead winner is removed in - /// place by the failed confirm. + /// Dead entries are age-zero candidates; the mirror can be stale, so the + /// winner is confirmed with one entity read. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self @@ -346,12 +343,25 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { entity.update(cx, |resource, _| resource.resource_invalidate()); } }); + self.touch_matching(filter); } pub(crate) fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_matching_entry(filter, cx, |entity, cx| { entity.update(cx, |resource, _| resource.resource_reset()); }); + self.touch_matching(filter); + } + + /// Invalidated/reset resources lose their own `last_updated_at`, so the + /// user action must restart the GC age baseline or live entries are evicted. + fn touch_matching(&mut self, filter: &QueryKeyFilter) { + let now_ms = current_time_ms(); + for (key, entry) in self.entries.iter_mut() { + if filter.matches(key) { + entry.updated_at = now_ms; + } + } } /// Gates on the authoritative `is_loading()` read: the entry mirror diff --git a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs index ffa66d2..8a47e81 100644 --- a/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs +++ b/crates/gpui-query/src/tests/integration_client/invalidation_reset_gc.rs @@ -456,3 +456,67 @@ fn test_gc_boundary_success_threshold_exact(cx: &mut TestAppContext) { }); }); } + +#[gpui::test] +fn test_gc_preserves_invalidated_entry_with_fresh_baseline(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc/invalidated_baseline"); + let held = client.resource::<String, QueryError>(key.clone(), cx); + held.update(cx, |r, _| r.apply_success("v".to_string(), 1_000)); + + let type_id = std::any::TypeId::of::<(String, QueryError)>(); + let bucket = client.buckets.get_mut(&type_id).unwrap(); + let typed = bucket + .as_any_mut() + .downcast_mut::<crate::client::QueryBucket<String, QueryError>>() + .unwrap(); + typed.inner.entries.get_mut(&key).unwrap().updated_at = 1_000; + + client.invalidate_queries(&QueryKeyFilter::Exact(&key), cx); + + client.gc_with_time(3_000, cx); + + assert!( + client.query::<String, QueryError>(&key).is_some(), + "invalidated entry held by a live component must survive GC on a fresh baseline" + ); + let again = client.resource::<String, QueryError>(key, cx); + assert_eq!( + again, held, + "resource() must not mint a second entity after GC" + ); + }); + }); +} + +#[gpui::test] +fn test_gc_preserves_reset_entry_with_fresh_baseline(cx: &mut TestAppContext) { + setup_query_client_with_gc(cx, 1_000); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("gc/reset_baseline"); + let held = client.resource::<String, QueryError>(key.clone(), cx); + held.update(cx, |r, _| r.apply_success("v".to_string(), 1_000)); + + let type_id = std::any::TypeId::of::<(String, QueryError)>(); + let bucket = client.buckets.get_mut(&type_id).unwrap(); + let typed = bucket + .as_any_mut() + .downcast_mut::<crate::client::QueryBucket<String, QueryError>>() + .unwrap(); + typed.inner.entries.get_mut(&key).unwrap().updated_at = 1_000; + + client.reset_queries(&QueryKeyFilter::Exact(&key), cx); + + client.gc_with_time(3_000, cx); + + assert!( + client.query::<String, QueryError>(&key).is_some(), + "reset entry held by a live component must survive GC on a fresh baseline" + ); + assert_eq!(held.read(cx).status(), QueryStatus::Idle); + }); + }); +} From 5527387de252bacbc2117e4bbf4006aa141f9199 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 04:10:01 +0200 Subject: [PATCH 100/111] fix: serialize persist saves in flush order --- crates/gpui-query/src/client/persist.rs | 118 ++++++++------- .../client_operations/persist_with_hydrate.rs | 134 ++++++++++++++++++ 2 files changed, 204 insertions(+), 48 deletions(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index feef75c..0ac35e8 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -301,7 +301,9 @@ impl QueryClient { /// Saves carry the full accumulated store, but only dirty entries (data /// epoch changed since the last flush) are re-serialized; entries /// removed from the cache are pruned. A flush with no dirty entries and - /// no prunes does not save at all. + /// no prunes does not save at all. Saves run one at a time: a flush + /// landing while a save is in flight queues a follow-up flush, so + /// snapshots reach the persister in flush order. pub fn persist_with<P: Persister>( &self, persister: P, @@ -315,6 +317,8 @@ impl QueryClient { let flush_state = Arc::new(Mutex::new(PersistFlushState { flushed: HashMap::new(), store: Arc::new(PersistSnapshot::new()), + save_in_flight: false, + save_queued: false, })); let _ = cx.default_global::<super::CacheMutation>(); @@ -340,56 +344,71 @@ impl QueryClient { } // Disarm before collecting: a bump landing now arms a fresh task. armed.store(false, Ordering::Release); - let delta = cx.update_global::<QueryClient, _>(|client, cx| { - let Ok(state) = flush_state.lock() else { - return None; + loop { + let delta = cx.update_global::<QueryClient, _>(|client, cx| { + let Ok(state) = flush_state.lock() else { + return None; + }; + Some(client.collect_persist_delta(&filter, max_age, &state.flushed, cx)) + }); + let Some(delta) = delta.ok().flatten() else { + return; }; - Some(client.collect_persist_delta(&filter, max_age, &state.flushed, cx)) - }); - let Some(delta) = delta.ok().flatten() else { - return; - }; - let Ok(mut state) = flush_state.lock() else { - return; - }; - let live: HashSet<String> = delta - .fresh - .iter() - .map(|c| c.path.clone()) - .chain(delta.reused.iter().cloned()) - .collect(); - let prune = state.store.entries.keys().any(|path| !live.contains(path)); - if delta.fresh.is_empty() && !prune { - return; - } - // Clone-on-write only while a previous save is still in flight. - let fresh_epochs: Vec<(String, (gpui::EntityId, u64))> = delta - .fresh - .iter() - .map(|c| (c.path.clone(), (c.entity_id, c.epoch))) - .collect(); - { - let snapshot = Arc::make_mut(&mut state.store); - if prune { - snapshot.entries.retain(|path, _| live.contains(path)); + // Collect on the main thread (entity reads), save on background (IO). + let out = { + let Ok(mut state) = flush_state.lock() else { + return; + }; + let live: HashSet<String> = delta + .fresh + .iter() + .map(|c| c.path.clone()) + .chain(delta.reused.iter().cloned()) + .collect(); + let prune = state.store.entries.keys().any(|path| !live.contains(path)); + if delta.fresh.is_empty() && !prune { + return; + } + if state.save_in_flight { + // The in-flight owner re-collects after its save, keeping saves ordered. + state.save_queued = true; + return; + } + let fresh_epochs: Vec<(String, (gpui::EntityId, u64))> = delta + .fresh + .iter() + .map(|c| (c.path.clone(), (c.entity_id, c.epoch))) + .collect(); + { + let snapshot = Arc::make_mut(&mut state.store); + if prune { + snapshot.entries.retain(|path, _| live.contains(path)); + } + for collected in delta.fresh { + snapshot.entries.insert(collected.path, collected.entry); + } + } + if prune { + state.flushed.retain(|path, _| live.contains(path)); + } + state.flushed.extend(fresh_epochs); + state.save_in_flight = true; + state.store.clone() + }; + let persister = persister.clone(); + let result = bg.spawn(async move { persister.save(&out).await }).await; + let Ok(mut state) = flush_state.lock() else { + return; + }; + state.save_in_flight = false; + if let Err(err) = result { + eprintln!("persist_with: save failed: {}", save_failure_log_text(&err)); } - for collected in delta.fresh { - snapshot.entries.insert(collected.path, collected.entry); + if !state.save_queued { + return; } + state.save_queued = false; } - if prune { - state.flushed.retain(|path, _| live.contains(path)); - } - state.flushed.extend(fresh_epochs); - let out = state.store.clone(); - drop(state); - // Collect on the main thread (entity reads), save on background (IO). - bg.spawn(async move { - if let Err(err) = persister.save(&out).await { - eprintln!("persist_with: save failed: {}", save_failure_log_text(&err)); - } - }) - .detach(); }) .detach(); }) @@ -403,10 +422,13 @@ impl QueryClient { /// Per-driver flush state: the owning entity id and data epoch each path was /// last flushed at, plus the full store as of the last save. Main-thread -/// only; the mutex guards the handoff of an in-flight save's snapshot. +/// only; the mutex guards the save handoff and the in-flight/queued pair +/// that keeps saves ordered. struct PersistFlushState { flushed: HashMap<String, (gpui::EntityId, u64)>, store: Arc<PersistSnapshot>, + save_in_flight: bool, + save_queued: bool, } /// Persister errors can embed payload or path detail (custom `Deserialize` diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs index 62970f4..38b2c8f 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/persist_with_hydrate.rs @@ -1,6 +1,8 @@ +use std::future::Future; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::task::{Poll, Waker}; use std::time::Duration; use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; @@ -776,6 +778,138 @@ fn test_persist_with_debounce_coalesces(cx: &mut TestAppContext) { let _ = harness; } +#[derive(Clone, Default)] +struct FlushGate { + inner: Arc<StdMutex<FlushGateInner>>, +} + +#[derive(Default)] +struct FlushGateInner { + released: bool, + wakers: Vec<Waker>, +} + +impl FlushGate { + fn release(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.released = true; + for waker in inner.wakers.drain(..) { + waker.wake(); + } + } + + fn wait(&self) -> impl Future<Output = ()> + Send { + let inner = self.inner.clone(); + async move { + std::future::poll_fn(move |cx| { + let mut guard = inner.lock().unwrap(); + if guard.released { + return Poll::Ready(()); + } + if !guard.wakers.iter().any(|w| w.will_wake(cx.waker())) { + guard.wakers.push(cx.waker().clone()); + } + Poll::Pending + }) + .await + } + } +} + +#[derive(Clone)] +struct GatedPersister { + gate: FlushGate, + events: Arc<StdMutex<Vec<String>>>, +} + +impl Persister for GatedPersister { + async fn load(&self) -> Result<PersistSnapshot, PersistError> { + Ok(PersistSnapshot::new()) + } + + async fn save(&self, snapshot: &PersistSnapshot) -> Result<(), PersistError> { + let value = snapshot + .entries + .get("gated") + .and_then(|e| e.value.as_str()) + .unwrap_or("?") + .to_string(); + self.events.lock().unwrap().push(format!("start:{value}")); + self.gate.wait().await; + self.events.lock().unwrap().push(format!("end:{value}")); + Ok(()) + } +} + +#[gpui::test] +fn flush_while_save_in_flight_queues_and_saves_in_order(cx: &mut TestAppContext) { + setup_query_client(cx); + let gate = FlushGate::default(); + let persister = GatedPersister { + gate: gate.clone(), + events: Arc::new(StdMutex::new(Vec::new())), + }; + let events = persister.events.clone(); + + struct H { + _entity: Entity<QueryResource<String, QueryError>>, + _handle: PersistHandle, + } + let harness = cx.new(|cx| { + let (handle, entity) = cx.update_global::<QueryClient, _>(|client, cx| { + client.register_serializer::<String, QueryError>(ser_string); + let handle = client.persist_with(persister, zero_debounce(), cx); + let entity = client.resource::<String, QueryError>(QueryKey::from("gated"), cx); + (handle, entity) + }); + H { + _entity: entity, + _handle: handle, + } + }); + + cx.update(|cx| { + let entity = harness.read_with(cx, |h, _| h._entity.clone()); + cx.update_global::<QueryClient, _>(|_client, cx| { + entity.update(cx, |r, _| { + r.apply_success("v1".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + events.lock().unwrap().as_slice(), + ["start:v1"], + "the first save must be started and blocked on the gate" + ); + + cx.update(|cx| { + let entity = harness.read_with(cx, |h, _| h._entity.clone()); + cx.update_global::<QueryClient, _>(|_client, cx| { + entity.update(cx, |r, _| { + r.apply_success("v2".to_string(), crate::client::current_time_ms()) + }); + cx.default_global::<CacheMutation>(); + }); + }); + cx.run_until_parked(); + assert_eq!( + events.lock().unwrap().as_slice(), + ["start:v1"], + "a flush while a save is in flight must queue, not start a second save" + ); + + gate.release(); + cx.run_until_parked(); + assert_eq!( + events.lock().unwrap().as_slice(), + ["start:v1", "end:v1", "start:v2", "end:v2"], + "the queued flush must save the newer snapshot after the in-flight save completes" + ); + let _ = harness; +} + fn block_on_ready<R>(fut: impl std::future::Future<Output = R>) -> R { use std::future::Future; use std::pin::Pin; From b58b7ee1357a2fa6b8233f4ddb166a02b6220ae0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 04:10:01 +0200 Subject: [PATCH 101/111] docs: correct rollback guides and campaign comments --- crates/gpui-query/src/client/bucket/types.rs | 7 ++--- .../gpui-query/src/client/mutation_bucket.rs | 12 +++------ crates/gpui-query/src/hook/fetch_retry.rs | 2 +- .../content/docs/docs/api/query-client.mdx | 26 +++++++++---------- web/src/content/docs/docs/guides/caching.mdx | 17 ++++++++---- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/types.rs b/crates/gpui-query/src/client/bucket/types.rs index 7766f14..21ec3c7 100644 --- a/crates/gpui-query/src/client/bucket/types.rs +++ b/crates/gpui-query/src/client/bucket/types.rs @@ -14,11 +14,8 @@ pub(crate) const DEFAULT_MAX_ENTRIES: usize = 10_000; /// `gc_time_ms`. pub(crate) const SUCCESS_GC_MULTIPLIER: u32 = 2; -/// `updated_at` is the bucket's recency baseline for entities that have no -/// completion timestamp of their own; `last_updated_ms` / `loading` mirror -/// the entity, refreshed wherever the bucket already reads it, so -/// `evict_oldest` scans cheap fields and confirms its winner with a single -/// entity read. +/// `updated_at` is the recency baseline when the resource has no completion +/// timestamp; the mirrors are refreshed wherever the bucket already reads the entity. pub(crate) struct BucketEntry<R> { pub entity: WeakEntity<R>, pub sequencer: RequestSequencer, diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 7bc5e84..9c9a744 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -11,9 +11,8 @@ use super::ErasedMutationBucket; use super::bucket::types::{DEFAULT_MAX_ENTRIES, MIN_GC_TIME_MS, SUCCESS_GC_MULTIPLIER}; use super::devtools::MutationDiagnostic; -/// `last_updated_ms` / `loading` mirror the entity, refreshed wherever the -/// bucket already reads it, so `evict_oldest` scans cheap fields and -/// confirms its winner with a single entity read. +/// Mirrors refreshed wherever the bucket already reads the entity, so +/// eviction scans cheap fields and confirms its winner with one entity read. struct MutationEntry<V, T, E> { entity: WeakEntity<MutationResource<V, T, E>>, updated_at: u64, @@ -42,11 +41,8 @@ impl< } } - /// Collected entries are age-zero candidates so they evict before any - /// live entry; the winner gets one confirming entity read (the mirror - /// can be stale if a fetch began after the last refresh), and each - /// stale re-check marks the mirror and re-picks. A dead winner is - /// removed in place by the failed confirm. + /// Dead entries are age-zero candidates; the mirror can be stale, so the + /// winner is confirmed with one entity read. pub(crate) fn evict_oldest(&mut self, cx: &App) { loop { let target = self diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index acdd3af..cddc7e9 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -98,7 +98,7 @@ where /// Shared retry loop; with `signal = Some`, a fresh signal is re-read after /// each delay, and the loop stops once a newer request supersedes this one. /// `cx.notify()` fires only on accepted results; retry counters stay in -/// `Loading` (the observer dedupes on status). +/// `Loading` (the observer dedupes on status or data epoch). async fn run_query_retry_loop<T, E, Out, F, Fut>( fetcher: F, request_id: RequestId, diff --git a/web/src/content/docs/docs/api/query-client.mdx b/web/src/content/docs/docs/api/query-client.mdx index 433bb0c..d10a4a8 100644 --- a/web/src/content/docs/docs/api/query-client.mdx +++ b/web/src/content/docs/docs/api/query-client.mdx @@ -131,10 +131,10 @@ Get a clone of the cancellation signal for a resource. Pass this to an async fet ### `set_query_data` ```rust -fn set_query_data<T, E>(&mut self, key: &QueryKey, data: T, cx: &mut App) -> bool +fn set_query_data<T, E>(&mut self, key: impl Into<QueryKey>, data: T, cx: &mut App) ``` -Set data on a resource without completing a request. The previous data is saved internally so you can roll it back. Returns `true` if the resource was found and updated. +Set data on a resource without completing a request. The resource is created if it does not exist, and the previous data is kept so a later write can restore it. Observers are notified and the persist snapshot is marked dirty. Use this for optimistic updates: update the cache immediately, then let the mutation result or a refetch correct it later. @@ -143,29 +143,29 @@ let client = cx.global_mut::<QueryClient>(); // Optimistically add the new user to the list client.set_query_data::<Vec<User>, MyError>( - &users_key, + users_key.clone(), updated_user_list, cx, ); ``` -### `rollback_query_data` +### Rolling back an optimistic update -```rust -fn rollback_query_data<T, E>(&mut self, key: &QueryKey, cx: &mut App) -> bool -``` - -Undo the last optimistic update. Restores the data that was stored before `set_query_data` was called. Returns `true` if there was previous data to restore. - -A typical pattern is to optimistically update, then roll back if the mutation fails: +There is no dedicated rollback method. Capture the previous value with `get_query_data` (returns `None` when no data is stored) before the optimistic write, then restore it with `set_query_data` if the mutation fails: ```rust -client.set_query_data::<Vec<User>, MyError>(&key, optimistic_data, cx); +let client = cx.global_mut::<QueryClient>(); +let previous = client.get_query_data::<Vec<User>, MyError>(&key, cx); +client.set_query_data::<Vec<User>, MyError>(key.clone(), optimistic_data, cx); // ... later, if the mutation fails ... -client.rollback_query_data::<Vec<User>, MyError>(&key, cx); +if let Some(previous) = previous { + client.set_query_data::<Vec<User>, MyError>(key.clone(), previous, cx); +} ``` +`set_query_data` notifies observers, so mounted views re-render with the restored data and the persist driver saves the change. Calling `rollback_to_previous()` on the resource entity restores the same value but does not notify observers. + ## Bulk operations Bulk operations use `QueryKeyFilter` to target resources across all type-partitioned buckets. They work on every `(T, E)` type pair at once. diff --git a/web/src/content/docs/docs/guides/caching.mdx b/web/src/content/docs/docs/guides/caching.mdx index 2b152ee..34009d0 100644 --- a/web/src/content/docs/docs/guides/caching.mdx +++ b/web/src/content/docs/docs/guides/caching.mdx @@ -182,21 +182,28 @@ Prefix matching is hierarchical. `Prefix(&QueryKey::from(["users"]))` matches `[ Sometimes you want to update the UI before the server confirms the change. `set_query_data` writes a value directly into the cache, storing the previous value for rollback. ```rust -let client = cx.global::<QueryClient>(); +let client = cx.global_mut::<QueryClient>(); client.set_query_data::<Vec<User>, MyError>( - &key, + key.clone(), updated_user_list, cx, ); ``` -If the mutation later fails, roll back to the previous data: +There is no dedicated rollback method. Capture the previous value with `get_query_data` before the optimistic write, then restore it if the mutation fails: ```rust -client.rollback_query_data::<Vec<User>, MyError>(&key, cx); +let client = cx.global_mut::<QueryClient>(); +let previous = client.get_query_data::<Vec<User>, MyError>(&key, cx); +client.set_query_data::<Vec<User>, MyError>(key.clone(), optimistic_data, cx); + +// ... later, if the mutation fails ... +if let Some(previous) = previous { + client.set_query_data::<Vec<User>, MyError>(key.clone(), previous, cx); +} ``` -This restores the data that was in the resource before `set_query_data` was called. If there was no previous data, the rollback does nothing and returns `false`. +Restoring through `set_query_data` notifies observers and marks the persist snapshot, so mounted views re-render and the rollback is persisted. Calling `rollback_to_previous()` on the resource entity restores the same value but does not notify observers. ## Garbage collection From b576f4743824d0d3bdecfa38a44353aa508532d0 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 102/111] fix: wake observers on bulk resets and pin observer suppression --- crates/gpui-query/src/client/bucket/shared.rs | 13 +- .../src/tests/hook_tests/regression_tests.rs | 126 +++++++++++++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/crates/gpui-query/src/client/bucket/shared.rs b/crates/gpui-query/src/client/bucket/shared.rs index 5ea789d..e673f97 100644 --- a/crates/gpui-query/src/client/bucket/shared.rs +++ b/crates/gpui-query/src/client/bucket/shared.rs @@ -40,6 +40,7 @@ pub(crate) trait BucketResource { fn resource_retry_count(&self) -> u32; /// Counts data writes; persistence collection skips entries whose epoch /// is unchanged since the last flush. + #[cfg(feature = "persist")] fn resource_data_epoch(&self) -> u64; fn resource_invalidate(&mut self); fn resource_reset(&mut self); @@ -84,6 +85,7 @@ impl<T: 'static, E: 'static> BucketResource for QueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + #[cfg(feature = "persist")] fn resource_data_epoch(&self) -> u64 { self.data_epoch() } @@ -139,6 +141,7 @@ impl<T: 'static, E: 'static> BucketResource for InfiniteQueryResource<T, E> { fn resource_retry_count(&self) -> u32 { self.retry_count() } + #[cfg(feature = "persist")] fn resource_data_epoch(&self) -> u64 { self.data_epoch() } @@ -333,8 +336,9 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { } } - /// `entity.update` notifies observers even when the closure mutates - /// nothing, so bulk ops gate on authoritative reads. + /// Observers fire only from `cx.notify()` inside the update closure, so + /// mutating bulk ops notify and the observer dedup suppresses no-change + /// wakes. pub(crate) fn invalidate_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_matching_entry(filter, cx, |entity, cx| { // invalidate() only clears last_updated_at; skip the no-op update. @@ -348,7 +352,10 @@ impl<R: BucketResource + 'static> ResourceBucket<R> { pub(crate) fn reset_matching(&mut self, filter: &QueryKeyFilter, cx: &mut App) { self.for_each_matching_entry(filter, cx, |entity, cx| { - entity.update(cx, |resource, _| resource.resource_reset()); + entity.update(cx, |resource, cx| { + resource.resource_reset(); + cx.notify(); + }); }); self.touch_matching(filter); } diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index 986ce3a..9040b12 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -6,7 +6,7 @@ use gpui::{AppContext as _, BorrowAppContext as _, Entity, TestAppContext}; use crate::client::QueryClient; use crate::core::{ CachePolicy, InfiniteQueryResource, MutationResource, MutationStatus, QueryError, QueryKey, - QueryResource, RequestPolicy, RetryPolicy, + QueryKeyFilter, QueryResource, QueryStatus, RequestPolicy, RetryPolicy, }; use crate::hook::*; use crate::tests::test_support::*; @@ -558,3 +558,127 @@ fn manual_append_page_reaches_mounted_use_infinite_query_observer(cx: &mut TestA "a same-status manual page write must re-render the mounted infinite consumer" ); } + +#[gpui::test] +fn test_reset_queries_wakes_mounted_observer(cx: &mut TestAppContext) { + setup_test(cx); + + struct H { + entity: Entity<QueryResource<String, QueryError>>, + _sub: gpui::Subscription, + } + + let key = QueryKey::from("reset-queries-notify"); + let harness = cx.new(|cx| { + let (entity, sub) = use_query_manual::<String, QueryError, _>( + key.clone(), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + cx, + ); + H { entity, _sub: sub } + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key.clone(), "v1".to_string(), cx); + }); + }); + cx.run_until_parked(); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.reset_queries(&QueryKeyFilter::Exact(&key), cx); + }); + }); + cx.run_until_parked(); + + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "reset_queries must wake the mounted consumer: it clears data and \ + drops status to Idle, so the observer dedup passes" + ); + cx.update(|cx| { + let resource = harness.read(cx).entity.read(cx); + assert_eq!(resource.status(), QueryStatus::Idle); + assert!(resource.data().is_none(), "reset must clear the data"); + }); +} + +#[gpui::test] +fn test_observer_stays_silent_on_retry_count_only_change(cx: &mut TestAppContext) { + setup_test(cx); + + struct H { + entity: Entity<QueryResource<String, QueryError>>, + _sub: gpui::Subscription, + } + + let key = QueryKey::from("observer-retry-silence"); + let harness = cx.new(|cx| { + let (entity, sub) = use_query_manual::<String, QueryError, _>( + key.clone(), + CachePolicy::NoCache, + RequestPolicy::LatestWins, + cx, + ); + H { entity, _sub: sub } + }); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_observer = hits.clone(); + let _notified = harness.update(cx, |_, cx| { + cx.observe_self(move |_, _| { + hits_for_observer.fetch_add(1, Ordering::SeqCst); + }) + }); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key.clone(), "v1".to_string(), cx); + }); + }); + cx.run_until_parked(); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "precondition: the seeded data change woke the consumer once" + ); + + harness.update(cx, |h, cx| { + h.entity.update(cx, |r, cx| { + r.increment_retry(); + cx.notify(); + }); + }); + cx.run_until_parked(); + + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "a retry-count-only change bumps neither status nor data epoch, so \ + the observer dedup must suppress the wake" + ); + + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + client.set_query_data::<String, QueryError>(key, "v2".to_string(), cx); + }); + }); + cx.run_until_parked(); + + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "a data-epoch change must still wake the mounted consumer" + ); +} From 716e509881be3fd94b744568e82765ce7b5d0ef1 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 103/111] fix: make cancel a first-class terminal mutation transition --- .../gpui-query/src/client/mutation_bucket.rs | 31 +++++ crates/gpui-query/src/core/mutation.rs | 1 + .../src/hook/mutation_hooks/internals.rs | 19 +++- .../src/tests/core_mutation/cancellation.rs | 13 +++ .../mutation_tests/callback_tests.rs | 106 +++++++++++++++++- 5 files changed, 164 insertions(+), 6 deletions(-) diff --git a/crates/gpui-query/src/client/mutation_bucket.rs b/crates/gpui-query/src/client/mutation_bucket.rs index 9c9a744..13259f1 100644 --- a/crates/gpui-query/src/client/mutation_bucket.rs +++ b/crates/gpui-query/src/client/mutation_bucket.rs @@ -193,3 +193,34 @@ impl< } } } + +#[cfg(test)] +mod tests { + use gpui::{AppContext as _, TestAppContext}; + + use crate::core::{MutationResource, QueryError, RetryPolicy}; + + use super::{ErasedMutationBucket, MutationBucket}; + + #[gpui::test] + fn gc_keeps_just_cancelled_entry_with_stale_insertion(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut bucket = MutationBucket::<String, String, QueryError>::new(); + let entity = cx.new(|_| { + MutationResource::<String, String, QueryError>::new(RetryPolicy::no_retries()) + }); + entity.update(cx, |m, _| m.begin("vars".to_string())); + bucket.insert(&entity, 1_000, cx); + + entity.update(cx, |m, _| m.cancel(QueryError::cancelled("user aborted"))); + + bucket.gc(1_001_000, 500_000, cx); + assert_eq!( + bucket.count(), + 1, + "GC must age a cancelled mutation from its completion time, \ + not the insertion baseline" + ); + }); + } +} diff --git a/crates/gpui-query/src/core/mutation.rs b/crates/gpui-query/src/core/mutation.rs index 08df84a..bf32291 100644 --- a/crates/gpui-query/src/core/mutation.rs +++ b/crates/gpui-query/src/core/mutation.rs @@ -251,6 +251,7 @@ impl<V, T, E> MutationResource<V, T, E> { self.status = MutationStatus::Failure; self.data = None; self.error = Some(error); + self.last_updated_at_ms = Some(completion_now_ms()); if let Some(signal) = self.signal.as_ref() { signal.cancel(); } diff --git a/crates/gpui-query/src/hook/mutation_hooks/internals.rs b/crates/gpui-query/src/hook/mutation_hooks/internals.rs index a436507..76bd8de 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/internals.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/internals.rs @@ -37,11 +37,6 @@ pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( match result { Ok(data) => { - let data_for_callback = callbacks - .as_ref() - .is_some_and(needs_data) - .then(|| data.clone()); - let Some(entity) = weak.upgrade() else { if let Some(ref cb) = callbacks && let Some(ref f) = cb.on_settled @@ -50,6 +45,20 @@ pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( } return; }; + + // A cancel/reset while the mutator was awaited leaves a + // terminal state a late Ok must not overwrite. + if !read_entity(&entity, cx, |r, _| r.is_loading()).unwrap_or(false) { + let terminal_error = + read_entity(&entity, cx, |r, _| r.error().cloned()).flatten(); + fire_error_callbacks(&callbacks, &terminal_error); + return; + } + + let data_for_callback = callbacks + .as_ref() + .is_some_and(needs_data) + .then(|| data.clone()); let _ = entity.update(cx, |resource, cx| { resource.complete_success(data); resource.reset_retry_count(); diff --git a/crates/gpui-query/src/tests/core_mutation/cancellation.rs b/crates/gpui-query/src/tests/core_mutation/cancellation.rs index 720e056..e08d9ae 100644 --- a/crates/gpui-query/src/tests/core_mutation/cancellation.rs +++ b/crates/gpui-query/src/tests/core_mutation/cancellation.rs @@ -89,3 +89,16 @@ fn cancelled_count_increments_across_mutations() { m.cancel(QueryError::cancelled("abort 3")); assert_eq!(m.cancelled_count(), 3); } + +#[test] +fn cancel_stamps_last_updated_at_ms() { + let mut m: MutationResource<&'static str, i32> = + MutationResource::new(RetryPolicy::no_retries()); + m.begin("vars"); + assert!(m.last_updated_at_ms().is_none()); + m.cancel(QueryError::cancelled("user aborted")); + assert!( + m.last_updated_at_ms().is_some(), + "cancel is a terminal completion and must refresh GC recency" + ); +} diff --git a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs index f90c2e0..e3ead8e 100644 --- a/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/mutation_tests/callback_tests.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex}; use gpui::{AppContext as _, Entity, TestAppContext}; -use crate::core::{MutationResource, QueryError}; +use crate::core::{MutationResource, MutationStatus, QueryError}; use crate::hook::*; use crate::tests::test_support::*; @@ -210,3 +210,107 @@ fn test_mutate_callbacks_settled_always_fires_on_failure(cx: &mut TestAppContext "on_settled must fire on failure even without on_error callback" ); } + +#[gpui::test] +fn test_mutate_late_ok_after_cancel_keeps_failure(cx: &mut TestAppContext) { + setup_query_client(cx); + + let success_fired = Arc::new(Mutex::new(false)); + let error_msg = Arc::new(Mutex::new(String::new())); + let settled_data = Arc::new(Mutex::new(None::<String>)); + let settled_err = Arc::new(Mutex::new(None::<String>)); + + let sf = success_fired.clone(); + let em = error_msg.clone(); + let sd = settled_data.clone(); + let se = settled_err.clone(); + + let gate = Gate::new(); + let gate_for_mutator = gate.clone(); + let executor = cx.background_executor.clone(); + + #[allow(dead_code)] + struct H { + mutation: Entity<MutationResource<String, String, QueryError>>, + } + + let harness = cx.new(|cx| { + let (entity, _sub) = + use_mutation::<String, String, QueryError, _>(no_retry_mutation_options(), cx); + let gate_for_mutator = gate_for_mutator.clone(); + let executor = executor.clone(); + mutate_with_callbacks( + &entity, + "cancel-late-ok".to_string(), + move |_| { + let gate_for_mutator = gate_for_mutator.clone(); + let executor = executor.clone(); + async move { + gate_for_mutator.wait(&executor).await; + Ok::<_, QueryError>("late-success".to_string()) + } + }, + MutationCallbacks::<String, QueryError>::new() + .on_success(move |_data: &String| { + *sf.lock().unwrap() = true; + }) + .on_error(move |err: &QueryError| { + *em.lock().unwrap() = err.to_string(); + }) + .on_settled( + move |opt_data: Option<&String>, opt_err: Option<&QueryError>| { + *sd.lock().unwrap() = opt_data.map(|d| d.to_string()); + *se.lock().unwrap() = opt_err.map(|e| e.to_string()); + }, + ), + cx, + ); + H { mutation: entity } + }); + + cx.update(|cx| { + assert!( + harness.read(cx).mutation.read(cx).is_loading(), + "precondition: mutation Loading while parked on the gate" + ); + }); + + harness.update(cx, |h, cx| { + h.mutation.update(cx, |m, _| { + m.cancel(QueryError::cancelled("user aborted")); + }); + }); + + gate.release(); + cx.run_until_parked(); + + cx.update(|cx| { + let resource = harness.read(cx).mutation.read(cx); + assert_eq!( + resource.status(), + MutationStatus::Failure, + "the cancelled mutation's terminal Failure must not be overwritten \ + by the late Ok" + ); + assert!( + resource.data().is_none(), + "no success data may appear after cancel" + ); + assert!( + error_msg.lock().unwrap().contains("user aborted"), + "on_error fires with the cancel error" + ); + assert!( + !*success_fired.lock().unwrap(), + "on_success must not fire for a cancelled mutation" + ); + assert!( + settled_data.lock().unwrap().is_none(), + "on_settled must not receive the late success data" + ); + assert!( + settled_err.lock().unwrap().is_some(), + "on_settled receives the cancel error" + ); + }); +} From 79a80c1555ace3d5ceee96ab304ad89fa8eebf1c Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 104/111] fix: bound email redaction scan on adversarial input --- crates/gpui-query/src/core/error/sanitize.rs | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/gpui-query/src/core/error/sanitize.rs b/crates/gpui-query/src/core/error/sanitize.rs index 2b592a8..52c2081 100644 --- a/crates/gpui-query/src/core/error/sanitize.rs +++ b/crates/gpui-query/src/core/error/sanitize.rs @@ -194,6 +194,18 @@ fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { if let Some(email_end) = try_match_email(&chars, i) { result.push_str("[REDACTED_EMAIL]"); i = email_end; + } else if chars[i].is_alphanumeric() || chars[i] == '_' { + // The failed candidate scanned a local run whose every interior + // start fails identically, so resume at the run's first + // non-local char instead of re-walking it one char at a time. + let mut resume = i; + while resume < len && is_email_local(chars[resume]) { + resume += 1; + } + for c in &chars[i..resume] { + result.push(*c); + } + i = resume; } else { result.push(chars[i]); i += 1; @@ -202,6 +214,10 @@ fn redact_emails(input: Cow<'_, str>) -> Cow<'_, str> { result.into() } +fn is_email_local(c: char) -> bool { + c.is_alphanumeric() || "_.%+-".contains(c) +} + /// TLD contract: >= 2 chars, all-alphanumeric, letter-first or >= 2 letters (`c0m`/`c0`/`0rg` redact; `2x`, `1.2.10` pass); the TLD slice is bounded by the last domain dot, falling back to `@` for dotless domains (`user@intranet` redacts), and a trailing FQDN dot is trimmed for the slice but stays inside the redaction. fn try_match_email(chars: &[char], start: usize) -> Option<usize> { let len = chars.len(); @@ -463,4 +479,23 @@ mod tests { assert!(out.contains("[REDACTED_CONNECTION]")); assert!(out.contains("[REDACTED_PATH]")); } + + #[test] + fn sanitize_message_stays_linear_on_large_failed_email_local_run() { + let local = "a".repeat(64 * 1024); + let msg = format!("{local}@x!"); + let start = std::time::Instant::now(); + let out = sanitize_message(&msg); + assert!( + start.elapsed().as_secs_f64() < 1.0, + "sanitizing a 64KiB failed-candidate local part took {:?}", + start.elapsed() + ); + assert!(!out.contains("[REDACTED_EMAIL]")); + let direct = redact_emails(Cow::Borrowed(msg.as_str())); + assert_eq!( + direct, msg, + "a failed-candidate local run must pass through unchanged" + ); + } } From a7bb655194513f4315a5de8fd62694272752d788 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 105/111] chore: pin the prefetch stale-active guard --- .../fetch_prefetch_cancel.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs index d695be7..a331f41 100644 --- a/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs +++ b/crates/gpui-query/src/tests/integration_client_coverage/client_operations/fetch_prefetch_cancel.rs @@ -139,6 +139,60 @@ fn test_prepare_prefetch_query_returns_none_for_fresh(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_prepare_prefetch_query_returns_none_while_revalidate_in_flight(cx: &mut TestAppContext) { + setup_query_client(cx); + cx.update(|cx| { + cx.update_global::<QueryClient, _>(|client, cx| { + let key = QueryKey::from("prefetch_stale_active"); + let cache_policy = CachePolicy::StaleWhileRevalidate { + ttl_ms: 1_000, + stale_ms: 60_000, + }; + let now = crate::client::current_time_ms(); + let cached_at = now.saturating_sub(31_000); + let entity = client.resource_with_policies::<String, QueryError>( + key.clone(), + cache_policy, + RequestPolicy::IgnoreWhileLoading, + cx, + ); + entity.update(cx, |r, _| r.apply_success("stale".to_string(), cached_at)); + + let rid = client + .next_request_id_for_key::<String, QueryError>(&key) + .expect("rid"); + entity.update(cx, |r, _| { + let _ = r.begin_request_with_id(Some(rid), now, QueryFetchMode::Normal); + }); + assert!( + entity.read(cx).is_current_request(rid), + "precondition: a revalidate must be in flight over the stale data" + ); + + let prepared = client.prepare_prefetch_query::<String, QueryError>( + key.clone(), + cache_policy, + RequestPolicy::IgnoreWhileLoading, + cx, + ); + assert!( + prepared.is_none(), + "IgnoreWhileLoading handing back the still-active id means a \ + revalidate is already running; prefetch must return None" + ); + assert!( + entity.read(cx).is_current_request(rid), + "the in-flight revalidate must still own the resource" + ); + assert!( + !entity.read(cx).signal().unwrap().is_cancelled(), + "the duplicate prefetch must not cancel the active revalidate" + ); + }); + }); +} + #[gpui::test] fn test_prepared_fetch_complete_failure_stores_error(cx: &mut TestAppContext) { setup_query_client(cx); From bba711d40a37b745b7a8ad2fead0320c6a002710 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 106/111] chore: gate key path helper to persist builds --- crates/gpui-query/src/core/key.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-query/src/core/key.rs b/crates/gpui-query/src/core/key.rs index 5cbdf49..f4d500c 100644 --- a/crates/gpui-query/src/core/key.rs +++ b/crates/gpui-query/src/core/key.rs @@ -88,7 +88,7 @@ impl QueryKey { /// Inverse of [`to_path`](Self::to_path); any input yields at least one /// segment and never panics. - #[cfg(any(feature = "client", test))] + #[cfg(any(feature = "persist", test))] pub(crate) fn from_path(path: &str) -> Self { let mut segments: Vec<String> = Vec::new(); let mut current = String::new(); From 001859a3eaa64b51d961023df8f9d6f6cf7c18bf Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 10:46:17 +0200 Subject: [PATCH 107/111] docs: align the mutations page with the real api --- web/src/content/docs/docs/api/mutations.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/content/docs/docs/api/mutations.mdx b/web/src/content/docs/docs/api/mutations.mdx index 62149c0..3d3062d 100644 --- a/web/src/content/docs/docs/api/mutations.mdx +++ b/web/src/content/docs/docs/api/mutations.mdx @@ -146,7 +146,7 @@ The core state container for a single mutation. Three type parameters: Every mutation goes through these states in order: 1. **Idle**: initial state. No mutation has been triggered. -2. **Loading**: `begin(variables, now_ms)` was called. The variables are stored, the error is cleared, and a fresh cancellation signal is created. +2. **Loading**: `begin(variables)` was called. The variables are stored, the error is cleared, and a fresh cancellation signal is created. 3. **Success** or **Failure**: the async task completes. `complete_success(data)` stores the result and clears the error. `complete_failure(error)` stores the error and increments the retry counter. If the entity has a retry policy and retries remain after a failure, the internal loop calls `retry()` to transition back to `Loading` and runs the mutator again. @@ -160,8 +160,9 @@ If the entity has a retry policy and retries remain after a failure, the interna | `error()` | `Option<&E>` | Most recent error | | `variables()` | `Option<&V>` | Current or most recent variables | | `retry_count()` | `u32` | How many retries have been attempted | +| `cancelled_count()` | `u64` | How many times this mutation has been cancelled | | `retry_policy()` | `&RetryPolicy` | The retry policy for this resource | -| `created_at()` | `u64` | Timestamp (ms) of the last `begin` call, or 0 | +| `set_retry_policy(policy)` | `()` | Replaces the retry policy for this resource | | `signal()` | `Option<&QuerySignal>` | The cancellation signal, if the mutation is in flight | | `key()` | `Option<&QueryKey>` | Optional query key for cache correlation | @@ -171,7 +172,7 @@ If the entity has a retry policy and retries remain after a failure, the interna ### Methods -**`begin(variables, now_ms)`** transitions to `Loading`. Stores the variables, clears any previous error, records the timestamp, and creates a fresh `QuerySignal`. +**`begin(variables)`** transitions to `Loading`. Stores the variables, clears any previous error and the GC timestamp, resets the retry count, and creates a fresh `QuerySignal`. **`complete_success(data)`** transitions to `Success`. Stores the result data and clears the signal. From 1c36b349d9a6b682d9af4aab0cb496e1352697b9 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@gmail.com> Date: Fri, 2 Oct 2026 21:07:55 +0200 Subject: [PATCH 108/111] chore: release v0.3.0 --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd8a13..67d7e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-10-02 + +> A campaign pass over the query lifecycle: direct cache writes now reach the UI, GC and eviction stop dropping live entries, request ids survive bucket churn, error redaction covers the connection strings it missed, and persistence gains dirty tracking, ordered saves, and collision-free key paths. + +### Fixed + +#### `gpui-query` — direct cache writes reach mounted observers + +- `set_query_data`, `PreparedFetch::complete`, and `hydrate` mutated resources without `cx.notify()`, so mounted `use_query`/`use_query_select` consumers never re-rendered on the documented optimistic-update and prefetch flows. They notify now, the observer dedup wakes on data-only writes to an already-`Success` entry, and `reset_queries` wakes its observers too. + +#### `gpui-query` — GC and eviction keep live entries + +- GC aged every resource without a completion timestamp as fully expired, so live never-fetched entries and `set_query_data`/hydrate-primed keys were dropped at the next sweep and a later `resource()` call minted a divergent second entity for the same key. Entries carry an insertion baseline now, and `invalidate`/`reset` refresh it. `evict_oldest` no longer pays a weak-upgrade scan per insert at the 10,000-entry cap (about 900x the normal insert cost before) and evicts dead entries first. Cancelled mutations are GC-stamped. +- After `remove_queries` dropped a bucket entry while a fetch was in flight, the resource's transient sequencer minted fallback ids in the same space as bucket ids, so a stale completion could pass `accept_current_request` and discard the fresh result. Fallback ids draw from a reserved scope now. Under StaleWhileRevalidate + `IgnoreWhileLoading`, revalidation also spawned a duplicate fetcher racing the original for one request id; that case is ignored at the hook, prefetch, and prepared-fetch sites. + +#### `gpui-query` — mutation cancel is a terminal transition + +- `MutationResource::cancel` clears stale success data beside the `Failure` status, matching what `complete_failure` always documented, and a late `Ok` from the mutation future can no longer overwrite a terminal `Failure`. + +#### `gpui-query` — redaction covers the schemes and shapes it missed + +- `sanitized()` matched only four exact scheme spellings, so `postgresql://`, `rediss://`, `mongodb+srv://`, `mysql2://`, `amqp://`, and `mssql://` connection strings leaked credentials verbatim; `PATH_NEEDLES` used forward slashes only, so Windows-style paths leaked entirely; and dotted-local-part + dotless-domain emails (`j.smith@intranet`) escaped the email pass. All three are covered, and the connection and email passes are linear on adversarial input (the email path measured ~895 ms at 32 KiB before; output is byte-identical on all prior inputs). + +#### `gpui-query` — persistence + +- `QueryKey::to_path` was not injective: `["a::", ""]` and `["a", "::"]` both produced `"a::::"`, so two live queries silently overwrote each other in the snapshot and re-hydrated as a key matching neither origin. The path format escapes the separator and `PERSIST_VERSION` is bumped: old snapshots are discarded rather than misread, so expect one cold cache after upgrading if you persist. +- Every debounce flush re-serialized the entire cache on the UI thread regardless of what changed. Flushes are dirty-tracked by a data epoch, unchanged entries reuse their stored payloads, removals are pruned, saves run strictly in order, and `Persister::save` still receives the full accumulated store. + +### Changed + +- `QueryResource` exposes an additive `data_epoch()`; `use_query_select` compares epochs instead of deep-comparing `T` on every notification and clones only on real change (the compare was ~93% of per-notify cost on a 1.6 MB payload). The `T: PartialEq` hook bound is unchanged. +- Criterion benches (sanitize, key path, request policy, request id, persist round-trip) land as dev-dependency tooling with a recorded baseline for regression gating; nothing ships to consumers. +- The docs match the shipped API: the rollback guides use the real `set_query_data` capture/restore pattern instead of the nonexistent `rollback_query_data`, and the mutations page documents this crate's `MutationResource` instead of the deprecated legacy crate's. + ## [0.2.2] - 2026-09-21 > Audit-driven fixes across all three crates: a release-profile compile break, wider secret redaction, retry counters that match their docs, RFC 9111 cache refresh, and a durability fix in the file persister. From 27b78cd3104feea5c765c807cc5e275782e5a719 Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:37:13 +0200 Subject: [PATCH 109/111] fix: unwrap AsyncApp::update_global for gpui-pre's plain return gpui-pre 0.3.x hands back the closure's value directly where zed's gpui wraps it in a Result, so the persist flush loop's two-step unwrap fails to compile on the bridge. --- crates/gpui-query/src/client/persist.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gpui-query/src/client/persist.rs b/crates/gpui-query/src/client/persist.rs index 0ac35e8..c09e355 100644 --- a/crates/gpui-query/src/client/persist.rs +++ b/crates/gpui-query/src/client/persist.rs @@ -351,7 +351,9 @@ impl QueryClient { }; Some(client.collect_persist_delta(&filter, max_age, &state.flushed, cx)) }); - let Some(delta) = delta.ok().flatten() else { + // gpui-pre's AsyncApp::update_global returns R directly, + // not the Result zed's gpui wraps it in. + let Some(delta) = delta else { return; }; // Collect on the main thread (entity reads), save on background (IO). From e9e11f2cf22535c4cadc2f102db3b0e069eabc0b Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:45:27 +0200 Subject: [PATCH 110/111] fix: adapt the merged 0.2.2 layer to gpui-pre's gpui API The release line targets zed's gpui where Entity update/read_with hand back Results and Application::new exists; the gpui-pre 0.3.x snapshot returns values directly and renames the constructor. Drop the unit let-bindings, dedupe the release-only AppContext import, and ignore the affected README doctests so the bridge stays CI-green. --- README.md | 4 +++- crates/gpui-query-persist/README.md | 4 +++- crates/gpui-query/README.md | 4 +++- crates/gpui-query/src/hook/fetch_retry.rs | 6 +++--- crates/gpui-query/src/hook/gpui_compat.rs | 2 +- crates/gpui-query/src/hook/mutation_hooks/internals.rs | 8 ++++---- crates/gpui-query/src/hook/query_hooks.rs | 4 +--- .../src/hook/use_infinite_query/fetch_runners.rs | 6 +++--- .../gpui-query/src/tests/hook_tests/regression_tests.rs | 2 +- 9 files changed, 22 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2173ab0..57e4511 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,9 @@ The `core` layer also builds for `wasm32-unknown-unknown`; the wasm-specific set Set up the `QueryClient` as a GPUI global when your app starts: -```rust,no_run +```rust,ignore +// `ignore` on the gpui-pre bridge only: the snapshot renames +// Application::new to with_platform, which would not read upstream. use gpui::Application; # use gpui::BorrowAppContext; use gpui_query::QueryClient; diff --git a/crates/gpui-query-persist/README.md b/crates/gpui-query-persist/README.md index 696ddce..416c46c 100644 --- a/crates/gpui-query-persist/README.md +++ b/crates/gpui-query-persist/README.md @@ -27,7 +27,9 @@ The crate pulls in [gpui-query](https://crates.io/crates/gpui-query) with the `p Hand a `FilePersister` to `QueryClient::persist_with` when your app starts. Keep the returned `PersistHandle` alive for as long as you want saves to continue. -```rust,no_run +```rust,ignore +// `ignore` on the gpui-pre bridge only: the snapshot renames +// Application::new to with_platform, which would not read upstream. use gpui::Application; # use gpui::BorrowAppContext; use gpui_query::client::{PersistOptions, QueryClient}; diff --git a/crates/gpui-query/README.md b/crates/gpui-query/README.md index 426c244..d734f70 100644 --- a/crates/gpui-query/README.md +++ b/crates/gpui-query/README.md @@ -33,7 +33,9 @@ The `core` layer also builds for `wasm32-unknown-unknown`: the crate swaps ahash Set up a `QueryClient` as a GPUI global when your app starts: -```rust,no_run +```rust,ignore +// `ignore` on the gpui-pre bridge only: the snapshot renames +// Application::new to with_platform, which would not read upstream. use gpui::Application; # use gpui::BorrowAppContext; use gpui_query::QueryClient; diff --git a/crates/gpui-query/src/hook/fetch_retry.rs b/crates/gpui-query/src/hook/fetch_retry.rs index cddc7e9..26d5240 100644 --- a/crates/gpui-query/src/hook/fetch_retry.rs +++ b/crates/gpui-query/src/hook/fetch_retry.rs @@ -125,7 +125,7 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( let Some(e) = entity.upgrade() else { return; }; - let _ = e.update(cx, |resource, cx| { + e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.reset_retry_count(); resource.complete_success(guard, parts.data, now_ms); @@ -168,7 +168,7 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( if !request_still_active { return; } - let _ = e.update(cx, |resource, _cx| { + e.update(cx, |resource, _cx| { resource.increment_retry(); }); if let Some(ref mut sig) = signal { @@ -177,7 +177,7 @@ async fn run_query_retry_loop<T, E, Out, F, Fut>( } else { let Some(e) = entity.upgrade() else { return }; let failure_now_ms = current_time_ms(); - let _ = e.update(cx, |resource, cx| { + e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.reset_retry_count(); resource.complete_failure(guard, error, failure_now_ms); diff --git a/crates/gpui-query/src/hook/gpui_compat.rs b/crates/gpui-query/src/hook/gpui_compat.rs index fd14002..80a9ee7 100644 --- a/crates/gpui-query/src/hook/gpui_compat.rs +++ b/crates/gpui-query/src/hook/gpui_compat.rs @@ -10,7 +10,7 @@ pub(crate) fn read_entity<T: 'static, R, C: gpui::AppContext>( f: impl FnOnce(&T, &gpui::App) -> R, ) -> Option<R> { let mut out: Option<R> = None; - let _ = entity.read_with(cx, |value, app| { + entity.read_with(cx, |value, app| { out = Some(f(value, app)); }); out diff --git a/crates/gpui-query/src/hook/mutation_hooks/internals.rs b/crates/gpui-query/src/hook/mutation_hooks/internals.rs index 76bd8de..1da00dc 100644 --- a/crates/gpui-query/src/hook/mutation_hooks/internals.rs +++ b/crates/gpui-query/src/hook/mutation_hooks/internals.rs @@ -59,7 +59,7 @@ pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( .as_ref() .is_some_and(needs_data) .then(|| data.clone()); - let _ = entity.update(cx, |resource, cx| { + entity.update(cx, |resource, cx| { resource.complete_success(data); resource.reset_retry_count(); cx.notify(); @@ -94,7 +94,7 @@ pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( fire_error_callbacks(&callbacks, &error_for_callback); return; }; - let _ = entity.update(cx, |resource, _cx| { + entity.update(cx, |resource, _cx| { resource.increment_retry(); }); @@ -113,14 +113,14 @@ pub(super) async fn run_mutation_loop<V, T, E, F, Fut>( return; } - let _ = entity.update(cx, |resource, _cx| { + entity.update(cx, |resource, _cx| { resource.prepare_retry(); }); attempt += 1; } else { if let Some(entity) = weak.upgrade() { - let _ = entity.update(cx, |resource, cx| { + entity.update(cx, |resource, cx| { resource.complete_failure(error); resource.reset_retry_count(); cx.notify(); diff --git a/crates/gpui-query/src/hook/query_hooks.rs b/crates/gpui-query/src/hook/query_hooks.rs index d3a0d61..62506b1 100644 --- a/crates/gpui-query/src/hook/query_hooks.rs +++ b/crates/gpui-query/src/hook/query_hooks.rs @@ -2,8 +2,6 @@ //! two-phase `accept_current_request` protocol, and each task holds only a //! `WeakEntity`, so it self-terminates on entity drop. -#[cfg(not(debug_assertions))] -use gpui::AppContext as _; use gpui::{BorrowAppContext as _, Context, Entity, Subscription}; // Only the release-profile fallback below calls `AppContext::new`; importing // it unconditionally warns as unused in dev builds, hence the cfg gate. @@ -320,7 +318,7 @@ pub fn fetch_query_with_signal<T, E, C, F, Fut>( let now_ms = super::current_time_ms(); let Some(entity) = weak.upgrade() else { return }; - let _ = entity.update(cx, |resource, cx| { + entity.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { match result { Ok(data) => { diff --git a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs index 6338ebb..f7b6ae7 100644 --- a/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs +++ b/crates/gpui-query/src/hook/use_infinite_query/fetch_runners.rs @@ -66,7 +66,7 @@ pub(super) async fn run_fetch_page_with_id<T, E, F, Fut>( match result { Ok((page, has_more)) => { - let _ = e.update(cx, |resource, cx| { + e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.reset_retry_count(); resource.complete_success_with_guard( @@ -105,11 +105,11 @@ pub(super) async fn run_fetch_page_with_id<T, E, F, Fut>( if cancelled || !still_current { return; } - let _ = e.update(cx, |resource, _cx| { + e.update(cx, |resource, _cx| { resource.increment_retry(); }); } else { - let _ = e.update(cx, |resource, cx| { + e.update(cx, |resource, cx| { if let Some(guard) = resource.accept_current_request(request_id) { resource.reset_retry_count(); resource.complete_failure_with_guard(guard, error); diff --git a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs index 9040b12..5367a73 100644 --- a/crates/gpui-query/src/tests/hook_tests/regression_tests.rs +++ b/crates/gpui-query/src/tests/hook_tests/regression_tests.rs @@ -126,7 +126,7 @@ fn test_mutate_from_two_spawn_contexts_second_rejected(cx: &mut TestAppContext) let _second_task = harness.update(cx, |_this, cx| { cx.spawn(async move |weak_self, async_cx| { if let Some(h) = weak_self.upgrade() { - let _ = h.update(async_cx, |this, cx| { + h.update(async_cx, |this, cx| { let sc = sc.clone(); mutate( &this.mutation, From 69a071fe0c86cb62a4978a967f74fecc98661f3d Mon Sep 17 00:00:00 2001 From: hmziqagent <hmziqagent@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:13:55 +0200 Subject: [PATCH 111/111] chore: bump gpui-query to 0.3.0 on gpui-pre-0.6 --- crates/gpui-query/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-query/Cargo.toml b/crates/gpui-query/Cargo.toml index f1bfb46..5399671 100644 --- a/crates/gpui-query/Cargo.toml +++ b/crates/gpui-query/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gpui-query" -version = "0.2.2" +version = "0.3.0" edition = "2024" description = "TanStack Query-inspired async state management for GPUI" repository = "https://github.com/freeoxide/gpui-query"