// MobileGL - MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp // Copyright (c) 2025-2026 MobileGL-Dev // Licensed under the GNU Lesser General Public License v3.0: // https://www.gnu.org/licenses/gpl-3.0.txt // https://www.gnu.org/licenses/lgpl-3.0.txt // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // P1 stage 5: the GL_KHR_parallel_shader_compile application surface. // // Four things are under test, and they are the four an application actually touches: // * GL_COMPLETION_STATUS_KHR on shaders and programs, which MUST NOT JOIN - the whole // point of the query is to answer while the work is still outstanding; // * glMaxShaderCompilerThreadsKHR / ...ARB, including the count == 0 mode switch the // extension mandates and what lifts it again; // * GL_MAX_SHADER_COMPILER_THREADS_KHR; // * the extension string itself, which must appear if and only if asynchronous // compilation is enabled - the kill switch has to revert the application-visible // behaviour change, not only the threading. // // Like the other async suites, every case drives the real GL entry points and flips // MG_Config::Features.AsyncShaderCompile itself, so the file behaves identically whether or // not the suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1. #include #include #include #include #include "Config.h" #include "Includes.h" #include "Init.h" #include "MG_Backend/BackendObjects.h" #include "MG_Backend/DirectGLES/BackendObject_DirectGLES.h" #include "MG_Backend/DirectVulkan/BackendObject_DirectVulkan.h" #include "MG_Impl/GLImpl/Getter/GL_Getter.h" #include "MG_Impl/GLImpl/Program/GL_Program.h" #include "MG_State/GLState/Core.h" #include "MG_Util/Async/ShaderCompilePool.h" using namespace MobileGL; using namespace MobileGL::MG_Impl::GLImpl; namespace { class AsyncModeScope { public: explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) { MG_Config::Features.AsyncShaderCompile = async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff; } ~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; } AsyncModeScope(const AsyncModeScope&) = delete; AsyncModeScope& operator=(const AsyncModeScope&) = delete; private: const MG_Config::QuirkOverride m_saved; }; // glMaxShaderCompilerThreadsKHR writes PROCESS-wide state (the pool's concurrency budget // and the suspension latch), so a case that touches it has to put both back or it // poisons every case declared after it in this binary. class CompilerThreadScope { public: CompilerThreadScope() = default; ~CompilerThreadScope() { MG_Util::Async::SetAsyncShaderCompileSuspended(false); MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency( MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()); } CompilerThreadScope(const CompilerThreadScope&) = delete; CompilerThreadScope& operator=(const CompilerThreadScope&) = delete; }; const char* kVs = R"(#version 460 layout(location = 0) in vec3 aPos; uniform vec4 uColor; out vec4 vColor; void main() { vColor = uColor; gl_Position = vec4(aPos, 1.0); } )"; // Deliberately expensive, and distinct per index so the source-hash memo never turns a // second instance into a no-op: a saturated pool is the only way to observe an // outstanding job without asserting on timing. String MakeBulkySource(const int index) { String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"; source += "uniform float uSeed" + std::to_string(index) + ";\n"; source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n"; for (int i = 0; i < 320; ++i) { source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n"; } source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n"; return source; } GLuint MakeShader(const GLenum type, const char* source) { const GLuint shader = CreateShader(type); ShaderSource(shader, 1, &source, nullptr); return shader; } GLint QueryShaderCompletion(const GLuint shader) { GLint status = -1; GetShaderiv(shader, GL_COMPLETION_STATUS_KHR, &status); return status; } GLint QueryProgramCompletion(const GLuint program) { GLint status = -1; GetProgramiv(program, GL_COMPLETION_STATUS_KHR, &status); return status; } GLint QueryCompileStatus(const GLuint shader) { GLint status = GL_FALSE; GetShaderiv(shader, GL_COMPILE_STATUS, &status); return status; } GLint QueryLinkStatus(const GLuint program) { GLint status = GL_FALSE; GetProgramiv(program, GL_LINK_STATUS, &status); return status; } // Enqueues `count` distinct heavy compiles and returns their names without reading // anything back, leaving the pool with a real backlog. Vector EnqueueBacklog(const int count, const int seedBase, Vector& sourceStorage) { Vector shaders; shaders.reserve(static_cast(count)); for (int i = 0; i < count; ++i) { sourceStorage.push_back(MakeBulkySource(seedBase + i)); const char* text = sourceStorage.back().c_str(); const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); ShaderSource(fs, 1, &text, nullptr); CompileShader(fs); shaders.push_back(fs); } return shaders; } Bool Advertises(const Vector& extensions, const GLExtension wanted) { return std::find(extensions.begin(), extensions.end(), wanted) != extensions.end(); } class ParallelShaderCompileTest : public ::testing::Test { protected: void SetUp() override { MobileGL::Initialize(); } }; } // namespace // --------------------------------------------------------------------------------------- // GL_COMPLETION_STATUS_KHR must not join // --------------------------------------------------------------------------------------- // The load-bearing case of the whole stage. A single-worker pool is saturated with heavy // compiles, so jobs are demonstrably still queued; GL_COMPLETION_STATUS_KHR then has to // report GL_FALSE for at least one of them *and leave it outstanding*. If the query joined - // which is what happens if it is ever routed through the ordinary Compiled() gate - it could // only ever return GL_TRUE, and the extension would be a lie that costs an application the // exact stall it added the polling loop to avoid. // // Skipped rather than failed when the machine drained the backlog first, so it can never be // a false red on a fast box. TEST_F(ParallelShaderCompileTest, ShaderCompletionStatusReportsFalseWithoutJoining) { const AsyncModeScope async(true); const CompilerThreadScope threads; // One worker: the queue behind it is the thing being observed. MaxShaderCompilerThreadsKHR(1); Vector sources; const Vector shaders = EnqueueBacklog(64, 4000, sources); int outstanding = 0; for (const GLuint shader : shaders) { const GLint completion = QueryShaderCompletion(shader); ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion; if (completion == GL_FALSE) ++outstanding; } if (outstanding == 0) { GTEST_SKIP() << "the pool drained 64 heavy compiles before the first query; nothing outstanding to observe"; } // Asking again must still not have settled anything: the query is a peek, so a second // one cannot have made progress happen. (A joining implementation would report every // shader complete by now.) int stillOutstanding = 0; for (const GLuint shader : shaders) { if (QueryShaderCompletion(shader) == GL_FALSE) ++stillOutstanding; } EXPECT_GT(stillOutstanding, 0) << "GL_COMPLETION_STATUS_KHR joined - every shader settled just by being asked"; // And once the real (joining) query is used, everything is complete and correct. for (const GLuint shader : shaders) { EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE); EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "GL_COMPILE_STATUS must have joined"; } EXPECT_EQ(GetError(), GL_NO_ERROR); } // The program half: a link enqueued behind a saturated pool cannot be complete either, and // asking must not drag it forward. TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoining) { const AsyncModeScope async(true); const CompilerThreadScope threads; MaxShaderCompilerThreadsKHR(1); Vector sources; EnqueueBacklog(48, 4200, sources); Vector programs; for (int i = 0; i < 8; ++i) { sources.push_back(MakeBulkySource(4400 + i)); const char* text = sources.back().c_str(); const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); ShaderSource(fs, 1, &text, nullptr); CompileShader(fs); const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); CompileShader(vs); const GLuint program = CreateProgram(); AttachShader(program, vs); AttachShader(program, fs); LinkProgram(program); programs.push_back(program); } int outstanding = 0; for (const GLuint program : programs) { const GLint completion = QueryProgramCompletion(program); ASSERT_TRUE(completion == GL_TRUE || completion == GL_FALSE) << "completion = " << completion; if (completion == GL_FALSE) ++outstanding; } if (outstanding == 0) { GTEST_SKIP() << "the pool drained the whole backlog before the first query; nothing outstanding to observe"; } for (const GLuint program : programs) { EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); // GL_COMPLETION_STATUS_KHR spans BOTH phases of a link, so reading GL_LINK_STATUS - // which is answered out of phase A - is no longer enough to turn it GL_TRUE. That is // deliberate: an application that polls completion and then draws must not be told // "done" while the SPIR-V is still being generated, or the draw it was cleared for is // the thing that blocks. Settling both phases is what makes the query true. const auto& object = MG_State::pGLContext->GetProgramObject(program); ASSERT_NE(object, nullptr); object->JoinLinkAndSpirv(); EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a full join must have settled both phases"; } EXPECT_EQ(GetError(), GL_NO_ERROR); } // "Nothing outstanding" is the answer for an object that was never compiled or linked at // all: the query asks whether work is pending, not whether work ever happened. TEST_F(ParallelShaderCompileTest, CompletionStatusIsTrueForUntouchedObjects) { const AsyncModeScope async(true); const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n"); const GLuint program = CreateProgram(); EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE); EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE); EXPECT_EQ(GetError(), GL_NO_ERROR); } // With the flag off nothing is ever in flight, so the query is constant GL_TRUE - and, just // as importantly, still a recognized pname rather than a GL_INVALID_ENUM. TEST_F(ParallelShaderCompileTest, CompletionStatusIsAlwaysTrueWithAsyncOff) { const AsyncModeScope async(false); Vector sources; const Vector shaders = EnqueueBacklog(8, 4600, sources); for (const GLuint shader : shaders) { EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE); } const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); CompileShader(vs); const GLuint program = CreateProgram(); AttachShader(program, vs); AttachShader(program, shaders.front()); LinkProgram(program); EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE); EXPECT_EQ(GetError(), GL_NO_ERROR); } // The pname is new; the rejection of everything else must be untouched. TEST_F(ParallelShaderCompileTest, UnknownPnamesStillRaiseInvalidEnum) { const GLuint shader = MakeShader(GL_FRAGMENT_SHADER, "#version 460\nvoid main() {}\n"); const GLuint program = CreateProgram(); GLint value = 0; GetShaderiv(shader, GL_TEXTURE_2D, &value); EXPECT_EQ(GetError(), GL_INVALID_ENUM); GetProgramiv(program, GL_TEXTURE_2D, &value); EXPECT_EQ(GetError(), GL_INVALID_ENUM); } // --------------------------------------------------------------------------------------- // glMaxShaderCompilerThreadsKHR / ...ARB // --------------------------------------------------------------------------------------- // count == 0 is the mode switch the extension defines: no compiler threads. Two obligations // follow, and both are asserted here - everything already in flight is settled by the time // the call returns (so every GL_COMPLETION_STATUS_KHR reads GL_TRUE straight away), and // compilation that happens AFTERWARDS is synchronous too. TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsEverythingAndCompilesInline) { const AsyncModeScope async(true); const CompilerThreadScope threads; MaxShaderCompilerThreadsKHR(1); Vector sources; const Vector backlog = EnqueueBacklog(48, 4800, sources); MaxShaderCompilerThreadsKHR(0); EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended()); EXPECT_FALSE(MG_Util::Async::AsyncShaderCompileActive()); // The configuration flag itself is untouched: the extension is still advertised, the // application just asked for serial compilation. EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled()); for (const GLuint shader : backlog) { EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "glMaxShaderCompilerThreadsKHR(0) must leave nothing in flight"; EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE); } // Anything compiled from here on is finished before its glCompileShader returns. Vector serialSources; const Vector serial = EnqueueBacklog(6, 4900, serialSources); for (const GLuint shader : serial) { EXPECT_EQ(QueryShaderCompletion(shader), GL_TRUE) << "a compile after a zero count must be synchronous"; } // Links too, not just compiles. const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); CompileShader(vs); const GLuint program = CreateProgram(); AttachShader(program, vs); AttachShader(program, serial.front()); LinkProgram(program); EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a link after a zero count must be synchronous"; EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); EXPECT_EQ(GetError(), GL_NO_ERROR); } // The same obligation, but for LINKS that are already in flight when the zero count arrives - // and specifically for BOTH phases of one. A link is two chained jobs now (ProgramLinkTask, // then ProgramSpirvTask), and GL_COMPLETION_STATUS_KHR spans both, so // ProgramState::JoinAllPendingWork has to settle both or this query reads GL_FALSE in the one // mode the extension says cannot have anything pending. The case above creates its program // AFTER the zero count, so it links inline and cannot see this; here the programs are linked // against a saturated pool BEFORE it. TEST_F(ParallelShaderCompileTest, ZeroCompilerThreadsJoinsPendingLinksAndTheirSpirvJobs) { const AsyncModeScope async(true); const CompilerThreadScope threads; MaxShaderCompilerThreadsKHR(1); // A backlog first, so the links below cannot all drain before the zero count lands. Vector sources; (void)EnqueueBacklog(24, 5000, sources); Vector programs; for (int i = 0; i < 8; ++i) { sources.push_back(MakeBulkySource(5100 + i)); const char* text = sources.back().c_str(); const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); ShaderSource(fs, 1, &text, nullptr); CompileShader(fs); const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); CompileShader(vs); // this file's MakeShader only sources; it does not compile const GLuint program = CreateProgram(); AttachShader(program, vs); AttachShader(program, fs); LinkProgram(program); programs.push_back(program); } int outstanding = 0; for (const GLuint program : programs) { if (QueryProgramCompletion(program) == GL_FALSE) ++outstanding; } MaxShaderCompilerThreadsKHR(0); for (const GLuint program : programs) { EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "glMaxShaderCompilerThreadsKHR(0) must leave neither link phase in flight"; EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); } EXPECT_GT(outstanding, 0) << "every link had drained before the zero count; this case proved nothing"; EXPECT_EQ(GetError(), GL_NO_ERROR); } // ...and a later NONZERO count is what lifts it. Nothing else does: not a new context, not a // join, not eglInitialize. That is the documented contract, so it gets an assertion. TEST_F(ParallelShaderCompileTest, NonzeroCompilerThreadsRestoresAsynchronousCompilation) { const AsyncModeScope async(true); const CompilerThreadScope threads; MaxShaderCompilerThreadsKHR(0); ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended()); // Re-initializing must NOT quietly re-arm it - the application asked for serial // compilation and has not taken that back. MobileGL::Initialize(); EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended()); MaxShaderCompilerThreadsKHR(4); EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended()); EXPECT_TRUE(MG_Util::Async::AsyncShaderCompileActive()); // And work really is being enqueued again: with the budget back at one worker a heavy // backlog leaves something outstanding (skip-not-fail if the box drained it first). MaxShaderCompilerThreadsKHR(1); Vector sources; const Vector shaders = EnqueueBacklog(64, 5000, sources); const Bool anyOutstanding = std::any_of(shaders.begin(), shaders.end(), [](const GLuint shader) { return QueryShaderCompletion(shader) == GL_FALSE; }); if (!anyOutstanding) { GTEST_SKIP() << "the pool drained the backlog before the first query; asynchrony not observable here"; } for (const GLuint shader : shaders) { EXPECT_EQ(QueryCompileStatus(shader), GL_TRUE); } EXPECT_EQ(GetError(), GL_NO_ERROR); } // The three count cases map onto the pool's concurrency budget: a request above the thread // count cannot conjure threads, 0xFFFFFFFF means "implementation maximum", and an ordinary // value is taken as given (clamped to at least one). TEST_F(ParallelShaderCompileTest, CompilerThreadCountIsClampedToTheThreadCount) { const AsyncModeScope async(true); const CompilerThreadScope threads; auto& pool = MG_Util::Async::ShaderCompilePool::Get(); const Uint threadCount = pool.GetThreadCount(); ASSERT_GE(threadCount, 1u); MaxShaderCompilerThreadsKHR(1); EXPECT_EQ(pool.GetMaxConcurrency(), 1u); MaxShaderCompilerThreadsKHR(threadCount + 1000); EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "asking for more threads than exist cannot create any"; MaxShaderCompilerThreadsKHR(1); ASSERT_EQ(pool.GetMaxConcurrency(), 1u); MaxShaderCompilerThreadsKHR(0xFFFFFFFFu); EXPECT_EQ(pool.GetMaxConcurrency(), threadCount) << "0xFFFFFFFF is the implementation maximum"; // The ARB spelling is the same entry point, not a second piece of state. MaxShaderCompilerThreadsARB(1); EXPECT_EQ(pool.GetMaxConcurrency(), 1u); MaxShaderCompilerThreadsARB(0); EXPECT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended()); MaxShaderCompilerThreadsKHR(threadCount); EXPECT_FALSE(MG_Util::Async::IsAsyncShaderCompileSuspended()) << "the KHR and ARB names must share one piece of state"; EXPECT_EQ(GetError(), GL_NO_ERROR); } // A zero count with the feature switched off is legal and does nothing observable: there is // nothing to suspend, and the call must not fail just because MobileGL never had threads. TEST_F(ParallelShaderCompileTest, CompilerThreadCallsAreHarmlessWithAsyncOff) { const AsyncModeScope async(false); const CompilerThreadScope threads; MaxShaderCompilerThreadsKHR(0); MaxShaderCompilerThreadsKHR(8); MaxShaderCompilerThreadsARB(0xFFFFFFFFu); EXPECT_EQ(GetError(), GL_NO_ERROR); } // --------------------------------------------------------------------------------------- // GL_MAX_SHADER_COMPILER_THREADS_KHR // --------------------------------------------------------------------------------------- TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsGetter) { { const AsyncModeScope async(true); GLint value = -1; GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value); EXPECT_EQ(value, static_cast(MG_Util::Async::ShaderCompilePool::Get().GetThreadCount())); EXPECT_GE(value, 1); EXPECT_EQ(GetError(), GL_NO_ERROR); } { // No compiler threads exist in this configuration, and the extension is not // advertised either, so zero is the honest answer. const AsyncModeScope async(false); GLint value = -1; GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value); EXPECT_EQ(value, 0); EXPECT_EQ(GetError(), GL_NO_ERROR); } } // The reported maximum is the pool's THREAD count, not its current concurrency budget: an // application that lowered the budget still wants to know what the implementation can do. TEST_F(ParallelShaderCompileTest, MaxShaderCompilerThreadsIgnoresTheCurrentBudget) { const AsyncModeScope async(true); const CompilerThreadScope threads; const Uint threadCount = MG_Util::Async::ShaderCompilePool::Get().GetThreadCount(); MaxShaderCompilerThreadsKHR(1); GLint value = -1; GetIntegerv(GL_MAX_SHADER_COMPILER_THREADS_KHR, &value); EXPECT_EQ(value, static_cast(threadCount)); EXPECT_EQ(GetError(), GL_NO_ERROR); } // --------------------------------------------------------------------------------------- // The extension string // --------------------------------------------------------------------------------------- // The advertisement is the riskiest half of P1 - a recorded trace cannot cover it, because // Iris and Sodium change their submission schedule the moment they see the string - so // MOBILEGL_ASYNC_SHADER_COMPILE=0 has to withdraw it. Asserted on both backends' own // BuildAdvertisedExtensions, which is the single source of truth each of them (and the // driver POST) builds the list from. TEST_F(ParallelShaderCompileTest, BothBackendsAdvertiseTheExtensionIffAsyncIsEnabled) { { const AsyncModeScope async(true); EXPECT_TRUE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false, false, false), E_GL_KHR_parallel_shader_compile)); EXPECT_TRUE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false, false), E_GL_KHR_parallel_shader_compile)); } { const AsyncModeScope async(false); EXPECT_FALSE(Advertises(MG_Backend::DirectGLES::BuildAdvertisedExtensions(false, false, false, false), E_GL_KHR_parallel_shader_compile)) << "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading"; EXPECT_FALSE(Advertises(MG_Backend::DirectVulkan::BuildAdvertisedExtensions(false, false, false, false), E_GL_KHR_parallel_shader_compile)) << "MOBILEGL_ASYNC_SHADER_COMPILE=0 must withdraw the extension, not only the threading"; } } // The same fact through the GL surface an application actually reads. No flag flipping here: // a backend's advertised list is built once, at its first use, from the configuration that // was in force then - so this case asserts against the AMBIENT configuration, which is // exactly what makes it meaningful in both of the suite's two runs (with and without // MOBILEGL_ASYNC_SHADER_COMPILE=1 exported). TEST_F(ParallelShaderCompileTest, GLExtensionStringTracksTheAmbientConfiguration) { UniquePtr previousBackend = Move(MG_Backend::pActiveBackendObject); MG_Backend::pActiveBackendObject = MakeUnique(); const char* extensions = reinterpret_cast(GetString(GL_EXTENSIONS)); ASSERT_NE(extensions, nullptr); const String extensionString(extensions); const Bool advertised = extensionString.find("GL_KHR_parallel_shader_compile") != String::npos; EXPECT_EQ(advertised, MG_Util::Async::AsyncShaderCompileEnabled()) << "GL_EXTENSIONS = " << extensionString; // glGetStringi must agree with the monolithic string - LWJGL builds GLCapabilities from // the indexed form on a core profile. GLint count = 0; GetIntegerv(GL_NUM_EXTENSIONS, &count); ASSERT_GT(count, 0); Bool foundIndexed = false; for (GLint i = 0; i < count; ++i) { const char* name = reinterpret_cast(GetStringi(GL_EXTENSIONS, static_cast(i))); if (name != nullptr && std::string(name) == "GL_KHR_parallel_shader_compile") foundIndexed = true; } EXPECT_EQ(foundIndexed, advertised); MG_Backend::pActiveBackendObject = Move(previousBackend); EXPECT_EQ(GetError(), GL_NO_ERROR); }