Skip to content

Hold the publisher's assets in the loading cache - #1375

Open
jwrosewell wants to merge 59 commits into
feature/33-geo-callfrom
feature/34-asset-cache
Open

jwrosewell wants to merge 59 commits into
feature/33-geo-callfrom
feature/34-asset-cache

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/asset-cache/51degrees 7 files, 2921 lines added, 0 removed
crates/trusted-server-core/src 9 files, 1979 lines added, 84 removed
docs/superpowers 1 file, 462 lines added, 0 removed
docs/guide 2 files, 89 lines added, 0 removed
Cargo.lock 1 file, 20 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 1 file, 9 lines added, 5 removed
.cargo/config.toml 1 file, 6 lines added, 6 removed
crates/trusted-server-adapter-axum/src 1 file, 6 lines added, 2 removed
crates/trusted-server-adapter-cloudflare/src 1 file, 6 lines added, 2 removed
crates/trusted-server-adapter-spin/src 1 file, 6 lines added, 2 removed
Cargo.toml 1 file, 2 lines added, 0 removed
CHANGELOG.md 1 file, 1 line added, 0 removed

Closes

Closes #1324

Spec

Spec: asset-cache

Test plan

CI on this pull request at aeb0aa1 passed all 20 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at aeb0aa1

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

On top of the geo module called with the request (#1374). Issue: #1324.

What changed after the 51Degrees PR review

Agreed at the review on 23 October, point 2. The 51Degrees loading cache is pluggable through modules, and the publisher asset feature becomes a module.

  • Four commits make the asset cache a module, as "What this adds" describes, and came into this pull request with "Merge the asset cache module into split/34-asset-cache". They are "Hold no copy of a file in core", "Offer files to the selected asset cache module", "Make the template cache's keep checks public" and "Hold the publisher's files in a 51Degrees module".
  • This pull request's copy of the asset cache design takes its final text from Add the integration seam design specs #1342, which describes the module.

Fixes found in review

  • [cache.51degrees-assets-vary] refuses memory_seconds, store_seconds and store_idle_seconds, which the module never reads, because the records take the lifetimes of [cache.51degrees-assets]. The table takes only module, and this pull request's copy of the asset cache design says so.

Kept current

What this adds

What changes.

Core offers each GET for a file, on the publisher path and on an asset route under [[proxy.asset_routes]], to the asset cache module that [asset-cache] selects, at the one point where it is about to ask the file's origin, and holds no copy of its own. The 51Degrees module, crates/asset-cache/51degrees, holds each file as its origin sent it, through the loading cache (#1365), so the origin is asked again only when no copy young enough is held, and the copy lasts no longer than the origin's own lifetime for it. On Fastly one load serves every instance at a location that asks for the file at the same moment, as the loading cache's design sets out.

[asset-cache]
module = "51degrees"

[cache.51degrees-assets]
memory_seconds = 5
store_seconds = 3600
store_idle_seconds = 86400

No stock build links the module or a loading cache module, so a stock build asks the origin for every file, as before this row. A deployment that wants its files held links a loading cache module and this module, hands both builders to its adapter, through run_with on Fastly or routes_with_registrations on Axum, Cloudflare and Spin, and selects the module in [asset-cache].

How it works.

#[async_trait::async_trait(?Send)]
pub trait AssetCacheModule: Send + Sync {
    async fn answer(&self, services: &RuntimeServices, file: AssetFile<'_>) -> AssetAnswer;
}

pub enum AssetAnswer {
    Serve(Response<EdgeBody>),
    OriginFailed(Report<PlatformError>),
    AskOrigin,
}
  • One interface in core. crates/trusted-server-core/src/asset_cache.rs holds AssetCacheModule, AssetAnswer, AssetVia, AssetFile, which carries what core knows of the reader's request, and OriginRequest, the request core would send. It holds no caching code. The module answers Serve, which takes the place of the origin's response, OriginFailed, which core answers as its own failed origin request, or AskOrigin, and core then sends the request it prepared.
  • Only the selected module. A registration supplies a module with with_asset_cache_module, and the registry takes it only from the builder that [asset-cache] selects. It refuses one from any other builder, an [asset-cache] that selects two modules, and one that selects a builder that supplies none.
  • What core offers. Only a GET that describes no body. A route offers no request the platform's image optimizer answers, and the publisher path offers a file only while no origin request has started.
  • Two caches the module declares. 51degrees-assets holds the copies, and 51degrees-assets-vary holds each address's record of the request headers the origin's Vary names, both declared with with_cache_features, as row 32 (Let a module declare the caches it loads through #1373) lets a module do, and both sized by [cache.51degrees-assets]. A later request for an address reads the record and is served the copy kept under its own values for those headers, as RFC 9111 section 4.1 describes.
  • What is never kept, whatever the settings say: anything but a GET, anything but a 200, HTML (a page is a template or nothing), a response carrying Set-Cookie, a response whose cache policy says private, no-store or no-cache or gives no lifetime, a response that varies on * or on Cookie, a response larger than a copy holds (1 MiB), and the answer to a request carrying Authorization, a Cookie header on the publisher path, a conditional header, a range, or a Cache-Control that asks for the origin (no-cache, no-store, min-fresh or a nonzero max-age). A request on the publisher path that carries a Cookie header is not served a copy either, because the origin receives the reader's cookies and nothing in its answer shows whether they shaped it.
  • A response that may not be shared is handed back to the request whose load fetched it, so that request is served from one fetch, not two, and the requests that follow fetch their own for a minute rather than wait on a load.
  • A copy is served as the origin's response would be, with the headers that describe the bytes, an Age that counts from the origin's, and the fetch and serve entries of the configuration run on it as they run on a fetched response.
  • Each publisher's copies are its own. Every key starts with the publisher's prefix, so two publishers sharing a store never share a copy.
  • One rule for keeping a response. origin_response_may_be_kept and origin_shared_ttl decide whether an origin's response may be kept for other readers, and for how long. They become public and return KeepRefusal, so the template cache and the module ask the same questions.

Why

A deployment with the platform's cache in front of it already has its assets held there. A deployment with nothing in front of it, and one whose fetch phase rewrites an asset before a reader sees it, fetches every asset on every page from the publisher's origin every time, and every instance does. Holding those files is a choice one deployment makes, so the code that holds them belongs in a module, and core offers only the place where a module may answer. At the 51Degrees PR review on 23 October, 51Degrees asked IAB Tech Lab for advice on whether the asset cache should become a module, and the change was decided by 51Degrees after the review.

IAB Tech Lab's own draft design for response caching (#894) holds documents in core, and the module holds no document, so no file is held by both. Section 2.10 of the design sets out how the two sit side by side.

What breaks

  • Nothing for a deployment that selects no asset cache module, which every stock build is.
  • A document written for this row before the module carries [cache.assets] or [cache.assets-vary], which no module declares now, so every build refuses it, and module = "none" is no longer read. Such a document adds [asset-cache] module = "51degrees", renames the tables [cache.51degrees-assets] and [cache.51degrees-assets-vary] and drops any lifetime its [cache.assets-vary] table set, which the renamed table refuses, in a build that links the module and a loading cache module.
  • A copy kept before the change is never read again, because the cache names, the key and the variant digest's tag, now ts-asset-variant-v1, all changed, and the copy ends by its own lifetime.

The commits

Commit What it does
7031828 The source cache and the asset cache, in core, selected by [cache.assets]
4e0bb71 The publisher path holds the origin's assets through the loading cache
87a55aa An asset route's answer is held the same way, and the guide documents the table
6d587fd The merges of the chain below
f6e598f Review fixes: a dropped response is handed back, the asset route honors the request's Cache-Control, shared test and hashing helpers
67af14e The asset cache design
d777cb0 The asset cache design takes its round 1 review
32f9ee3 A request that sends the publisher's origin a cookie is fetched for itself
210d3e0 The asset caches are turned off when their table names the module none
34010ca An asset cache lifetime out of range is refused when the settings load
93520fb The guide and the changelog describe the asset cache's cookie rule, off switch and range check
10aacb0 The asset cache design takes its round 2 review
a5b62b1 Core holds no copy of a file, and asks the origin for every file again
d411eb7 Core offers each file to the asset cache module [asset-cache] selects
e2f0b72 The template cache's keep checks become public, for the module
9273781 The 51Degrees module holds the files, with the tests that cover them
e7c7d6a This row's copy of the asset cache design describes the module
7627136 [cache.51degrees-assets-vary] refuses memory_seconds, store_seconds and store_idle_seconds, which the module never reads
ac11984 This row's copy of the asset cache design says the vary table refuses its lifetimes

The other commits merge the branch below into this one, and through it Tech Lab main at 7aa5c99, apart from "Merge the asset cache module into split/34-asset-cache", which brings the module's four commits listed above.

What is not in this pull request

  • A stock build that holds assets. No stock list links the module or a loading cache module.
  • A body the fetch phase rewrites is kept as the origin sent it, and the fetch phase runs on each reader's copy. Keeping the rewritten body once is a later commit, which brings back a digest of the build and the settings into the key.
  • The publisher's declared rule for inputs the origin varies on without saying so ([publisher.origin_personalization]), which separates copies the origin's own Vary does not. Later, with its tests.
  • The /proxy first-party route, which rehosts third party assets named in a page. It streams, and on Fastly the platform's cache holds it.
  • Pages stay on the template cache, as Add the loading cache core declares and the module that supplies it #1365 said.

How it was verified

Locally on Windows, on this pull request's tree, these passed: cargo fmt --all -- --check, clippy-fastly, clippy-axum, clippy-cli, clippy-cloudflare and clippy-spin-native, the core library's tests (2,987), the asset cache module's tests (43), cargo test-axum (1,142, 1 ignored), cargo test-cloudflare (54, 1 ignored), cargo test-spin (93, 1 ignored), the Fastly adapter's library tests under Viceroy (276) and the docs checks.

jwrosewell and others added 6 commits October 13, 2026 23:52
Core gains source_cache.rs, which keeps each address's record of what the
origin's responses vary on and a copy under each request's values for those
headers, both through the loading cache, so requests for an address share
one load and a copy is served only to a request whose values match, and
asset_cache.rs, which is what a kept asset holds and the address it is held
under. Both are selected by one table, [cache.assets], which CacheSettings
lists after [cache.robots-txt], so a deployment names the module whose
loading cache holds its assets as it does for robots.txt, and a build that
links one takes it without a line in the document.

Nothing holds an asset yet. The publisher path does in the next commit.
Every GET that is not a page is looked for in the asset cache before the
origin is fetched, under the publisher's prefix and the asset's address,
and a copy found is served as the origin's response would be, with an Age
that counts from the origin's. A miss loads once however many requests ask
at the same moment, keeps the response as the origin sent it when it may be
shared, and hands a response that may not be shared back to the request
whose load fetched it, so that request is served from one fetch and the
requests that follow fetch their own for a minute rather than wait.

What is never kept: anything but a 200, HTML, a response carrying
Set-Cookie, one whose cache policy says private, no-store or no-cache or
gives no lifetime, one that varies on * or on Cookie, one larger than a
copy holds, and the answer to a request carrying Authorization. The record
of what an address varies on and the copies are two caches selected by the
one [cache.assets] table, and a deployment whose selected cache keeps
nothing asks the origin as before.
…isher's assets are

A file served through an asset route, under [[proxy.asset_routes]], streamed
from its origin on every request, however many readers and instances asked
for it, while a file the publisher's own origin sends was held through the
loading cache. The route handler now looks for a copy under the same two
caches [cache.assets] selects before it asks the origin, and keeps the
origin's answer the way the publisher path keeps an asset: under what the
origin says it varies on, for the lifetime its cache policy gives, served
with the headers that describe it and an Age that counts from the origin's.
The key carries the route's origin and prefix, so two routes to one address
keep their own copies.

Nothing is kept that may not be shared, by the same rules, and a request a
copy cannot answer is not served one: anything but a GET, a request
carrying Authorization, a conditional header or a range, one that names no
host, and any request while the selected cache keeps nothing. An answer the
platform's image optimizer makes is not kept either, because the platform
holds it. An answer that may not be shared is served to the request whose
load fetched it, from that one fetch.

The publisher path's asset helpers are open to the rest of core so the
route handler shares them, and the memory loading cache the publisher's
tests used lives in the platform's test support so the route's tests use
it too. Four tests cover an answer held and served to the next request
with one origin call, an answer carrying Set-Cookie fetched for each
request, a range request fetched rather than served a copy, and nothing
held while no loading cache keeps values.

The guide documents [cache.assets] beside [cache.robots-txt], with what is
held and what never is, and the changelog says what the asset cache adds.
A body that outgrows a copy while it is read is handed back as what was
read and then the rest. A response whose Vary no longer matches its record
is served to the request that fetched it. Both were dropped, and that
request asked the origin a second time.

The asset caches run their loads in the request that started them, and an
asset route refuses a copy to a request whose Cache-Control asks for the
origin, as the publisher path does. The two lookups share one function, the
origin checks the template cache and asset cache share are one helper, the
three length-prefixed hashes share one, and the tests use the memory loading
cache and the shared test services. The docs describe the copy and its key
as they are built today.
Describes the asset cache as the code at f6e598f builds it. It covers the key and what goes into it, when a stored asset is used, what is stored and served, the one table that selects both caches, each adapter, consent and privacy, failure handling and rollout for existing deployments. It lists the tests that exist and the joins that have none.
@jwrosewell
jwrosewell added this pull request to stack #1401 October 15, 2026 17:56
The design now says that the asset gate does not apply the cookie rules of the page path, so a request that carries a session cookie can read a stored copy or lead a load whose response is stored for every reader. It gives the reason the code gives, states that the cache is on by default with no off switch, and puts the cookie rule and an off switch under Open questions.

The design also gains Pragma in the gate, the verdict kept for a response that fails a test, the module default, where the sizes and the assets-vary table stand, the key-value store each adapter uses, how far one fetch reaches, what the caches add on Fastly, the asset rules, the cost and limits, and the tests that are missing. The review log and the revision record name the round.
@jwrosewell
jwrosewell removed this pull request from stack #1401 October 15, 2026 20:51
…tself

The asset gate on the publisher path let a request that carries a
Cookie header read a kept copy, and lead a load whose answer was then
kept for every reader. The origin receives the reader's cookies and
nothing in its response shows whether they shaped it, so such a request
now neither reads nor leads a copy, and asks the origin as it did
before the asset cache.

A response that sets a cookie was already kept for no one. A test on
the publisher path now shows that such an answer is served only to the
request whose fetch made it, and another shows that two readers with
different cookies each get their own answer while nothing is kept. An
asset route sends its origin no cookie, so its answers stay shared, and
a test pins that the route's origin never receives one.
A build that links a loading cache module held assets with no way to
stop it short of rebuilding without the module. Naming the module
"none" in [cache.assets] now leaves both asset caches unselected, so
the publisher path and the asset routes ask the origin for every
request as they did before the asset cache, and the other caches are
selected as before. Only [cache.assets] reads "none" this way, as
[ec] and [geo] do for their own modules.

The comments on loading_caches_from and on LoadingCacheConfig::module
said a table that names no module keeps no copy. They now say it takes
the one loading cache module the build links, and keeps no copy when
the build links none or several, which is what the code does.

One test selects the caches from settings that name no module and
from settings that name none, and checks each asset cache keeps values
in the first and nothing in the second, with robots.txt's cache left
as it was. Another runs two asset requests through the publisher path
with the caches an adapter would make, and counts one origin fetch
with no module named and two with none.
CacheSettings::prepare_runtime range checked [cache.robots-txt] and
each table a module declares, and not [cache.assets], so a store
lifetime of zero, or a lifetime over a year, loaded and was used. The
check now runs for [cache.assets] too, wherever settings are loaded or
validated for a deploy, and the error names the table and the field.

The test of out of range lifetimes now writes each value in both
[cache.robots-txt] and [cache.assets].
…ff switch and range check

The configuration guide says that a request sending the publisher's
origin a cookie always goes to the origin and its answer is not kept,
that an asset route sends its origin no cookie, and that
module = "none" in [cache.assets] keeps no asset. Its table gives the
one linked module as the default module of each cache table, where it
said none.

The changelog entry for the asset cache says the same, and that a
[cache.assets] lifetime outside the ranges of [cache.robots-txt] is
refused when the settings load.
The design now states the three fixes to the code. A request on the
publisher path that carries a Cookie header neither reads nor leads a
copy, module = "none" in [cache.assets] turns both asset caches off,
and the range check runs for [cache.assets]. The cookie rule and the
off switch leave Open questions, and the question left asks whether a
publisher may say that cookies do not shape its assets, as
origin_is_cookie_independent says of its HTML. Section 3 drops the off
switch and the range check, and Acceptance names the new tests and
drops the joins they cover.

From the round 2 review, the design says which entries each load
writes, no longer says that no test sends a Cookie header through the
publisher path, and gives the one linked module as the default. The
review log and the revision record name the round.
@jwrosewell jwrosewell reopened this Oct 19, 2026
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell jwrosewell changed the title The publisher's assets held through the loading cache Hold the publisher's assets in the loading cache Oct 19, 2026
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23
The publisher's assets and the answers of the asset routes were held in
core, in asset_cache.rs and source_cache.rs and in a gate and a loader in
publisher.rs and proxy.rs. That holding moves to a module, behind an
interface that core gains next, so core goes back to asking the origin
for every file, as it did before.

[cache.assets] and [cache.assets-vary] leave with it, with
module = "none", their place in declared_caches, the changelog line and
the configuration guide's section. The seam probe expects only
robots-txt and its own cache again.

Tests show that two requests for a file the origin lets be kept are two
origin requests, on the publisher path and on an asset route, with a
loading cache that keeps values linked, that core declares only
robots-txt, and that a [cache.assets] table is refused.
MemoryLoadingCache::held stays, for the module's tests.
Core offers each GET for a file, on the publisher path and on an asset
route, to the asset cache module that [asset-cache] selects, at the one
point where it is about to ask the file's origin. The module answers
Serve, which takes the place of the origin's response, OriginFailed,
which core answers as its own failed origin request, or AskOrigin, and
core then sends the request it prepared. With no module selected,
nothing changes.

asset_cache.rs holds the interface: AssetCacheModule, AssetAnswer,
AssetVia, AssetFile, which carries what core knows of the reader's
request, and OriginRequest, the request core would send, read from the
request it then sends. A registration supplies a module with
with_asset_cache_module. The registry takes it only from the
registration of the builder that [asset-cache] selects. It refuses one
from any other builder, and one in what any builder registers for the
auction plan, which is made whatever [asset-cache] selects. It refuses
an [asset-cache] that selects two modules, and one that selects a
builder that supplies none, naming that builder. Each adapter hands the
module to each request's services.

Core offers only a GET that describes no body, because a module's load
sends the request with none. A route offers no request the platform's
image optimizer answers, and the publisher path offers a file only
while no origin request has started, which happens only for a
navigation.

Tests use a module that records what it is offered. A served answer
keeps the origin from being asked and is processed as the origin's
response is, a file passed back is asked of the origin once, and a
failure is a 502. The reader's facts are reported, among them the
scheme, a route's fallback to the Host header and to https, and
asks_for_origin as it stands once a middleware that needs the whole
file has removed the range. A HEAD, a GET that describes a body and an
optimized file are not offered, and the request a module is handed is
the one core sends with no module. Registry tests cover each refusal,
the auction plan's both when [asset-cache] selects its builder and when
it selects another.
origin_response_may_be_kept and origin_shared_ttl decide whether an
origin's response may be kept for other readers, and for how long. An
asset cache module asks the same questions, so both become public and
return KeepRefusal, a small type of their own, in place of the template
cache's TemplateCacheBypassReason, whose sixteen variants stay private.
The template cache turns a KeepRefusal into its own reason, and a
KeepRefusal displays as that reason does, so the log lines read as
before.

template_cache_ttl calls origin_response_may_be_kept, which checks the
status before the cookie and the shared cache headers, so a page
response with a status other than 200 and a later fault is reported by
its status. The freshness helpers return KeepRefusal. A test covers
each refusal of origin_response_may_be_kept, the order of its checks,
and the reason each KeepRefusal becomes.
crates/asset-cache/51degrees, the module asset-cache.51degrees, holds
the files core held before, as the asset cache module [asset-cache]
selects. It keeps each file as its origin sent it, in two loading caches
it declares with with_cache_features, 51degrees-assets for the copies
and 51degrees-assets-vary for each address's record of what the
origin's Vary names, both sized by [cache.51degrees-assets]. It reads
no table of its own, so it refuses an [asset-cache.51degrees] table, an
empty one included. No stock list links it. The workspace, and the Axum
and Fastly aliases, name it.

The key, the entry and the source cache move from core with their
tests, the gate and the loader move from publisher.rs and proxy.rs, and
the module carries its own copies of four small helpers of core rather
than core making them public. Four things change in the move. The
caches take the names the module gives them, the gate also needs the
record cache to keep values, the key holds no digest of the build and
the settings, because a copy is the origin's bytes, and the variant
digest's tag becomes ts-asset-variant-v1. The module's loads capture the
services without the connection, as the Loader contract asks.

The tests that covered the copies in core move into the module and
drive it through AssetCacheModule::answer, with a stub origin and core's
MemoryLoadingCache. A document is passed back however shareable its
origin's answer. New tests cover the caches the builder declares, their
lifetimes, a record cache that keeps nothing, the refusal of a table
naming none, core's refusal of a lifetime out of range in
[cache.51degrees-assets], and the refusal of an [asset-cache.51degrees]
table, empty or not. The changelog and the configuration guide describe
the module.
The records in 51degrees-assets-vary take the lifetimes that
[cache.51degrees-assets] sets, so memory_seconds, store_seconds and
store_idle_seconds in [cache.51degrees-assets-vary] passed core's range
check and were then read by nothing. A setting that nothing reads looks
as if it works.

The module's validate now refuses each lifetime the vary table sets,
naming the table and every lifetime it sets. Loading the settings then
fails as it does for any invalid configuration, so the service logs why
and answers with its startup error, a 500. Every linked builder
validates whether or not a section selects it, so the refusal also holds
in a build that links the module and leaves [asset-cache] unset, and
the deploy check refuses the same in a tool that links the module. The
table keeps module, which selects the loading cache module that holds
the records, and core is unchanged.

The check names every field of LoadingCacheConfig, so a setting added
to that table does not build in the module until the check reads or
refuses it.

New tests show that each lifetime set alone is refused by loading the
settings and by the deploy check, selected or not, and by the registry
when selected, that the refusal names every lifetime set, and that a
vary table without lifetimes, empty or naming its loading cache module,
loads beside an assets table that sets all three. The configuration
guide says the lifetimes are refused.
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Oct 28, 2026
A lifetime set in [cache.51degrees-assets-vary] is refused when the
settings load and by the deploy check, a fix found in review in IABTechLab#1375.
The caches the module declares, failure handling, the rollout of a
document written for row 34, the module's tests and what no test
covers say so.
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Oct 28, 2026
This row's copy of the asset routes design carries the date it is
proposed, 2026-10-27, and the fixes of its review rounds of 28 October,
as the specs branch has them. The changelog says that a route's answers are
held by a selected asset cache module, a fix found in review, because
the publisher asset cache is a module since IABTechLab#1375.
@jwrosewell
jwrosewell added this pull request to stack #1465 October 28, 2026 18:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assets held through the loading cache

2 participants