diff --git a/MobileGL/Config.h b/MobileGL/Config.h index c47ce30f..c28548d4 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -130,6 +130,11 @@ namespace MobileGL::MG_Config { Bool AdvertiseFp64 = false; // MOBILEGL_MAGMA_R11G11B10F_FALLBACK: use fallback format for R11G11B10F on Vulkan. Bool MagmaR11G11B10FFallback = false; + // MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV: hand a shader module that fails SPIR-V validation to + // the driver anyway. Off by default: Magma declines such a program (no shader module, its + // draws refused by name), because a driver that accepts invalid SPIR-V may fault the GPU + // into device loss. For diagnosing a driver that copes with a specific module only. + Bool MagmaAllowInvalidSpirv = false; // MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3. Uint32 MagmaFramesInFlight = 3; // MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER: GL draws and dispatches Magma records into diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index fd554e3a..4809329c 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -348,6 +348,7 @@ namespace MobileGL::MG_ConfigLoader { features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS"); features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64"); features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK"); + features.MagmaAllowInvalidSpirv = QueryEnvFlag("MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV"); features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); features.MagmaMaxDrawsPerCommandBuffer = QueryEnvUint32("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER", 16384, 0, 1u << 24); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index b8a9a9f0..faacc799 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -391,6 +391,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPipeline PipelineFactory::CreatePipeline(const PipelineCreatePayload& payload) const { MOBILEGL_ASSERT(payload.stages != nullptr && !payload.stages->empty(), "PipelineFactory: stages are empty"); + // The assert compiles out of shipping builds, and a program declined for an invalid + // module (VkProgramObject::invalidSpirvDeclined) has no stages at all: a caller that + // forgot to refuse it must get a skipped draw, not a stageCount=0 pipeline. + if (payload.stages == nullptr || payload.stages->empty()) { + MGLOG_E_ONCE("PipelineFactory::CreatePipeline: refusing a pipeline with no shader stages " + "(programHash=0x%llx); its draws are skipped", + static_cast(payload.programHash)); + return VK_NULL_HANDLE; + } MOBILEGL_ASSERT(payload.vertexInputState != nullptr, "PipelineFactory: vertexInputState is null"); MOBILEGL_ASSERT(payload.pipelineLayout != VK_NULL_HANDLE, "PipelineFactory: pipelineLayout is null"); MOBILEGL_ASSERT(payload.renderPass != VK_NULL_HANDLE, "PipelineFactory: renderPass is null"); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index 9ca6539e..54a5db70 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -12,7 +12,9 @@ #include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/Types.h" +#include #include +#include #include #include #include @@ -2273,8 +2275,84 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } } + + // ScreenModuleForDriver's memory. Keyed by content, never by program: the same bytes get + // the same answer whichever program, variant or session carries them. + struct DriverModuleVerdictCache { + struct Entry { + Uint64 wordCount = 0; + Bool valid = true; + String reason; + }; + // Generous for any one application's distinct stage modules; past it the whole table + // is dropped rather than tracked per entry - a re-validation is the only cost. + static constexpr SizeT kMaxEntries = 8192; + std::mutex mutex; + UnorderedMap entries; + std::atomic validatorRuns{0}; + }; + + DriverModuleVerdictCache& GetDriverModuleVerdictCache() { + // Leaked on purpose: screened from session threads that may outlive static destruction. + static auto* cache = new DriverModuleVerdictCache(); + return *cache; + } } // namespace + ProgramFactory::DriverModuleVerdict ProgramFactory::ScreenModuleForDriver(const Vector& spirv, + Bool allowInvalid, String* outReason) { + if (outReason != nullptr) outReason->clear(); + if (spirv.empty()) return DriverModuleVerdict::Valid; + + auto& cache = GetDriverModuleVerdictCache(); + const Uint64 key = XXH64(spirv.data(), spirv.size() * sizeof(Uint), 0); + Bool valid = true; + String reason; + Bool known = false; + { + const std::lock_guard lock(cache.mutex); + const auto it = cache.entries.find(key); + if (it != cache.entries.end() && it->second.wordCount == spirv.size()) { + known = true; + valid = it->second.valid; + reason = it->second.reason; + } + } + if (!known) { + // Outside the lock: two threads screening the same new module both validate, and + // agree; nothing waits on another session's spirv-val. + MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation(); + cache.validatorRuns.fetch_add(1, std::memory_order_relaxed); + spv_context context = spvContextCreate(GetSpirvTargetEnv(spirv)); + spv_validator_options options = spvValidatorOptionsCreate(); + spvValidatorOptionsSetFriendlyNames(options, true); + spv_const_binary_t binary = {spirv.data(), spirv.size()}; + spv_diagnostic diagnostic = nullptr; + const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic); + valid = result == SPV_SUCCESS; + if (!valid) { + reason = diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : ""; + // The process-wide counter the validating test lanes assert on. + MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure(); + } + spvDiagnosticDestroy(diagnostic); + spvValidatorOptionsDestroy(options); + spvContextDestroy(context); + + const std::lock_guard lock(cache.mutex); + if (cache.entries.size() >= DriverModuleVerdictCache::kMaxEntries) cache.entries.clear(); + cache.entries[key] = DriverModuleVerdictCache::Entry{spirv.size(), valid, reason}; + } + + if (valid) return DriverModuleVerdict::Valid; + if (outReason != nullptr) *outReason = Move(reason); + return allowInvalid ? DriverModuleVerdict::AdmittedInvalid : DriverModuleVerdict::DeclinedInvalid; + } + + Uint64 ProgramFactory::DriverModuleValidatorRuns() { + return GetDriverModuleVerdictCache().validatorRuns.load(std::memory_order_relaxed); + } + // A shader that assigns gl_FragDepth (SPIR-V DepthReplacing) supplies depth itself // instead of taking the pipeline's interpolated Z, so a driver that varies the vertex // position math between pipelines cannot desynchronize it; the blended depth-write @@ -3814,27 +3892,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Bool remapOk = RemapDescriptorBindingsForVulkan(moduleSpirvs, m_maxBindings, moduleSpirvs); MOBILEGL_ASSERT(remapOk, "ProgramFactory::GetOrCreateProgram: descriptor binding remap failed"); + // Last look at the exact bytes the driver receives, in EVERY build rather than only in + // DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed, and for every stage BEFORE any + // module is created. By here the descriptor bindings have been remapped and the layout + // about to be reflected describes the remapped module, so there is no module left that + // is both valid and consistent with it to fall back to (the recovery that exists lives + // one step earlier, at the clip/XFB fixups). What is left is to keep the bytes away from + // the driver: a driver is not a validating entry point, and an invalid module it accepts + // can fault the GPU into device loss (a frontend translation once put a sampler inside + // the default uniform block, and Adreno page-faulted reading its "descriptor" out of + // uniform-buffer bytes). One invalid stage declines the whole program: no shader module + // of it is created, and every draw or dispatch of it is refused by name + // (VkProgramObject::invalidSpirvDeclined). MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV hands the + // bytes over anyway, for diagnosing a driver that copes. for (SizeT i = 0; i < moduleCount; ++i) { + String reason; + const DriverModuleVerdict verdict = ScreenModuleForDriver( + moduleSpirvs[i], MG_Config::Features.MagmaAllowInvalidSpirv, &reason); + if (verdict == DriverModuleVerdict::DeclinedInvalid) { + MGLOG_E_ONCE("ProgramFactory: declining program %u - its stage %d module fails SPIR-V validation (%s); " + "no shader module is created and its draws are refused. " + "MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV=1 hands such modules to the driver anyway.", + program.GetExternalIndex(), static_cast(stages[i]), reason.c_str()); + entry.invalidSpirvDeclined = true; + break; + } + if (verdict == DriverModuleVerdict::AdmittedInvalid) { + MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d " + "because MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV is set (%s)", + program.GetExternalIndex(), static_cast(stages[i]), reason.c_str()); + } + } + + for (SizeT i = 0; i < moduleCount && !entry.invalidSpirvDeclined; ++i) { auto& moduleSpv = moduleSpirvs[i]; if (moduleSpv.empty()) continue; - // Last look at the exact bytes the driver receives, in EVERY build rather than only - // in DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed. This one only reports: - // by here the descriptor bindings have been remapped and the layout about to be - // reflected describes the remapped module, so there is no module left that is both - // valid and consistent with it to fall back to. The recovery lives one step earlier, - // at the clip/XFB fixups (see the revert there) - which is where a transform can - // introduce a reference to a delisted interface variable, the failure this whole - // guard exists for. Anything that reaches this line names itself in the log of a - // shipping build instead of dying anonymously inside the driver. - SpirvValidationFailure finalFailure{}; - if (!ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex(), &finalFailure)) { - MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d - " - "a backend transform after the clip/XFB fixups broke it (%s)", - program.GetExternalIndex(), static_cast(stages[i]), - finalFailure.message.c_str()); - } - // Does the stage the driver will treat as the last pre-rasterization one actually // carry Xfb? Asked of the FINAL bytes, so it answers for whatever the whole transform // chain produced - a rewound clip/XFB backstop, a capture pass that resolved no diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index 5858a485..eae37ffd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -229,6 +229,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // the cache entry, so every later draw of the same program variant reads the same // answer instead of re-deciding it. Bool pointSizeCapabilityUnsupported = false; + // One of the program's final modules fails SPIR-V validation, so none of its modules + // was created and every draw or dispatch of it is refused by name (the ProgramInvalidSpirv / + // ComputeProgramInvalidSpirv wire declines) - see ProgramFactory::ScreenModuleForDriver. + // Sticky like the flag above: it lives on the cache entry, so the one log line is honest. + Bool invalidSpirvDeclined = false; Bool needsPassthroughTessControl = false; // ...and the pass-through this renderer can synthesize carries gl_Position and // nothing else, so it is only correct when the evaluation stage's inputs are @@ -289,6 +294,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; declinedDescriptors = other.declinedDescriptors; + // The three refusal flags travel like every other field: a robin-hood insert swaps + // entries, and a flag left behind would refuse (or admit) the wrong program. + invalidSpirvDeclined = other.invalidSpirvDeclined; + xfbCaptureDeclined = other.xfbCaptureDeclined; + pointSizeCapabilityUnsupported = other.pointSizeCapabilityUnsupported; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -310,6 +320,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; other.declinedDescriptors = false; + other.invalidSpirvDeclined = false; + other.xfbCaptureDeclined = false; + other.pointSizeCapabilityUnsupported = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -353,6 +366,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; declinedDescriptors = other.declinedDescriptors; + // The three refusal flags travel like every other field: a robin-hood insert swaps + // entries, and a flag left behind would refuse (or admit) the wrong program. + invalidSpirvDeclined = other.invalidSpirvDeclined; + xfbCaptureDeclined = other.xfbCaptureDeclined; + pointSizeCapabilityUnsupported = other.pointSizeCapabilityUnsupported; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -374,6 +392,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; other.declinedDescriptors = false; + other.invalidSpirvDeclined = false; + other.xfbCaptureDeclined = false; + other.pointSizeCapabilityUnsupported = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -504,6 +525,29 @@ namespace MobileGL::MG_Backend::DirectVulkan { // VkRenderPassManager::OnPresent's sweep. void OnFrameBoundary(); + // What the last look at a module's final bytes - the exact words vkCreateShaderModule + // would receive - decided. + enum class DriverModuleVerdict : Uint8 { + Valid, // validates: handed to the driver + DeclinedInvalid, // fails validation: never handed over, the program's work is declined + AdmittedInvalid, // fails validation, handed over anyway (MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV) + }; + // Validates `spirv` for the Vulkan environment its version word names and turns the + // result into a verdict. A driver is not a validating entry point: Adreno page-faulted + // into device loss on a module that carried a sampler inside a uniform block, Mali + // SIGSEGVs inside pipeline creation. So an invalid module is declined unless + // `allowInvalid` (the escape hatch) says to hand it over regardless. + // + // The validator's answer is remembered per module content (64-bit hash plus word count), + // process-wide: a program variant, an evicted-then-rebuilt entry and two programs that + // share a stage all re-screen the same bytes, and only the first pays for spirv-val. + // `outReason` receives the validator's message for an invalid module. + static DriverModuleVerdict ScreenModuleForDriver(const Vector& spirv, Bool allowInvalid, + String* outReason = nullptr); + // How many times ScreenModuleForDriver actually ran the validator (cache misses), for + // the tests that pin the cache. + static Uint64 DriverModuleValidatorRuns(); + static VkShaderStageFlagBits ToVkStage(ShaderStage stage); static VkFormat ConvertSpirvImageFormatToVkFormat(SpvImageFormat format); static SamplerNumericDomain UniformTypeToSamplerNumericDomain(GLenum glType); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 267de68a..9d510a8e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -6835,7 +6835,7 @@ void main() { // A refused program cannot reach here today - the full path refuses before it ever // records a snapshot - but declining the fast path costs one compare and means the // refusal does not depend on that ordering staying true. - if (programObj.pointSizeCapabilityUnsupported) { + if (programObj.pointSizeCapabilityUnsupported || programObj.invalidSpirvDeclined) { return false; } @@ -7318,7 +7318,7 @@ void main() { // The build already said why, once, naming the program and the stage. Refusing here - // before any pipeline is built from it - is what makes that message a decline rather // than a note attached to invalid usage the driver still receives. - if (programObj.pointSizeCapabilityUnsupported) { + if (programObj.pointSizeCapabilityUnsupported || programObj.invalidSpirvDeclined) { return false; } // For the snapshot's memoised entry pointer: if anything below inserts into the @@ -7778,6 +7778,8 @@ void main() { } ProgramFactory::CompileOptionFlags transformFlags = 0; const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags); + // No shader module of a program that failed validation exists; the build said why, once. + if (programObj.invalidSpirvDeclined) return; if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); @@ -7840,6 +7842,8 @@ void main() { } ProgramFactory::CompileOptionFlags transformFlags = 0; const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags); + // No shader module of a program that failed validation exists; the build said why, once. + if (programObj.invalidSpirvDeclined) return; if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def index ab39a53d..dc3423fe 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def @@ -126,3 +126,11 @@ MGL_WIRE_DECLINE(MipmapDepthStencilAspect) // is missing, or the three group-count words lie outside it or are misaligned. MGL_WIRE_DECLINE(DispatchIndirectBufferUnbound) MGL_WIRE_DECLINE(DispatchIndirectRange) + +// ---- ProgramFactory::ScreenModuleForDriver ------------------------------------------------ +// One of the bound program's final modules fails SPIR-V validation, so no shader module of it was +// created (VkProgramObject::invalidSpirvDeclined): SetupWireDraw refuses the draw, and the two +// dispatch arms the dispatch. A driver handed the module could fault the GPU into device loss. +// MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV=1 builds the module anyway, and then neither row moves. +MGL_WIRE_DECLINE(ProgramInvalidSpirv) +MGL_WIRE_DECLINE(ComputeProgramInvalidSpirv) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc index 5d28103d..1059e53b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc @@ -230,6 +230,10 @@ flags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex; programObject = &m_programFactory->GetOrCreateProgram(program, flags); } + if (programObject->invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ProgramInvalidSpirv, "a module of the program fails SPIR-V validation and was never built"); + return false; + } if (programObject->pointSizeCapabilityUnsupported) { MGL_WIRE_DECLINE_AT(PointSizeCapability, "the program writes gl_PointSize and the device cannot express it"); return false; @@ -775,6 +779,10 @@ return; } const auto& programObj = m_programFactory->GetOrCreateProgram(program, {}); + if (programObj.invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ComputeProgramInvalidSpirv, "a module of the compute program fails SPIR-V validation and was never built"); + return; + } if (!m_uniformManager->PrepareWireTextureResources(program, programObj)) { MGL_WIRE_DECLINE_AT(ComputeTextureResourcesUnprepared, "a sampled/storage image could not be made ready for the dispatch"); return; @@ -847,6 +855,10 @@ return; } const auto& programObj = m_programFactory->GetOrCreateProgram(program, {}); + if (programObj.invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ComputeProgramInvalidSpirv, "a module of the compute program fails SPIR-V validation and was never built"); + return; + } if (!m_uniformManager->PrepareWireTextureResources(program, programObj)) { MGL_WIRE_DECLINE_AT(ComputeTextureResourcesUnprepared, "a sampled/storage image could not be made ready for the dispatch"); return; diff --git a/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp b/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp index f274b6e2..8d25f1ad 100644 --- a/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp +++ b/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp @@ -32,6 +32,7 @@ #include #endif #include +#include #include namespace MobileGL::MG_Impl::EGLImpl { @@ -216,6 +217,38 @@ namespace MobileGL::MG_Impl::EGLImpl { return *surfaces; } + // EGL_KHR_surfaceless_context. The EGL state records a surfaceless binding as exactly that + // (eglGetCurrentSurface answers EGL_NO_SURFACE), but every backend renders into the surface + // it was bound to, and a session's capabilities are published on its first bind to one. So + // the backend is bound to a 1x1 pbuffer of the display's own instead: one per display, made + // on the first surfaceless bind, never handed to the application, and gone with the display + // (eglTerminate). Guarded by EGLOperationMutex. + std::unordered_map& SurfacelessStandIns() { + static auto* standIns = new std::unordered_map(); + return *standIns; + } + + // The display's stand-in, made now if it has none (or the one it had did not survive). + // EGL_NO_SURFACE when the backend could not make a pbuffer; the EGL error is then set. + EGLSurface SurfacelessStandIn(EGLStateContext* state, EGLDisplay dpy) { + auto& standIns = SurfacelessStandIns(); + if (const auto it = standIns.find(dpy); it != standIns.end()) { + if (state->ValidateSurfaceOnDisplay(dpy, it->second)) return it->second; + standIns.erase(it); + } + // Any of the display's configs will do: a stand-in is never read back or presented. + EGLConfig config = nullptr; + EGLint count = 0; + if (!state->GetConfigs(dpy, &config, 1, &count) || count < 1 || config == nullptr) { + state->SetError(EGL_BAD_ALLOC); + return EGL_NO_SURFACE; + } + const EGLint attribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; + const EGLSurface standIn = CreatePbufferSurface(dpy, config, attribs); + if (standIn != EGL_NO_SURFACE) standIns[dpy] = standIn; + return standIn; + } + #if MOBILEGL_WAYLAND_WINDOWS // Every Wayland window surface's presentation, by surface. Guarded by EGLOperationMutex, // which every entry point that reaches it holds. @@ -620,7 +653,22 @@ namespace MobileGL::MG_Impl::EGLImpl { state->MakeCurrent(oldDisplay, oldDraw, oldRead, oldContext); return EGL_FALSE; } - if (!backendObject->MakeEGLCurrent(dpy, draw, read, ctx)) { + // A surfaceless binding (EGL_KHR_surfaceless_context; EGLState took it above and records + // EGL_NO_SURFACE): the backend gets the display's stand-in pbuffer to render into. + EGLSurface backendDraw = draw; + EGLSurface backendRead = read; + if (draw == EGL_NO_SURFACE && read == EGL_NO_SURFACE) { + backendDraw = backendRead = SurfacelessStandIn(state, dpy); + if (backendDraw == EGL_NO_SURFACE) { + const EGLint error = state->ConsumeError(); + MGLOG_E_ONCE("eglMakeCurrent: no stand-in pbuffer for a surfaceless binding thread=%s (error=0x%04x)", + threadId.c_str(), error); + state->MakeCurrent(oldDisplay, oldDraw, oldRead, oldContext); + state->SetError(error != EGL_SUCCESS ? error : EGL_BAD_ALLOC); + return EGL_FALSE; + } + } + if (!backendObject->MakeEGLCurrent(dpy, backendDraw, backendRead, ctx)) { MGLOG_E_ONCE("eglMakeCurrent backend attach failed thread=%s dpy=%p draw=%p read=%p ctx=%p", threadId.c_str(), dpy, draw, read, ctx); state->SetError(EGL_BAD_ACCESS); @@ -700,6 +748,12 @@ namespace MobileGL::MG_Impl::EGLImpl { if (!state->TerminateDisplay(dpy)) { return EGL_FALSE; } + { + // The display's surfaces went with it, its surfaceless stand-in among them; the backend + // side goes with ReleaseEGLResources below. + const std::lock_guard operationLock(EGLOperationMutex()); + SurfacelessStandIns().erase(dpy); + } if (auto* backendObject = MG_Backend::pActiveBackendObject.get()) { // The display's dma-buf EGLImages went with it; so do their shared-image references. for (const Uint64 sharedImageId : state->TakeOrphanedSharedImages()) { diff --git a/MobileGL/MG_Impl/EGLImpl/EGLPlatformExtensions.h b/MobileGL/MG_Impl/EGLImpl/EGLPlatformExtensions.h index b60fb8f7..87a2501a 100644 --- a/MobileGL/MG_Impl/EGLImpl/EGLPlatformExtensions.h +++ b/MobileGL/MG_Impl/EGLImpl/EGLPlatformExtensions.h @@ -19,14 +19,16 @@ namespace MobileGL::MG_Impl::EGLImpl { "EGL_EXT_client_extensions EGL_EXT_platform_base EGL_KHR_platform_base " "EGL_MESA_platform_surfaceless" MOBILEGL_PLATFORM_WAYLAND_EXTENSIONS; inline constexpr const char* kDisplayExtensionString = - "EGL_KHR_create_context EGL_EXT_platform_base EGL_KHR_platform_base " + "EGL_KHR_create_context EGL_KHR_surfaceless_context EGL_KHR_no_config_context EGL_EXT_platform_base " + "EGL_KHR_platform_base " "EGL_MESA_platform_surfaceless" MOBILEGL_PLATFORM_WAYLAND_EXTENSIONS; // The display list again, with the dma-buf import a backend with shared images has (a split // client: the server allocates the images and names the descriptors that come back). A // monolith has no allocator to recognise a dma-buf with, so it never advertises these. The // same server answers the buffer age of its surfaces and takes swap damage to its presents. inline constexpr const char* kDisplayExtensionStringWithSharedImages = - "EGL_KHR_create_context EGL_EXT_platform_base EGL_KHR_platform_base " + "EGL_KHR_create_context EGL_KHR_surfaceless_context EGL_KHR_no_config_context EGL_EXT_platform_base " + "EGL_KHR_platform_base " "EGL_MESA_platform_surfaceless" MOBILEGL_PLATFORM_WAYLAND_EXTENSIONS " EGL_KHR_image_base EGL_EXT_image_dma_buf_import EGL_EXT_image_dma_buf_import_modifiers" " EGL_EXT_buffer_age EGL_KHR_partial_update EGL_KHR_swap_buffers_with_damage" diff --git a/MobileGL/MG_State/EGLState/Core.cpp b/MobileGL/MG_State/EGLState/Core.cpp index df1f9cd1..3eb441b0 100644 --- a/MobileGL/MG_State/EGLState/Core.cpp +++ b/MobileGL/MG_State/EGLState/Core.cpp @@ -659,7 +659,10 @@ namespace MobileGL { SetError(EGL_NOT_INITIALIZED); return nullptr; } - if (!ValidateConfigOnDisplay(display, config)) { + // EGL_KHR_no_config_context: EGL_NO_CONFIG_KHR is a context with no config, which + // every surface is compatible with. eglinfo's ES probe relies on it: it chooses with + // EGL_OPENGL_ES_BIT (ES 1.x), which no config here claims, and passes the NULL on. + if (config != EGL_NO_CONFIG_KHR && !ValidateConfigOnDisplay(display, config)) { SetError(EGL_BAD_CONFIG); return nullptr; } @@ -802,6 +805,11 @@ namespace MobileGL { *value = contextObject->EGLContextFlags; return true; case EGL_CONFIG_ID: { + // EGL_KHR_no_config_context: a context created without a config reports 0. + if (contextObject->Config == EGL_NO_CONFIG_KHR) { + *value = 0; + return true; + } const auto* cfg = TryGetConfig(contextObject->Config); if (!cfg) { const_cast(this)->SetError(EGL_BAD_CONFIG); @@ -1333,7 +1341,9 @@ namespace MobileGL { SetError(EGL_BAD_CONTEXT); return false; } - if (draw == EGL_NO_SURFACE || read == EGL_NO_SURFACE) { + // EGL_KHR_surfaceless_context: with BOTH surfaces absent the context is bound + // surfacelessly (no default framebuffer). One without the other is still a mismatch. + if ((draw == EGL_NO_SURFACE) != (read == EGL_NO_SURFACE)) { SetError(EGL_BAD_MATCH); return false; } diff --git a/MobileGL/MG_Test/EGLState/EGLStateTest.cpp b/MobileGL/MG_Test/EGLState/EGLStateTest.cpp index 7961c782..9d236ac9 100644 --- a/MobileGL/MG_Test/EGLState/EGLStateTest.cpp +++ b/MobileGL/MG_Test/EGLState/EGLStateTest.cpp @@ -135,6 +135,51 @@ TEST(EGLStateProfile, CompatibilityProfileRequiresExplicitCompatBit) { EXPECT_EQ(fixture->State.ConsumeError(), EGL_SUCCESS); } +// What `eglinfo -B` does for every API: eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) +// (EGL_KHR_surfaceless_context). It used to be EGL_BAD_MATCH, so eglinfo printed the EGL lines and +// no GL renderer/version lines at all. The binding records no surface: a client asking for the +// current surface must see what it bound. +TEST(EGLStateMakeCurrent, SurfacelessBindIsAcceptedAndRecordsNoSurface) { + auto fixture = CreateFixture(); + EXPECT_TRUE(fixture->State.MakeCurrent(fixture->Display, EGL_NO_SURFACE, EGL_NO_SURFACE, fixture->Context)); + EXPECT_EQ(fixture->State.ConsumeError(), EGL_SUCCESS); + EXPECT_EQ(fixture->State.GetCurrentContext(), fixture->Context); + EXPECT_EQ(fixture->State.GetCurrentSurface(EGL_DRAW), EGL_NO_SURFACE); + EXPECT_EQ(fixture->State.GetCurrentSurface(EGL_READ), EGL_NO_SURFACE); + EXPECT_TRUE(fixture->State.MakeCurrent(EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)); +} + +// The extension relaxes only the both-absent case: one surface without the other stays a mismatch. +TEST(EGLStateMakeCurrent, OneSidedSurfaceIsStillAMismatch) { + auto fixture = CreateFixture(); + EXPECT_FALSE(fixture->State.MakeCurrent(fixture->Display, fixture->Surface, EGL_NO_SURFACE, fixture->Context)); + EXPECT_EQ(fixture->State.ConsumeError(), EGL_BAD_MATCH); + EXPECT_FALSE(fixture->State.MakeCurrent(fixture->Display, EGL_NO_SURFACE, fixture->Surface, fixture->Context)); + EXPECT_EQ(fixture->State.ConsumeError(), EGL_BAD_MATCH); +} + +// eglinfo's ES probe chooses with EGL_RENDERABLE_TYPE/EGL_CONFORMANT = EGL_OPENGL_ES_BIT (ES 1.x), +// which no MobileGL config claims, so it passes the NULL config on to eglCreateContext - legal +// with EGL_KHR_no_config_context, where it means "no config". Without it that was +// EGL_BAD_CONFIG and the "OpenGL ES profile" lines never printed. +TEST(EGLStateCreateContext, NoConfigContextIsAcceptedAndReportsConfigIdZero) { + auto fixture = CreateFixture(); + const EGLint esAttribs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + const auto context = fixture->State.CreateContext(fixture->Display, EGL_NO_CONFIG_KHR, EGL_NO_CONTEXT, esAttribs); + ASSERT_NE(context, EGL_NO_CONTEXT); + EXPECT_EQ(fixture->State.ConsumeError(), EGL_SUCCESS); + EGLint configId = -1; + EXPECT_TRUE(fixture->State.QueryContext(fixture->Display, context, EGL_CONFIG_ID, &configId)); + EXPECT_EQ(configId, 0); + EXPECT_TRUE(fixture->State.MakeCurrent(fixture->Display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)); + EXPECT_TRUE(fixture->State.MakeCurrent(EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)); + // A non-null handle that names no config is still EGL_BAD_CONFIG. + EXPECT_EQ(fixture->State.CreateContext(fixture->Display, reinterpret_cast(0xdead), EGL_NO_CONTEXT, + esAttribs), + EGL_NO_CONTEXT); + EXPECT_EQ(fixture->State.ConsumeError(), EGL_BAD_CONFIG); +} + namespace { constexpr EGLAttrib kAbgr8888 = StateContext::kDrmFourccAbgr8888; constexpr EGLAttrib kXbgr8888 = StateContext::kDrmFourccXbgr8888; diff --git a/MobileGL/MG_Test/Pipeline/CMakeLists.txt b/MobileGL/MG_Test/Pipeline/CMakeLists.txt index 34ebeb9b..d51f949a 100644 --- a/MobileGL/MG_Test/Pipeline/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipeline/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable( PipelineQuirkTest.cpp PassthroughTessControlTest.cpp ViewportIndexReflectionTest.cpp + DriverModuleScreenTest.cpp ) target_include_directories(PipelineQuirkTest PRIVATE diff --git a/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp b/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp new file mode 100644 index 00000000..81064d1a --- /dev/null +++ b/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp @@ -0,0 +1,183 @@ +// MobileGL - MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.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 +// +// ProgramFactory::ScreenModuleForDriver is the last look Magma takes at a module's bytes before +// vkCreateShaderModule. A driver is not a validating entry point: a module that carried a sampler +// inside the default uniform block (a frontend translation defect) was accepted by Adreno, which +// then page-faulted on a descriptor read out of uniform-buffer bytes and lost the device. The +// screen used to report such a module and hand it over anyway; it must DECLINE it, so the GPU +// never sees it, unless MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV asks otherwise. +// +// The invalid module here is that exact shape, assembled by hand so it stays invalid whatever +// the frontend does: a Block-decorated uniform struct with a sampled-image member. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" + +#include +#include +#include + +#include "spirv-tools/libspirv.hpp" + +using namespace MobileGL; +using MobileGL::MG_Backend::DirectVulkan::ProgramFactory; +using Verdict = ProgramFactory::DriverModuleVerdict; + +namespace { + + // Shared by both modules: a fragment shader whose default uniform block holds a vec4 and, + // in the invalid one, a sampler2D next to it. + String UniformBlockFragment(Bool withSampler) { + String text = + "OpCapability Shader\n" + "OpMemoryModel Logical GLSL450\n" + "OpEntryPoint Fragment %main \"main\" %out\n" + "OpExecutionMode %main OriginUpperLeft\n" + "OpDecorate %out Location 0\n" + "OpDecorate %Block Block\n" + "OpMemberDecorate %Block 0 Offset 0\n" + "OpDecorate %ubo DescriptorSet 0\n" + "OpDecorate %ubo Binding 0\n" + "%void = OpTypeVoid\n" + "%fn = OpTypeFunction %void\n" + "%float = OpTypeFloat 32\n" + "%v4 = OpTypeVector %float 4\n" + "%int = OpTypeInt 32 1\n" + "%zero = OpConstant %int 0\n" + "%img = OpTypeImage %float 2D 0 0 0 1 Unknown\n" + "%simg = OpTypeSampledImage %img\n"; + text += withSampler ? "%Block = OpTypeStruct %v4 %simg\n" : "%Block = OpTypeStruct %v4\n"; + text += + "%pBlock = OpTypePointer Uniform %Block\n" + "%ubo = OpVariable %pBlock Uniform\n" + "%pv4u = OpTypePointer Uniform %v4\n" + "%pv4o = OpTypePointer Output %v4\n" + "%out = OpVariable %pv4o Output\n" + "%main = OpFunction %void None %fn\n" + "%entry = OpLabel\n" + "%ptr = OpAccessChain %pv4u %ubo %zero\n" + "%color = OpLoad %v4 %ptr\n" + "OpStore %out %color\n" + "OpReturn\n" + "OpFunctionEnd\n"; + return text; + } + + Vector Assemble(const String& text) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + std::vector words; + EXPECT_TRUE(tools.Assemble(text, &words)) << text; + return Vector(words.begin(), words.end()); + } + + // A realistic module out of the real frontend, for the cost measurement. + Vector CompileFragment(const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!binaryResult || binaryResult->empty()) return {}; + const auto& words = binaryResult->front(); + return Vector(words.begin(), words.end()); + } + + class DriverModuleScreenTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; + +} // namespace + +// The headline: the sampler-in-uniform-block module never reaches the driver. Red before the +// screen acted on its verdict - it answered AdmittedInvalid, i.e. "logged, handed over anyway". +TEST_F(DriverModuleScreenTest, ASamplerInsideTheDefaultUniformBlockIsDeclined) { + const Vector invalid = Assemble(UniformBlockFragment(/*withSampler=*/true)); + ASSERT_FALSE(invalid.empty()); + String reason; + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(invalid, /*allowInvalid=*/false, &reason), + Verdict::DeclinedInvalid); + EXPECT_FALSE(reason.empty()) << "a decline must carry the validator's reason for its log line"; +} + +// The positive control: the same block without the sampler validates and is handed over. A screen +// that declined everything would pass the case above and fail this one. +TEST_F(DriverModuleScreenTest, TheSameBlockWithoutTheSamplerPasses) { + const Vector valid = Assemble(UniformBlockFragment(/*withSampler=*/false)); + ASSERT_FALSE(valid.empty()); + String reason = "stale"; + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(valid, /*allowInvalid=*/false, &reason), Verdict::Valid); + EXPECT_TRUE(reason.empty()); +} + +// The escape hatch: with MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV the invalid module is handed over, +// and the verdict says it was invalid rather than pretending it validated. +TEST_F(DriverModuleScreenTest, TheEscapeHatchAdmitsAnInvalidModuleAndSaysSo) { + const Vector invalid = Assemble(UniformBlockFragment(/*withSampler=*/true)); + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(invalid, /*allowInvalid=*/true), Verdict::AdmittedInvalid); + const Vector valid = Assemble(UniformBlockFragment(/*withSampler=*/false)); + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(valid, /*allowInvalid=*/true), Verdict::Valid); +} + +// The verdict is remembered by content: re-screening the same bytes does not run spirv-val again, +// and a different module does. Also reports what one validation costs next to a cached answer. +TEST_F(DriverModuleScreenTest, TheVerdictIsCachedPerModuleContent) { + const Vector module = CompileFragment(R"(#version 330 core +uniform sampler2D tex0; +uniform sampler2D tex1; +uniform vec4 tint; +uniform mat4 colorMatrix; +in vec2 uv; +in vec4 vertexColor; +out vec4 fragColor; +vec3 tonemap(vec3 c) { return c / (c + vec3(1.0)); } +void main() { + vec4 base = texture(tex0, uv) * vertexColor; + vec4 detail = texture(tex1, uv * 4.0); + vec4 c = colorMatrix * mix(base, base * detail, 0.5) * tint; + for (int i = 0; i < 4; ++i) c.rgb += 0.01 * sin(c.gbr * float(i + 1)); + fragColor = vec4(tonemap(c.rgb), c.a); +} +)"); + ASSERT_FALSE(module.empty()); + // A copy with one changed word in the bound field of the header: different bytes, same + // validity, so it must miss the cache. + Vector sibling = module; + sibling[3] += 1; + + const Uint64 runsBefore = ProgramFactory::DriverModuleValidatorRuns(); + const auto t0 = std::chrono::steady_clock::now(); + const Verdict first = ProgramFactory::ScreenModuleForDriver(module, false); + const auto t1 = std::chrono::steady_clock::now(); + const Verdict second = ProgramFactory::ScreenModuleForDriver(module, false); + const auto t2 = std::chrono::steady_clock::now(); + EXPECT_EQ(first, Verdict::Valid); + EXPECT_EQ(second, first); + EXPECT_EQ(ProgramFactory::DriverModuleValidatorRuns(), runsBefore + 1) << "the second screen re-validated"; + + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(sibling, false), Verdict::Valid); + EXPECT_EQ(ProgramFactory::DriverModuleValidatorRuns(), runsBefore + 2) << "different bytes shared a verdict"; + + const auto us = [](auto a, auto b) { + return static_cast(std::chrono::duration_cast(b - a).count()); + }; + std::printf("[ screen ] %zu-word module: validate %lld us, cached %lld us\n", module.size(), us(t0, t1), + us(t1, t2)); +} diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index ce8d6c6f..b2bb5bcb 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -67,6 +67,9 @@ if (NOT WIN32) # MOBILEGL_IPC_SURFACE=server against a display-less `--serve` supervisor. Both drive real peers # and (the second, and D8's case) the real server image, as PeerLatchTest does. list(APPEND MOBILEGL_WIRE_TESTS InProcessServerTest ServerOwnedSurfaceTest) + # `eglinfo -B`'s per-API sequence (surfaceless make-current, the ES probe's NULL config) through + # a real MobileGL client against a real `--serve` supervisor, as ServerOwnedSurfaceTest does. + list(APPEND MOBILEGL_WIRE_TESTS EglInfoSequenceTest) # P14 S2 (docs/Disaggregated/design/11-state-ownership.md): several sessions in ONE process. # The in-process unix supervisor serves each session on its own control thread with its own # ServerSession/ServerLoop/PipeInputs block/latch domain/segment resolver; two SHM clients are @@ -99,6 +102,7 @@ foreach (test IN LISTS MOBILEGL_WIRE_TESTS) ${MGL_ROOT}/3rdparty/flatbuffers/include ) if (test STREQUAL "PeerLatchTest" OR test STREQUAL "InProcessServerTest" OR test STREQUAL "ServerOwnedSurfaceTest" OR + test STREQUAL "EglInfoSequenceTest" OR test STREQUAL "AdoptTierStreamTest" OR test STREQUAL "MultiSessionTest" OR test STREQUAL "ServerProbeTest") # It builds records and a program archive by hand, so it reaches MG_Pipe and MG_State # the way the codec suites do. (P12's two server suites share PeerLatchTest's healthy @@ -128,7 +132,8 @@ foreach (test IN LISTS MOBILEGL_WIRE_TESTS) endif () if (test STREQUAL "ServerSpawnTest" OR test STREQUAL "PeerLatchTest" OR test STREQUAL "InProcessServerTest" OR - test STREQUAL "ServerOwnedSurfaceTest" OR test STREQUAL "AdoptTierStreamTest" OR test STREQUAL "ServerProbeTest") + test STREQUAL "ServerOwnedSurfaceTest" OR test STREQUAL "EglInfoSequenceTest" OR test STREQUAL "AdoptTierStreamTest" OR + test STREQUAL "ServerProbeTest") # It execs the real image, so it must be built first and must be told # where it is. MOBILEGL_TEST_SERVER_PATH is read by the test's # ServerImage() helper; without it the test would depend on the working diff --git a/MobileGL/MG_Test/Wire/EglInfoSequenceTest.cpp b/MobileGL/MG_Test/Wire/EglInfoSequenceTest.cpp new file mode 100644 index 00000000..b9c30012 --- /dev/null +++ b/MobileGL/MG_Test/Wire/EglInfoSequenceTest.cpp @@ -0,0 +1,276 @@ +// MobileGL - MobileGL/MG_Test/Wire/EglInfoSequenceTest.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 + +// `eglinfo -B` AGAINST MOBILEGL, END TO END ON THE HOST: a real MobileGL client (the EGL entry +// points, the spawn/tcp session) against a real `--serve` supervisor, driven through the exact call +// sequence the distribution's eglinfo runs per display: +// +// eglInitialize; EGL_CLIENT_APIS must name "OpenGL" / "OpenGL_ES"; EGL_KHR_create_context decides +// the attribute form. Then, per API: +// eglBindAPI(api) +// eglChooseConfig({EGL_CONFORMANT, bit, EGL_RED/GREEN/BLUE/ALPHA_SIZE 1, EGL_RENDERABLE_TYPE, +// bit}) and take configs[0], or NULL when nothing matched +// desktop: eglCreateContext({MAJOR, MINOR, PROFILE_MASK}) from 4.6 down (core stops at 3.1) +// ES: eglCreateContext({EGL_CONTEXT_MAJOR_VERSION, n}) for n = 3, 2, 1 +// eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) - surfaceless +// glGetString(GL_VENDOR / GL_RENDERER / GL_VERSION) through eglGetProcAddress +// +// On the device eglinfo printed MobileGL's EGL lines for every platform and no GL lines at all. Two +// refusals, both red here before the fix: +// * the surfaceless eglMakeCurrent was EGL_BAD_MATCH, so no desktop context ever became current; +// * the ES probe's bit is EGL_OPENGL_ES_BIT (ES 1.x), which no MobileGL config claims, so the +// config is NULL and eglCreateContext answered EGL_BAD_CONFIG. + +#include "P12ServerRig.h" + +#include + +using namespace MobileGL; +using namespace P12; + +namespace { + + namespace EGL = MobileGL::MG_Impl::EGLImpl; + + constexpr unsigned kGlVendor = 0x1F00; + constexpr unsigned kGlRenderer = 0x1F01; + constexpr unsigned kGlVersion = 0x1F02; + + // One "OpenGL ... profile" block of eglinfo's output, or why it is missing. + struct ProfileReport { + Int32 configFound = 0; // eglChooseConfig matched something + Int32 created = 0; // some eglCreateContext in the version walk returned a context + Int32 madeCurrent = 0; // ...and the surfaceless eglMakeCurrent took it + Int32 lastError = 0; // eglGetError after the last failed create / make-current + Int32 drawIsNone = 0; // eglGetCurrentSurface(EGL_DRAW) == EGL_NO_SURFACE while current + Int32 major = 0; + Int32 minor = 0; + char vendor[96] = {}; + char renderer[96] = {}; + char version[96] = {}; + }; + + struct EglInfoReport { + Int32 initialized = 0; + Int32 eglMajor = 0; + Int32 eglMinor = 0; + Int32 hasOpenGL = 0; + Int32 hasOpenGLES = 0; + Int32 khrCreateContext = 0; + Int32 surfacelessExtensions = 0; // EGL_KHR_surfaceless_context and EGL_KHR_no_config_context advertised + ProfileReport core{}; + ProfileReport compat{}; + ProfileReport es{}; + char note[128] = {}; + }; + + // eglinfo's extension_supported(): a whole space-separated token. + bool HasToken(const char* list, const char* token) { + if (list == nullptr) return false; + const std::string all = std::string(" ") + list + " "; + return all.find(std::string(" ") + token + " ") != std::string::npos; + } + + EGLConfig ChooseLikeEglInfo(EGLDisplay dpy, EGLint apiBit, ProfileReport& out) { + const EGLint attribs[] = {EGL_CONFORMANT, apiBit, EGL_RED_SIZE, 1, EGL_GREEN_SIZE, 1, EGL_BLUE_SIZE, 1, + EGL_ALPHA_SIZE, 1, EGL_RENDERABLE_TYPE, apiBit, EGL_NONE}; + EGLConfig configs[64] = {}; + EGLint count = 0; + (void)EGL::ChooseConfig(dpy, attribs, configs, 64, &count); + out.configFound = count > 0 ? 1 : 0; + return count > 0 ? configs[0] : nullptr; + } + + // eglinfo's doOneContext, minus the printing. + void ReadStrings(ProfileReport& out) { + using GetStringFn = const unsigned char* (*)(unsigned); + const auto getString = reinterpret_cast(EGL::GetProcAddress("glGetString")); + if (getString == nullptr) return; + const auto copy = [&](unsigned name, char* into, size_t size) { + const unsigned char* text = getString(name); + std::snprintf(into, size, "%s", text != nullptr ? reinterpret_cast(text) : ""); + }; + copy(kGlVendor, out.vendor, sizeof(out.vendor)); + copy(kGlRenderer, out.renderer, sizeof(out.renderer)); + copy(kGlVersion, out.version, sizeof(out.version)); + } + + // Returns the context made current, or EGL_NO_CONTEXT - eglinfo's createEGLContext. + EGLContext TryCurrent(EGLDisplay dpy, EGLConfig config, const EGLint* attribs, ProfileReport& out) { + const EGLContext ctx = EGL::CreateContext(dpy, config, EGL_NO_CONTEXT, attribs); + if (ctx == EGL_NO_CONTEXT) { + out.lastError = EGL::GetError(); + return EGL_NO_CONTEXT; + } + out.created = 1; + if (EGL::MakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) != EGL_TRUE) { + out.lastError = EGL::GetError(); + (void)EGL::DestroyContext(dpy, ctx); + return EGL_NO_CONTEXT; + } + out.madeCurrent = 1; + out.drawIsNone = EGL::GetCurrentSurface(EGL_DRAW) == EGL_NO_SURFACE ? 1 : 0; + return ctx; + } + + void Release(EGLDisplay dpy, EGLContext ctx, ProfileReport& out) { + ReadStrings(out); + (void)EGL::MakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + (void)EGL::DestroyContext(dpy, ctx); + } + + void DesktopProfile(EGLDisplay dpy, EGLConfig config, bool core, bool khrCreateContext, ProfileReport& out) { + static const int kVersions[][2] = {{4, 6}, {4, 5}, {4, 4}, {4, 3}, {4, 2}, {4, 1}, {4, 0}, {3, 3}, + {3, 2}, {3, 1}, {3, 0}, {2, 1}, {2, 0}, {1, 5}, {1, 4}, {1, 3}, + {1, 2}, {1, 1}, {1, 0}}; + for (const auto& v : kVersions) { + if (core && v[0] == 3 && v[1] == 0) return; + const EGLint withProfile[] = {EGL_CONTEXT_MAJOR_VERSION, v[0], EGL_CONTEXT_MINOR_VERSION, v[1], + EGL_CONTEXT_OPENGL_PROFILE_MASK, + core ? EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT + : EGL_CONTEXT_OPENGL_COMPATIBILITY_PROFILE_BIT, + EGL_NONE}; + const EGLint old[] = {EGL_CONTEXT_CLIENT_VERSION, v[0], EGL_NONE}; + const EGLContext ctx = TryCurrent(dpy, config, khrCreateContext ? withProfile : old, out); + if (ctx != EGL_NO_CONTEXT) { + out.major = v[0]; + out.minor = v[1]; + Release(dpy, ctx, out); + return; + } + } + } + + void EsProfile(EGLDisplay dpy, EGLConfig config, ProfileReport& out) { + for (int major = 3; major > 0; --major) { + const EGLint attribs[] = {EGL_CONTEXT_MAJOR_VERSION, major, EGL_NONE}; + const EGLContext ctx = TryCurrent(dpy, config, attribs, out); + if (ctx != EGL_NO_CONTEXT) { + out.major = major; + Release(dpy, ctx, out); + return; + } + } + } + + EglInfoReport RunEglInfoPeer(const ServerProcess& server, const std::string& clientLogBase) { + EglInfoReport report{}; + int fds[2] = {-1, -1}; + if (::pipe(fds) != 0) return report; + std::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid == 0) { + ::close(fds[0]); + ::setenv("MOBILEGL_LOG_FILE_PATH", clientLogBase.c_str(), 1); + ::setenv("MOBILEGL_TRANSPORT", "spawn", 1); + ::setenv("MOBILEGL_IPC_CONTROL", server.endpoint.c_str(), 1); + ::setenv("MOBILEGL_BACKEND_TYPE", "DirectGLES", 1); + ::unsetenv("MOBILEGL_IPC_SURFACE"); + EglInfoReport r{}; + // eglinfo walks every platform; surfaceless is the one a display-less container has. + const EGLDisplay dpy = EGL::GetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, EGL_DEFAULT_DISPLAY, nullptr); + EGLint major = 0; + EGLint minor = 0; + if (dpy == EGL_NO_DISPLAY || EGL::Initialize(dpy, &major, &minor) != EGL_TRUE) { + std::snprintf(r.note, sizeof(r.note), "eglInitialize failed (no spawn session?)"); + } else { + r.initialized = 1; + r.eglMajor = major; + r.eglMinor = minor; + const char* apis = EGL::QueryString(dpy, EGL_CLIENT_APIS); + const char* exts = EGL::QueryString(dpy, EGL_EXTENSIONS); + r.hasOpenGL = HasToken(apis, "OpenGL") ? 1 : 0; + r.hasOpenGLES = HasToken(apis, "OpenGL_ES") ? 1 : 0; + const bool khr = major == 1 && minor >= 4 && HasToken(exts, "EGL_KHR_create_context"); + r.khrCreateContext = khr ? 1 : 0; + r.surfacelessExtensions = + HasToken(exts, "EGL_KHR_surfaceless_context") && HasToken(exts, "EGL_KHR_no_config_context") ? 1 : 0; + if (r.hasOpenGL && EGL::BindAPI(EGL_OPENGL_API) == EGL_TRUE) { + const EGLConfig config = ChooseLikeEglInfo(dpy, EGL_OPENGL_BIT, r.core); + r.compat.configFound = r.core.configFound; + if (khr) DesktopProfile(dpy, config, /*core=*/true, khr, r.core); + DesktopProfile(dpy, config, /*core=*/false, khr, r.compat); + } + if (r.hasOpenGLES && EGL::BindAPI(EGL_OPENGL_ES_API) == EGL_TRUE) { + const EGLConfig config = ChooseLikeEglInfo(dpy, EGL_OPENGL_ES_BIT, r.es); + EsProfile(dpy, config, r.es); + } + (void)EGL::Terminate(dpy); + } + const ssize_t wrote = ::write(fds[1], &r, sizeof(r)); + (void)wrote; + std::fflush(nullptr); + ::_exit(0); + } + ::close(fds[1]); + if (pid < 0) { + ::close(fds[0]); + return report; + } + pollfd pfd{fds[0], POLLIN, 0}; + if (::poll(&pfd, 1, 90000) > 0) { + if (::read(fds[0], &report, sizeof(report)) != static_cast(sizeof(report))) { + report = EglInfoReport{}; + std::snprintf(report.note, sizeof(report.note), "peer died before reporting"); + } + } else { + std::snprintf(report.note, sizeof(report.note), "peer timed out"); + ::kill(pid, SIGKILL); + } + ::close(fds[0]); + int status = 0; + ::waitpid(pid, &status, 0); + return report; + } + + std::string Describe(const char* label, const ProfileReport& p) { + char text[512]; + std::snprintf(text, sizeof(text), + "%s: config=%d created=%d current=%d eglError=0x%04x version=%d.%d vendor='%s' renderer='%s' " + "GL_VERSION='%s'", + label, p.configFound, p.created, p.madeCurrent, p.lastError, p.major, p.minor, p.vendor, + p.renderer, p.version); + return text; + } + + void ExpectProfilePrinted(const char* label, const ProfileReport& p) { + EXPECT_EQ(p.created, 1) << Describe(label, p); + EXPECT_EQ(p.madeCurrent, 1) << "the surfaceless eglMakeCurrent was refused - " << Describe(label, p); + EXPECT_EQ(p.drawIsNone, 1) << "a surfaceless binding reported a current draw surface - " << Describe(label, p); + EXPECT_NE(p.renderer[0], '\0') << "no renderer line - " << Describe(label, p); + EXPECT_NE(p.version[0], '\0') << "no version line - " << Describe(label, p); + } + +} // namespace + +TEST(EglInfoSequence, EveryProfilePrintsItsRendererAndVersion) { + ServerProcess server; + ASSERT_TRUE(LaunchSupervisor("eglinfo", &server)) << server.Log(); + const std::string clientLog = "/tmp/mgl-eglinfo-client-" + std::to_string(::getpid()) + ".log"; + Debug::TruncateRoleLogs(clientLog.c_str()); + const EglInfoReport r = RunEglInfoPeer(server, clientLog); + ASSERT_EQ(r.initialized, 1) << r.note; + // eglinfo only takes the KHR_create_context attribute form on EGL >= 1.4 with the extension. + EXPECT_EQ(r.khrCreateContext, 1) << "EGL " << r.eglMajor << "." << r.eglMinor; + ASSERT_EQ(r.hasOpenGL, 1); + ASSERT_EQ(r.hasOpenGLES, 1); + EXPECT_EQ(r.surfacelessExtensions, 1) << "the display does not advertise what it now accepts"; + std::printf("[ eglinfo ] %s\n[ eglinfo ] %s\n[ eglinfo ] %s\n", Describe("core", r.core).c_str(), + Describe("compat", r.compat).c_str(), Describe("es", r.es).c_str()); + if (r.core.madeCurrent == 1 && r.core.renderer[0] == '\0') + GTEST_SKIP() << "the surfaceless binding was accepted; the strings need headless EGL on the server"; + ExpectProfilePrinted("OpenGL core profile", r.core); + ExpectProfilePrinted("OpenGL compatibility profile", r.compat); + ExpectProfilePrinted("OpenGL ES profile", r.es); + std::error_code ec; + if (!::testing::Test::HasFailure()) { + std::filesystem::remove(Debug::RoleLogPath(clientLog.c_str(), Debug::LogRole::Client), ec); + std::filesystem::remove(Debug::RoleLogPath(clientLog.c_str(), Debug::LogRole::Server), ec); + std::filesystem::remove(clientLog, ec); + } +} diff --git a/README.md b/README.md index 5be39685..fac07295 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ MobileGL supports runtime configuration via environment variables. | `MOBILEGL_MAGMA_DISABLE_SUBGROUP` | Disable Vulkan shader subgroup support. | `0`, `1` | `0` | | `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` | +| `MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV` | Hand a shader module that fails SPIR-V validation to the driver anyway. By default Magma declines such a program (no shader module is created, its draws and dispatches are refused and logged by name), because a driver that accepts invalid SPIR-V can fault the GPU into device loss. Diagnostic use only. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1`–`64` | `3` | | `MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER` | Draws and dispatches Magma records into one command buffer before it submits the buffer mid-frame and continues on a fresh one. `0` never splits. | Integer `0`–`16777216` | `16384` | | `MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES` | Frames a frame slot's descriptor pools must stay under a quarter full before Magma frees the slot's cached descriptor sets and grown pools. `0` never trims. | Integer `0`–`1048576` | `120` |