fix: ship a link.xml so IL2CPP stripping cannot empty crash responses - #220
Conversation
BugSplatResponse is only ever produced by JsonUtility.FromJson, so nothing in the IL references its fields or its parameterless constructor. Under Medium or High managed stripping the linker is free to remove them, and the failure is silent: callbacks still receive a BugSplatResponse, only with a null infoUrl and a crashId of 0. UnityLinker globs Assets/**/link.xml and nothing else (UnityCsReference AssemblyStripper.GetUserBlacklistFiles), so a link.xml shipped inside a UPM package is never read. BugSplatLinkXmlProcessor implements IUnityLinkerProcessor.GenerateAdditionalLinkXmlFile to name the packaged file, which is the mechanism Unity points packages at. When the sources are copied under Assets/ rather than installed as a package the PackageInfo lookup returns null and UnityLinker's own glob already covers the file. The vendored BugSplatDotNetStandard.dll is deliberately not preserved: every entry point the player uses is called directly, and its JSON handling is manual (JsonReaderWriterFactory/XElement) rather than reflective, so there is nothing for the linker to miss. Unity's own scene-type and serialized-type roots already cover BugSplatOptions, BugSplatAttribute and BugSplatManager. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses an IL2CPP managed-stripping issue where BugSplatResponse fields (and its implicit parameterless constructor) can be stripped because they’re only accessed via JsonUtility.FromJson, resulting in empty/null crash response data at runtime. It adds a package-hosted link.xml plus an Editor callback to ensure UnityLinker consumes that descriptor even though it doesn’t scan Packages/** for link.xml.
Changes:
- Add
Runtime/link.xmlto preserveBugSplatUnity.Runtime.Reporter.BugSplatResponsefrom managed stripping. - Add
Editor/BugSplatLinkXmlProcessor.csimplementingIUnityLinkerProcessorto supply the packagelink.xmlto UnityLinker during builds. - Add Unity
.metafiles for the new assets/scripts.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Runtime/link.xml | Adds UnityLinker descriptor preserving BugSplatResponse needed for JsonUtility deserialization under stripping. |
| Runtime/link.xml.meta | Unity asset metadata for Runtime/link.xml. |
| Editor/BugSplatLinkXmlProcessor.cs | UnityLinker processor that provides the package link.xml path during build. |
| Editor/BugSplatLinkXmlProcessor.cs.meta | Unity asset metadata for the new editor script. |
Files not reviewed (2)
- Editor/BugSplatLinkXmlProcessor.cs.meta: Generated file
- Runtime/link.xml.meta: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(BugSplatLinkXmlProcessor).Assembly); | ||
| if (package == null) | ||
| { | ||
| // Sources were copied under Assets/ rather than installed as a package, which puts | ||
| // link.xml somewhere UnityLinker already looks. | ||
| return null; | ||
| } | ||
|
|
||
| var linkXml = Path.Combine(package.resolvedPath, "Runtime", "link.xml"); | ||
| if (!File.Exists(linkXml)) | ||
| { | ||
| Debug.LogWarning($"BugSplat warning: {linkXml} is missing, managed stripping may leave crash report responses empty"); | ||
| return null; | ||
| } | ||
|
|
||
| return linkXml; | ||
| } |
There was a problem hiding this comment.
Fixed in 8fd87f1 — when FindForAssembly returns null the processor now checks Packages/com.bugsplat.unity/Runtime/link.xml (the same fallback root PostBuild.cs uses) and hands that to UnityLinker if it exists; only when the fallback is also absent does it conclude the sources live under Assets/ and return null. The missing-file warning stays limited to the confirmed package-install path so Assets-based installs don't get a false warning.
PostBuild.cs already treats a null PackageInfo.FindForAssembly result as possible for package installs and falls back to Packages/com.bugsplat.unity; the link.xml processor instead concluded the sources were copied under Assets/ and silently skipped the descriptor. Try the package root first and only treat the install as Assets-based when no link.xml exists there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #157
BugSplatResponseis only ever produced byJsonUtility.FromJson(Runtime/Reporter/DotNetStandardExceptionReporter.cs:289andRuntime/Client/WebGLExceptionClient.cs:78). Nothing in the IL references its fields or its parameterless constructor, so Medium/High managed stripping is free to remove them. There is no build error and no runtime exception, just aBugSplatResponsewith a nullinfoUrland acrashIdof 0 handed to everyPostcallback, on exactly the IL2CPP configuration the README recommends.Where the link.xml had to go, and why there is a build callback
A
link.xmlat the package root would have been silently ignored. Unity's manual says it plainly (Preserving code):The editor source agrees —
UnityCsReferenceEditor/Mono/BuildPipeline/AssemblyStripper.cscollects user descriptors with exactly one glob, andPackages/is not in it:The same method adds
ProcessBuildPipelineGenerateAdditionalLinkXmlFiles(args), which callsIUnityLinkerProcessor.GenerateAdditionalLinkXmlFileon every registered processor and keeps whatever paths exist. That is the supported route for a package, so:Runtime/link.xmlholds the descriptor, next to the assembly it preserves and reviewable as a plain file.Editor/BugSplatLinkXmlProcessor.csimplementsIUnityLinkerProcessorand returns that file's absolute path, resolved throughPackageInfo.FindForAssemblyso it works fromLibrary/PackageCache, from an embedded package, and from a tarball install alike.Assets/instead of installed as a package,FindForAssemblyreturns null and the processor returns null — UnityLinker's ownAssets/**glob already finds the file, and Unity filters null/missing paths (.Where(p => p?.FileExists() ?? false)).What is preserved, and the granularity choice
One type. No
preserve="all"on an assembly, and no wildcards.preserve="all"at the type level rather thanpreserve="fields"becauseJsonUtilityneeds the compiler-generated parameterless constructor as well, and a constructor is a method —fieldswould keep the three fields and let the linker drop the thing that instantiates them. Metadata for the compiled type is exactlystatus,infoUrl,crashIdand.ctor(dumped below), soallpreserves nothing beyond what deserialization requires.I audited the whole
Runtime/tree for other members reachable only by reflection or serialization:BugSplatResponseJsonUtility.FromJson.BugSplatDotNetStandard.dll(vendored)BugSplatOptions,BugSplatAttribute,BugSplatManagerAssemblyStripper.WriteTypesInScenesBlacklistandWriteSerializedTypesBlacklistgenerateTypesInScenes.xml/SerializedTypes.xmlfor precisely this. Repeating them buys nothing.Runtime/BugSplat.cscalls out throughDllImportandAndroidJavaClassonly; there are no[MonoPInvokeCallback]delegates, noAndroidJavaProxysubclasses and noUnitySendMessagetargets, i.e. nothing that native code enters by name.Type.GetType,GetMethod,GetPropertyorActivatoruse anywhere underRuntime/.Why the vendored DLL is not preserved
The issue suggested preserving
BugSplatDotNetStandard.dll; I checked what it actually needs and concluded it does not.BugSplat.Post,CrashPostClient,FormDataParam, …) is invoked by direct calls fromDotNetStandardClient/DotNetStandardExceptionReporter, so the linker's static analysis keeps them.Http/JsonObject.csparses responses by hand viaJsonReaderWriterFactoryinto anXElementand queries it, andJsonSerializerbuilds JSON by string concatenation — no POCO mapping, noDataContractJsonSerializer, noSystem.Reflection. A strings dump of the shipped DLL matches the upstream source (CreateJsonReader,XmlDictionaryReader,XmlDictionaryReaderQuotas; noDataContract, noActivator, noGetMethod/GetProperty/GetField).preserve="all"on it would pin the OAuth2 and symbol-upload code that only the Editor ever calls into every player build, which is the opposite of what a crash reporter should cost.If a future version of that DLL starts deserializing into types, this decision has to be revisited — the reasoning is recorded in
Runtime/link.xmlso the omission reads as deliberate rather than forgotten.How each name was verified
A
link.xmlnaming an assembly or type that does not exist is silently useless, so every string in it was checked against the built artifact, not just against the source:[xml],System.Xml.Linq.XDocument.Load, and Pythonxml.dom.minidom. All three round-trip the document and exposeassembly/@fullnameandtype/@fullnameas intended (the explanatory comments do not swallow the elements).Runtime/BugSplat.Unity.Runtime.asmdefdeclares"name": "BugSplat.Unity.Runtime", which is also the name CI'sCompileCheckasserts (BugSplat.Unity.Runtime.dll).BugSplatUnity.Runtime.Reporter(Runtime/Reporter/IExceptionReporter.cs:5), top-level classBugSplatResponse(line 14), so no nested-type/syntax applies.Runtime/**/*.csintoBugSplat.Unity.Runtime.dllwith Roslyn against the assemblies of a local Unity 6000.5.6f1 install plus the vendored DLL, then read the result's metadata directly (PEReader/MetadataReader) and diffed it against the link.xml:Editor/BugSplatLinkXmlProcessor.cswas compiled with Roslyn against Unity 6000.5.6f1'sUnityEditor.dll/UnityEngine.dll/UnityEngine.CoreModule.dll(exit 0), which checksUnityEditor.Build.IUnityLinkerProcessor, theGenerateAdditionalLinkXmlFile(BuildReport, UnityLinkerBuildPipelineData)signature, andPackageInfo.FindForAssembly(...).resolvedPathagainst the shipping API rather than from memory. The interface declaration inUnityCsReferenceconfirmscallbackOrderis its only other member (the oldOnBeforeRun/OnAfterRunare gone), andBuildPipelineInterfacesinstantiates processors viaActivator.CreateInstanceoffTypeCache.GetTypesDerivedFrom<IOrderedCallback>(), which an internal class with the implicit public constructor satisfies.Not verified here
I cannot run Unity or produce a stripped IL2CPP player in this environment, so what is proven is that the descriptor is well-formed, that its names match the real assembly and type, and that the callback that delivers it compiles against the real editor API. End-to-end proof — an IL2CPP player built at Medium/High stripping that posts a report and gets back a populated
infoUrland non-zerocrashId— is deferred to the release smoke test (#200). The most useful thing to watch for there is the stripping level: atMinimalthe bug does not reproduce, so the smoke test needsMediumorHighto be meaningful.🤖 Generated with Claude Code