Search Unity

Clarifying API usage requirements via improved messaging

September 12, 2022 in Technology | 9 min. read
Unity stock image of developer at work with light-up headphones
Unity stock image of developer at work with light-up headphones

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.

API protection

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:

  • Supply a state to the attribute under which the decorated function should not be called
  • Give the protection a severity specification (i.e., Exception or Error message)
  • Provide a detailed explanation on how to fix this problem

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.

Autogenerated code

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:

  • If a method in the UnityEditor.dll assembly is decorated with the PreventExecutionInStateAttribute, we’ll extract the relevant information:
    • Name of the class where the current attribute is present
      • i.e., AssetDatabase
    • Name of the method, which is decorated by the attribute
      • i.e., CreateAsset
    • Name of the enum class where the constant is defined
      • i.e., AssetDatabasePreventExecution
    • Name and value of the enum
      • i.e., kGatheringDependenciesFromSourceFile
        • We extract the name using Reflection
private string GetNameFromCustomAttribute(IBindingsPreventExecution curCheck)
{
    var name = curCheck.someObject.GetType().GetProperty("Type").GetValue(curCheck.someObject).ToString();

    //일반적으로 이름은 UnityEditor.AssetDatabasePreventExecution과 유사합니다.
    //그러나 클래스 내부에 UnityEditor.MyClass/MyEnum과 같은 형식의
    //열거형이 있을 수 있으며 여기에서 'MyEnum' 부분을 추출해야 합니다.
    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;
}
  • kGatheringDependenciesFromSourceFile = 1 << 3
    • We also extract the value using Reflection
//값을 얻는 방법은 더 간단합니다. 값은 항상 정수일 것이며 더 구체적으로는 어떤 조건이 실패했는지
//보고할 수 있어야 하므로 값은 2의 제곱일 것입니다.
//따라서 조건별로 하나의 비트가 있어야 합니다.
var flag = curCheck.someObject.GetType().GetProperty("Value").GetValue(curCheck.someObject);
int flagValue = int.Parse(flag.ToString());
  • Value of the PreventExecutionSeverity parameter
    • PreventExecution_ManagedException
      • We append the C++ enum that we’ve created to represent the severities, which is PreventExecutionSeverity
  • A message clarifies the reason why this API should not be called when the system being protected is in a particular state.
    • i.e., “Assets may not be created during the gathering of import dependencies.

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.

Making the autogenerated code legible

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 state of the asset database (g_AssetDatabasePreventExecutionBitField) is queried vs a particular condition (in this case, when we are executing the callback GatherDependenciesFromSourceFile).
    • The bit field should give as little overhead as possible, yet simultaneously allow us to detect whether a call should be prevented before it heads further down into the engine code.
  • If the condition is true, we report that we’re not allowing this call through, and output the reason why.
  • We’ll then proceed to throw a managed exception via Mono prior to being able to invoke the C++ counterpart for AssetDatabase::CreateAsset code, thus preventing an unwanted side effect of creating an asset while trying to tell Unity which asset dependencies a particular asset should have.
  • If the condition is false, we execute the native AssetDatabase::CreateAsset code as we would under normal conditions.

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.

Putting it all together

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:

Capture of AssetDatabase Test Suite view when showing 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 the change

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.

September 12, 2022 in Technology | 9 min. read
Related Posts