The conditions that an API can or should be called are sometimes unclear – and lacking this information often leads to writing code with unintended side effects.
As the number of use cases that the Unity Editor caters for increases, so do the different ways that APIs are used. Uninitialized systems can become unstable if APIs attempt to use them directly. Deleting a scene while opening it or trying to load an asset in the middle of an import can lead to inconsistent results – or even worse, a crash.
As part of our improved messaging when working with assets in-Editor, we’ve started restricting particular APIs from being invoked under certain conditions. This initiative serves to prevent hard-to-fix bugs, disorganized workflows, and other inconsistencies from becoming a habit.
We created a new internal attribute added to the APIs that ship with the Unity Editor. The attribute autogenerates code in the native bindings layer between C# and C++ when we compile the Editor code.
[PreventExecutionInState]
More specifically, we designed this solution to do the following:
For example, this is how AssetDatabase.CreateAsset has been decorated inside AssetDatabase.bindings.cs:
[PreventExecutionInState
(
AssetDatabasePreventExecution.kGatheringDependencies
FromSourceFile, PreventExecutionSeverity.PreventExecution_ManagedException,
"Assets may not be created during gathering of import dependencies"
)]
You cannot create an asset by calling AssetDatabase.CreateAsset during the callback GatherDependenciesFromSourceFile, when only dynamic dependencies are supposed to be given back to the AssetDatabase so that it can determine where an asset should be imported in the import queue. Once this attribute has been put in place, it can be used to drive the code generation, which ships with the Unity Editor.
When the Unity Editor source code is compiled (both C# and C++), there is an autogenerated C++ code layer that defines all C# functions. These functions are marked as “extern” inside the .bindings.cs files, whereas the generated C++ file is clearly marked as an autogenerated file.
To be able to add the control to the APIs, our code autogeneration tool was expanded to consider the new C# attribute and translate it into something actionable on the C++ side of things. This acts as the layer in which we can provide better information on how to use an API, in case it has been invoked when it should not have been.
C# code (AssetDatabase.bindings.cs)
[NativeThrows]
[PreventExecutionInState(
AssetDatabasePreventExecution.kGatheringDependenciesFromSourceFile,
PreventExecutionSeverity.PreventExecution_ManagedException,
"Assets may not be created during gathering of import dependencies"
)]
extern public static void CreateAsset([NotNull] Object asset, string path);
The autogenerated code is written via a standalone C# tool, which will do the following for the PreventExecutionInStateAttribute:
private string GetNameFromCustomAttribute(IBindingsPreventExecution curCheck)
{
var name = curCheck.someObject.GetType().GetProperty("Type").GetValue(curCheck.someObject).ToString();
//Generally speaking, the name will look like UnityEditor.AssetDatabasePreventExecution,
//However, we could have an enum inside a class which could take the form of:
//UnityEditor.MyClass/MyEnum and we need to make sure we can
//extract the "MyEnum" portion here by handling that case
var splitName = name.Split('.');
var lastEntry = splitName[splitName.Length - 1];
int start = lastEntry.LastIndexOf("/") + 1;
var fixedName = lastEntry.Substring(start, lastEntry.Length - start);
return fixedName;
}
//Getting the value is more straightforward, as we expect it to always be an integer
//and more specifically a power or two value since we must be able to report
//which condition failed, so having a single bit per condition is required
var flag = curCheck.someObject.GetType().GetProperty("Value").GetValue(curCheck.someObject);
int flagValue = int.Parse(flag.ToString());
Additionally, if there are multiple instances of the PreventExecutionInStateAttribute placed on top of the function, their conditions will be merged into a single check to give as little a performance hit as possible when invoking these APIs.
Once we have all the information available, we have to generate the equivalent C++ code from it. Each portion of the autogenerated C++ code can be traced back to the original attribute definition in C# (i.e., the method CreateAsset is mapped to the string “CreateAsset” in the autogenerated C++ code below).
Finally, when all the information has been extracted, the autogenerated code will be created, along with variables, namespaces, and classes that use the naming from the PreventExecutionInStateAttribute.
Autogenerated C++ code
if(g_AssetDatabasePreventExecutionBitField & AssetDatabasePreventExecutionChecks::kGatheringDependenciesFromSourceFile)
AssetDatabasePreventExecution::ReportExecutionPrevention(
"CreateAsset",
AssetDatabasePreventExecutionChecks::kGatheringDependenciesFromSourceFile,
PreventExecutionSeverity::PreventExecution_ManagedException,
"Assets may not be created during gathering of import dependencies",
&exception
);
The following then occurs:
The end result of this is that the C++ counterpart of the API will only be executed when it’s supposed to, and provide a reasonable error that should help rectify the situation.
You might have also noticed that g_AssetDatabasePreventExecutionBitField contains the AssetDatabasePreventExecution in the name. This is done on purpose, so that other systems within Unity can similarly define their own API protection and prevent undesirable uses of their APIs upfront.
To ensure that we catch changes to the behavior of this API, we have a number of tests that have been added to our AssetDatabase Test Suite. We expect that a managed exception is thrown whenever CreateAsset is called from within a call to GatherDependenciesFromSourceFile:
We have test coverage for this scenario (and various others), and by extension, increased confidence that things will work as they should.
Landing an API protection change can be considered a behavior change. As such, we take care to provide strong messaging. However, the biggest challenge we face right now is how this can impact our community, as various Asset Store packages, user projects, libraries, and API examples rely on the API. We will start by looking at how many failing tests we have internally when making the change, and use that information to gauge the magnitude of an API protection change (note that we might actually consider not making the change if too many systems rely on the current behavior).
Fortunately, GatherDependenciesFromSourceFile is a relatively new callback, and not many ScriptedImporters are using it. If, for example, something like OnPostProcessAllAssets were to get a restriction on calls to AssetDatabase.FindAssets (don’t worry, we’re not actually planning to do this!), then we would have a lot of pushback from both internal tests and our users, as it is common to call AssetDatabase.FindAssets from within OnPostProcessAllAssets.
If you would like to discuss this article, or share your ideas after reading it, head on over to the discussion thread on our Asset Database forum. You can also follow me on Twitter at @jav_dev, and check out new and upcoming technical blogs from our developers in the ongoing Tech from the Trenches series.