CNA-BUG-098: A throwing shadow-caster or transparent-phase callback leaves RenderPipeline and its ShadowMap in an unrecoverable state
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.
RenderPipeline::begin() raises frameOpen_ before running the shadow-caster callback between ShadowMap::begin and end, and end() lowers it before the transparent callback; an exception from either skips the cleanup that pass exceptions get.
- Identifier
CNA-BUG-098- Category
- Bug
- Subsystem
- Graphics & renderers
- Status
- Open
- Verified against
- CNA
009d40f5(009d40f5dd085c4e674d3479675fac84b12b3e0a) - Severity
- Low (a triage suggestion, not a project priority)
- Evidence basis
- Source-verified: read at TARGET, not executed
- Tests touching this area
- Yes: see Current tests
- Affected contract
- CNA::Graphics::RenderPipeline::begin()/end() with callbacks registered through setShadowScene and setTransparentScene (CNAEXT)
Expected behaviour
MOD-713 made the pipeline recover from an exception inside a post-process pass: PipelineExceptionSafetyTest.AThrowingPassLeavesNoTargetBoundAndTheNextFrameRendersNormally requires that no target stays bound and that "a throw is not a one-way door into a broken object", and AFailedFrameDoesNotLeaveTheFrameOpen notes that CubeShadowMap and ShadowMap once had the same trap at their own begin(). The two caller callbacks are the same kind of caller code running inside a frame.
Actual behaviour at TARGET
In RenderPipeline::begin, frameOpen_ is raised first; with shadows enabled, shadowMap_->begin(...), the caster callback and shadowMap_->end() then run unguarded. If the callback throws, the shadow map's target stays bound, the ShadowMap stays open (its next begin throws "a shadow pass is already open") and the pipeline's frame stays open (its next begin throws "a frame is already open"); calling end() to recover runs the transparent phase and the chain for a frame whose scene target was never bound. In RenderPipeline::end, frameOpen_ is lowered first and drawTransparentPhase() runs before SetRenderTarget(nullptr): a throwing transparent callback leaves the scene target bound and the DepthRead/NonPremultiplied state (or the order-independent accumulation targets) in place, and the next Present refuses while a render target is bound.
Source locations
modules/graphics-ext/src/RenderPipeline.cpp— RenderPipeline::begin (shadow pass), RenderPipeline::end and RenderPipeline::drawTransparentPhasemodules/graphics-ext/src/ShadowMap.cpp— ShadowMap::begin refuses a second open pass; ShadowMap::end restores the back buffermodules/graphics-ext/include/CNA/Graphics/RenderPipeline.hpp— setShadowScene and setTransparentScenemodules/graphics-ext/tests/CNA/Graphics/PipelineDiagnosticsTests.cpp— PipelineExceptionSafetyTest covers pass exceptions only
Evidence
Checked by reading at 009d40f5; not executed. Both existing exception-safety tests throw from a user post-process pass, which runs after the unbind in end(); no test throws from either callback.
Focused reproduction
// Illustrative; not compiled or run for this entry.
pipeline.getSettings().setShadowsEnabled(true);
pipeline.setShadowScene(&shadowMap, light, bounds, [] { throw std::runtime_error("caster"); });
try { pipeline.begin(Color::Black); } catch (const std::runtime_error&) {}
pipeline.begin(Color::Black); // throws std::logic_error: a frame is already open
Current tests
PipelineDiagnosticsTests.cpp (PipelineExceptionSafetyTest.*, pass exceptions), RenderPipelineTests.cpp (TheShadowPassRunsBeforeTheSceneTargetIsBound, EachMissingIngredientLeavesTheShadowPassUnrun) and TransparentPhaseTests.cpp; none throws from a callback.
Regression test
Two cases beside PipelineExceptionSafetyTest: a caster callback that throws once, followed by a normal begin()/end() that must succeed with no target bound and a reusable ShadowMap; and a transparent callback that throws once, followed by assertions that no target is bound, default depth and blend state are back, and the next frame renders. The fix is a scope guard in each routine.
Blast radius
CNAEXT RenderPipeline users whose shadow-caster or transparent callbacks can throw (content loading, validation exceptions from draw calls). The shadow path leaves the pipeline and ShadowMap unusable until they are recreated; the transparent path leaves the frame bound to the scene target. Pass exceptions, and games without these callbacks, are unaffected.
Workaround
Catch exceptions inside both callbacks; after a callback failure, recreate the RenderPipeline and the ShadowMap.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- CNAEXT engine layer: limits
- Maintainer workflow
- Debug shutdown and lifetime: failure shapes
- Known issues
- Bug index