Skip to content

[Patch] Say why a rendering extension's shader library or pipeline failed to load - #1215

Merged
untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:bugfix/render_extension_shader_diagnostics
Sep 23, 2026
Merged

untoldengine merged 2 commits into
untoldengine:developfrom
miolabs:bugfix/render_extension_shader_diagnostics

Conversation

@miogds

@miogds miogds commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Say why a rendering extension's shader library or pipeline failed to load

When a plugin's metallib could not be created, RenderShaderLibraryManager.load caught the error Metal threw and recorded a bare metallibCreationFailed(libraryID:resource:subdirectory:). The registry then logged one warning, Extension '…' cannot load shaders: Failed to create shader library '…' from bundled metallib '….metallib', removed the extension and invalidated the plugin. A plugin that rendered nothing left no explanation behind.

The case that motivated this (untoldengine/UntoldArcade#23): a plugin metallib compiled by Xcode 27 without -mtargetos was stamped for visionOS 27, and Metal rejected it on a visionOS 26 device with

This library is using a deployment target (0x001B0000) that is not supported on this visionOS.

That text never reached the log, so the reviewer only saw "not working". The engine's own metallib path (createXR → handleError(.metalLibraryNotFound, error.localizedDescription)) already logs the reason at error level; the extension path now does the same.

Changes

  • RenderShaderLibraryLoadingError.defaultLibraryCreationFailed, .metallibCreationFailed and .libraryCreationFailed carry a reason: String and end their description with it. The reason is the NSError's localizedDescription for Metal and Foundation errors, errorDescription for a LocalizedError, and String(describing:) otherwise, so a plain Swift error reads as its case name instead of "The operation couldn't be completed".
  • RenderExtensionPipelineError.creationFailed carries a reason too. The internal pipeline creator protocol now throws, and CreatePipeline / CreateComputePipeline are thin wrappers over throwing cores (buildRenderPipeline, buildComputePipeline, internal PipelineCreationError), so Metal's error reaches the registry. Their public signatures and behaviour are unchanged, except that their own pipelineStateCreationFailed log line now includes the reason as well. The legacy init-block registrations say whether the block returned nil or a pipeline with success == false.
  • Extension shader-library and pipeline failures, and the out-of-transaction fallbacks, are logged with Logger.logError instead of logWarning. Resource-validation, conflict and graph-validation rejections still log as warnings; I left those as they were.

With this change the log line for the arcade case reads:

Error: [RenderExtension] Extension 'com.miolabs.splat' cannot load shaders: Failed to create shader library 'com.miolabs.splat.shaders' from bundled metallib 'SplatShaders-xros.metallib' in 'Shaders': This library is using a deployment target (0x001B0000) that is not supported on this visionOS.

Source compatibility

Constructing or exhaustively matching the four cases above needs the new reason argument; nothing else in the public API changes. Only the engine and its tests construct them (the editor, arcade and examples do not reference these cases). I did not give reason a default value on purpose: a silently empty reason is the gap this fixes.

Tests

  • New Tests/UntoldEngineTests/RenderExtensionDiagnosticsTests.swift covers every case's description and the reason helper, including a Metal-style NSError carrying the deployment-target message.
  • RenderShaderLibraryPackagingTest: the fake loader throws a configurable error; the invalid-metallib test expects the reason, and new tests cover a Metal NSError reason, a failing default library and a failing URL library.
  • RenderExtensionPipelineDescriptorTest: the fake creator throws; the two creation-failure tests expect the reason, and new tests cover compute creation failure and an init block returning an unsuccessful pipeline.

Verified locally on Xcode 27.0: swift test --filter UntoldEngineTests, the two render-extension test classes, swift build of every product, the external SwiftPackagePlugin fixture, and swiftformat --lint --swiftversion 5.8 on the changed files.

@untoldengine

Copy link
Copy Markdown
Owner

Wow, 170 commits. can we squash them please.
@miogds

@untoldengine

Copy link
Copy Markdown
Owner

Also, it seems that some files that were changed have nothing to do with the PR description. For example, MotionDatabase.swift. I think you just wanted to do a PR on bcff749
but everything else came along with it.

@miogds

miogds commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

let me review because theres too much and wasn't suppose to be there.

…iled to load

When a plugin's metallib could not be created, RenderShaderLibraryManager
discarded the error Metal threw and recorded a bare
`metallibCreationFailed`, so the only trace of a plugin that rendered
nothing was a warning naming the resource. The case that motivated this
was a metallib compiled by Xcode 27 without -mtargetos and rejected on a
visionOS 26 device with "This library is using a deployment target that
is not supported"; that text never reached the log.

- `RenderShaderLibraryLoadingError.defaultLibraryCreationFailed`,
  `.metallibCreationFailed` and `.libraryCreationFailed` carry a
  `reason` (the NSError's localizedDescription for Metal and Foundation
  errors, the Swift description otherwise) and print it.
- `RenderExtensionPipelineError.creationFailed` carries a `reason` too.
  The pipeline creator protocol now throws, and `CreatePipeline` and
  `CreateComputePipeline` are wrappers over throwing cores
  (`buildRenderPipeline`, `buildComputePipeline`) so the Metal error
  reaches the registry; their own `handleError` message now includes
  it as well. The legacy init-block registrations say whether the
  block returned nil or a pipeline with `success == false`.
- Extension shader-library and pipeline failures are logged with
  `Logger.logError` instead of `logWarning`, matching the engine's own
  metallib failure path.

Source compatibility: constructing or exhaustively matching the four
cases above needs the new `reason` argument; nothing else changes.
Only the engine and its tests construct them.
@miogds

miogds commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Sorry about that. The branch was cut from our fork's develop, which carries motion-matching work that isn't upstream yet. Rebased onto upstream develop: one commit, 8 files, only the shader-diagnostics change.

@miogds
miogds force-pushed the bugfix/render_extension_shader_diagnostics branch from bcff749 to af6864f Compare September 20, 2026 16:53
@untoldengine

Copy link
Copy Markdown
Owner

Hey @miogds , nice fix overall — this is going to save a lot of head-scratching next time a plugin silently fails to render. One thing I noticed while going through it though:

In CreatePipeline (RenderPipeLines.swift) and CreateComputePipeline (ComputePipelines.swift), the final catch-all does:

} catch {
    handleError(.pipelineStateCreationFailed, "\(name): \(failureReason(for: error))")
}

By the time execution reaches this branch, error is always PipelineCreationError.pipelineStateCreationFailed(underlying:) — it's the only case still standing after the .missingShaderLibrary/.missingFunction catches above it grab everything else. The problem is failureReason(for:) doesn't know how to unwrap PipelineCreationError — it's not LocalizedError and it's not an NSError, so it falls back to String(describing: error). That means instead of getting the nice clean Metal message, you get something like:

Model Pipeline: pipelineStateCreationFailed(underlying: Error Domain=MTLLibraryErrorDomain Code=1 "This library is using a deployment target..." UserInfo=...)

when what we actually want (and what the extension path already gets right!) is just:

Model Pipeline: This library is using a deployment target (0x001B0000) that is not supported on this visionOS.

It seems you already wrote the fix for this — RenderExtensionPipelineFailureReason.describe(_:) unwraps PipelineCreationError via its .reason property correctly.

Easy fix, something like:

} catch {
    let reason = (error as? PipelineCreationError)?.reason ?? failureReason(for: error)
    handleError(.pipelineStateCreationFailed, "\(name): \(reason)")
    return nil // just for CreatePipeline
}

in both spots — or just call RenderExtensionPipelineFailureReason.describe(error) directly so there's one place that knows how to unwrap it.

Also, totally optional, but a quick test asserting the final log string here (or at least failureReason behavior against a PipelineCreationError) would've caught this — might be worth adding so it doesn't creep back in.

Thanks

… line

Review follow-up on untoldengine#1215. CreatePipeline and CreateComputePipeline
passed the thrown PipelineCreationError to failureReason(for:), which
did not know the type and fell back to String(describing:), so the
engine's own log line read
`pipelineStateCreationFailed(underlying: Error Domain=...)` instead of
Metal's message. The extension path was unaffected because it unwrapped
the error itself.

PipelineCreationError is now a LocalizedError whose errorDescription
is its reason, so failureReason(for:) is the one place that describes
a thrown error for both paths, and the registry's separate describe
helper is gone. A unit test asserts failureReason(for:) against a
PipelineCreationError wrapping a Metal NSError.
@miogds

miogds commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, thanks. PipelineCreationError is now a LocalizedError whose errorDescription is its reason, so failureReason(for:) unwraps it in one place for both the engine log line and the extension diagnostics, and the registry's own describe helper is gone. Added the test you suggested; it failed before the fix with exactly the output you quoted.

@untoldengine
untoldengine merged commit ccd8ece into untoldengine:develop Sep 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants