From fb433715fc505895d4c3dd61d492bd3fa3992842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 22 Jul 2026 20:15:29 -0300 Subject: [PATCH] release stale web resources and avoid diagnostic texture pinning Advance the web resource cache generation only when the replacement document is installed, after the previous document and its asynchronous loads have been detached. This prevents outgoing document nodes from acquiring leases under the new generation. Add UIWebView coverage for delayed document replacement, stale resource release, and periodic cache pruning. Make Texture Viewer thumbnails observe textures through weak handles and retain them only for the duration of drawing. Keep explicit selection as the only operation that pins a texture, so opening the diagnostic viewer no longer prevents unused textures from being released. Update the shared-resource ownership plan to document the replacement-time navigation boundary. --- .../resource_shared_ownership_architecture.md | 5 +- src/eepp/ui/tools/uitextureviewer.cpp | 58 +++++++++++++- src/eepp/ui/uiwebview.cpp | 6 +- src/tests/unit_tests/uiwebview_tests.cpp | 75 +++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/.agent/plans/resource_shared_ownership_architecture.md b/.agent/plans/resource_shared_ownership_architecture.md index e8d1ce752..f263f34a2 100644 --- a/.agent/plans/resource_shared_ownership_architecture.md +++ b/.agent/plans/resource_shared_ownership_architecture.md @@ -753,8 +753,9 @@ Exit criteria: ### Stage 6: WebResourceCache and document leases Status: complete, 2026-07-21. Each UISceneNode now owns a WebResourceCache document session with -an explicit cache partition and navigation generation. UIWebView advances that session at the -existing navigation boundary. Document, stylesheet, remote font, image, and CSS background image +an explicit cache partition and navigation generation. UIWebView advances that session when the +replacement document is installed, after the previous document has been detached. Document, +stylesheet, remote font, image, and CSS background image requests share canonical fragment-free keys that include the partition, request method/body and headers, resource kind, and image decode options. diff --git a/src/eepp/ui/tools/uitextureviewer.cpp b/src/eepp/ui/tools/uitextureviewer.cpp index d81c4440a..540016255 100644 --- a/src/eepp/ui/tools/uitextureviewer.cpp +++ b/src/eepp/ui/tools/uitextureviewer.cpp @@ -6,6 +6,59 @@ namespace EE { namespace UI { namespace Tools { +namespace { + +class UIWeakTexturePreview : public UIImage { + public: + static UIWeakTexturePreview* New( TextureWeakPtr texture ) { + return eeNew( UIWeakTexturePreview, ( std::move( texture ) ) ); + } + + virtual void draw() { + UINode::draw(); + + if ( !mVisible || mAlpha == 0.f ) + return; + + TexturePtr texture = mTexture.lock(); + if ( !texture ) + return; + + const Sizef textureSize( texture->getPixelsSize() ); + if ( textureSize.x <= 0.f || textureSize.y <= 0.f ) + return; + + const Sizef availableSize( mSize.x - mPaddingPx.Left - mPaddingPx.Right, + mSize.y - mPaddingPx.Top - mPaddingPx.Bottom ); + Float scale = eemax( 0.f, eemin( 1.f, eemin( availableSize.x / textureSize.x, + availableSize.y / textureSize.y ) ) ); + Sizef destSize( ( textureSize * scale ).floor() ); + Vector2f position( std::trunc( mScreenPos.x ) + mPaddingPx.Left, + std::trunc( mScreenPos.y ) + mPaddingPx.Top ); + + if ( Font::getHorizontalAlign( mFlags ) == UI_HALIGN_CENTER ) + position.x += ( availableSize.x - destSize.x ) * 0.5f; + else if ( Font::getHorizontalAlign( mFlags ) == UI_HALIGN_RIGHT ) + position.x += availableSize.x - destSize.x; + if ( Font::getVerticalAlign( mFlags ) == UI_VALIGN_CENTER ) + position.y += ( availableSize.y - destSize.y ) * 0.5f; + else if ( Font::getVerticalAlign( mFlags ) == UI_VALIGN_BOTTOM ) + position.y += availableSize.y - destSize.y; + + const Color previousColor( texture->getColor() ); + texture->setColor( mColor ); + texture->draw( position, destSize ); + texture->setColor( previousColor ); + } + + protected: + explicit UIWeakTexturePreview( TextureWeakPtr texture ) : mTexture( std::move( texture ) ) {} + + TextureWeakPtr mTexture; +}; + +} // namespace + UITextureViewer* UITextureViewer::New() { return eeNew( UITextureViewer, () ); } @@ -107,11 +160,10 @@ void UITextureViewer::insertTexture( const TextureRegistryRecord& record ) { if ( !texture ) return; - UIImage* img = UIImage::New(); + UIImage* img = UIWeakTexturePreview::New( record.texture ); std::string uid( String::format( "texture-%llu", static_cast( record.id.value() ) ) ); - img->setDrawable( texture ) - ->setScaleType( UIScaleType::FitInside ) + img->setScaleType( UIScaleType::FitInside ) ->setClasses( { "texture-preview", uid } ) ->setTooltipText( getTextureDescription( texture.get() ) ) ->setGravity( UI_HALIGN_CENTER | UI_VALIGN_CENTER ) diff --git a/src/eepp/ui/uiwebview.cpp b/src/eepp/ui/uiwebview.cpp index a913ade60..248b4ae40 100644 --- a/src/eepp/ui/uiwebview.cpp +++ b/src/eepp/ui/uiwebview.cpp @@ -296,8 +296,6 @@ void UIWebView::loadURI( URI uri, const std::string& method, const std::string& void UIWebView::loadURI( URI uri, bool isHistoryNav, const std::string& method, const std::string& body, const Http::Request::FieldTable& headers ) { Uint64 generation = beginNavigationLoad(); - if ( mDocumentScene ) - mDocumentScene->beginDocumentNavigation( uri ); mIsLoading = true; if ( !isHistoryNav ) @@ -415,6 +413,10 @@ void UIWebView::loadDocumentData( URI url, std::string data, Uint64 generation ) self->getHorizontalScrollBar()->setValue( 0 ); static_cast( self->mDocContainer )->clearDocumentChildren(); ui->invalidateAsyncResourceLoads(); + // The previous document remains active while its replacement is downloading. Advance the + // cache generation only now, after that document has been detached, so late updates from + // the visible document cannot lease resources as if they belonged to its replacement. + ui->beginDocumentNavigation( url ); ui->clearFontFaces(); ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker ); ui->setURIFromURL( url ); diff --git a/src/tests/unit_tests/uiwebview_tests.cpp b/src/tests/unit_tests/uiwebview_tests.cpp index b1f93470f..6373e45a5 100644 --- a/src/tests/unit_tests/uiwebview_tests.cpp +++ b/src/tests/unit_tests/uiwebview_tests.cpp @@ -3107,6 +3107,81 @@ UTEST( UIWebView, NewerNavigationSupersedesStartedLoad ) { Engine::destroySingleton(); } +UTEST( UIWebView, CacheGenerationAdvancesWhenReplacementDocumentIsInstalled ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 800, 600, "UIWebView Cache Lease Boundary Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 300, 200 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + auto cache = WebResourceCache::New(); + cache->setTTL( Time::Zero ); + std::vector documentCompletions; + cache->setFetcher( [&documentCompletions]( const WebResourceRequest& request, + WebResourceCache::FetchCompletion completion ) { + if ( request.kind == WebResourceKind::Document ) { + documentCompletions.emplace_back( std::move( completion ) ); + } else { + Http::Response::FieldTable fields; + auto status = Http::Response::Status::Ok; + completion( Http::Response::createFakeResponse( fields, status, "resource" ) ); + } + } ); + webView->setWebResourceCache( cache ); + UISceneNode* documentScene = webView->getDocumentSceneNode(); + ASSERT_TRUE( documentScene != nullptr ); + + auto pump = [&]( int frames ) { + for ( int i = 0; i < frames; ++i ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + auto completeDocument = [&] { + ASSERT_FALSE( documentCompletions.empty() ); + auto completion = std::move( documentCompletions.front() ); + documentCompletions.erase( documentCompletions.begin() ); + Http::Response::FieldTable fields; + auto status = Http::Response::Status::Ok; + completion( + Http::Response::createFakeResponse( fields, status, "" ) ); + pump( 10 ); + }; + + webView->loadURI( URI( "https://first.example/" ) ); + EXPECT_EQ( 0u, documentScene->getDocumentGeneration() ); + completeDocument(); + EXPECT_EQ( 1u, documentScene->getDocumentGeneration() ); + + webView->loadURI( URI( "https://second.example/" ) ); + EXPECT_EQ( 1u, documentScene->getDocumentGeneration() ); + WebResourceRequest oldDocumentResource; + oldDocumentResource.uri = URI( "https://first.example/late.css" ); + oldDocumentResource.kind = WebResourceKind::StyleSheet; + documentScene->requestWebResource( std::move( oldDocumentResource ), {} ); + completeDocument(); + EXPECT_EQ( 2u, documentScene->getDocumentGeneration() ); + + pump( 61 ); + EXPECT_EQ( 0u, cache->getEntryCount() ); + + Engine::destroySingleton(); +} + UTEST( UIWebView, RepeatedRemoteNavigationHandlesSubresourceFanOut ) { constexpr int NavigationCount = 6; constexpr int ImagesPerDocument = 100;