Search Unity

Question How does domain reloading work when you call SwitchEditorPlatform within a build script?

Discussion in 'Testing & Automation' started by geordiemhall, May 7, 2022.

  1. geordiemhall

    geordiemhall

    Joined:
    Jun 6, 2020
    Posts:
    20
    Hi there,

    At our studio we have a build system where you can define a "build set" that's basically a list of target platforms to build, along with some pre/post actions at different stages.

    Jenkins then invokes a static C# method in the build system class via the `-executeMethod` command line argument (running the editor in batch mode). That static method then looks up the "build set" you wanted (again from cmd line arg), and goes through the list of platforms defined in the configuration. For each build target it'll switch editor platform (via EditorUserBuildSettings.SwitchActiveBuildTarget), execute pre-build actions (which often set up state like changing defines or player settings) and eventually call BuildPipeline.BuildPlayer(), then any post-build actions (such as uploading or zipping). Then repeat for N build targets until the batch editor eventually exits.

    A lot of the configuration for these build sets and their actions used scriptable objects, and some recent changes I made seemed to cause the references to those SO's to become null partway through execution. I also needed to #ifdef certain parts of the build script to allow accessing platform-specific Unity APIs and settings.

    Which got me wondering... if the build script's static method calls SwitchEditorPlatform or SetDefines, which causes a domain reload (possibly with whole parts of the script itself being compiled in or out), how does this even work in the first place?

    My initial expectation is that if you trigger a recompilation via script then your script would need to stop executing. But perhaps Unity serializes the domain state and then does its best to deserialize and restore the state of memory and the instruction pointer?

    Any insight would be greatly appreciated, cause I suspect a better understanding of how everything's interacting will probably help me solve the issue (even if the answer is not to use scriptable objects but instead serialize our build configuration via plain C# types).

    Cheers,
    Geordie

    Edit: Just did a little debugging and it seems that the native object has been destroyed (so `mySO == null` returns true, but casting to get the base managed object equality operator `(object)mySO == null` returns false. Which makes sense because the assets might have been reimported as part of the build or something which killed the native version of the existing managed reference (and probably broke cause I added a == null sanity check), but other scriptable objects are still alive and well, so I'm still a bit confused...
     
    Last edited: May 7, 2022
  2. superpig

    superpig

    Drink more water! Unity Technologies

    Joined:
    Jan 16, 2011
    Posts:
    4,655
    This is exactly what it does - the docs page focuses on what happens when entering play mode, but it's similar when reloading after recompiling scripts. However, the instruction pointer is not saved and restored because we do not reload the domain while scripts are running on the main thread, so there's no managed instruction pointer to save. (Background threads get terminated).

    You probably want to set HideFlags on your SO - particularly DontUnloadUnusedAsset.
     
  3. geordiemhall

    geordiemhall

    Joined:
    Jun 6, 2020
    Posts:
    20
    Ah thanks @superpig, that does clear things up a bit.

    From what you've said I've done some more testing, and it doesn't seem like there's a way to have #ifdef'd code in your build script?

    I was trying to set some platform-specific player settings after switching editor platform, but after adding logs it seems like the build script never gets "recompiled" while it's running (which matches what you said), so the platform-specific code never gets to run between builds.

    Is there a recommended approach to this? Or do you basically need some external thing (like a bat file or other script) to invoke Unity once per platform with a specific `-buildTarget` argument so that the build script can run in that mode.

    I also tried putting the per-platform code into its own asmdef, but since they're both just set to `Editor` it doesn't seem to be recompiled either. The only other idea I haven't tried was to manually update the yaml on disk and hope that a reimport causes it to apply to the build (eg. changing subtarget or il2cpp settings for a next-gen platform that isn't in the core Unity dll).

    For example, this is basically what our current system does, and it never hits the `#if UNITY_NDA_PLATFORM` branch

    Code (CSharp):
    1.  
    2. [UsedImplicitly]
    3. private static void MyBuildScript()
    4. {
    5.     // Launch batch editor in Win64 platform
    6.     Debug.Log("Starting build set");
    7.  
    8.     Debug.Log("Runtime platform: " + Application.platform);
    9.     Debug.Log("Editor platform: " + EditorUserBuildSettings.activeBuildTarget);
    10.  
    11.     EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.NDAPlatform, BuildTarget.NDAPlatform);
    12.  
    13. #if UNITY_NDA_PLATFORM
    14.     Debug.Log("Running NDA platform code.");
    15.     Unity.NDAPlatform.PlayerSettings.someValue = true;
    16. #else
    17.     // This always seems to be run
    18.     Debug.LogError("Not running NDA platform code");
    19. #endif
    20.  
    21.     BuildPipeline.BuildPlayer(new BuildPlayerOptions(...));
    22.  
    23.     // And so on...
    24.     EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.OtherPlatform, BuildTarget.OtherPlatform);
    25.     BuildPipeline.BuildPlayer(new BuildPlayerOptions(...));
    26.  
    27.     EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.OtherPlatform, BuildTarget.OtherPlatform);
    28.     BuildPipeline.BuildPlayer(new BuildPlayerOptions(...));
    29.  
    30.     Debug.Log("Finished builds");
    31. }
    32.  
    Thanks!
     
    Last edited: May 9, 2022
  4. superpig

    superpig

    Drink more water! Unity Technologies

    Joined:
    Jan 16, 2011
    Posts:
    4,655
    I think you can do it entirely from within the Editor, but it's a bit awkward:
    • You make an SO that you can use to store state in a way that persists across domain reloads
    • You use an [InitializeOnLoad] function to set up code which gets automatically run after a domain reload
    With these things combined, you can set up a kind of state machine which can tick forward your build process after a domain reload, so that you can change the scripting defines and let everything reload, and then have your state machine carry out the next stage of the build (with the new defines in place).
     
  5. geordiemhall

    geordiemhall

    Joined:
    Jun 6, 2020
    Posts:
    20
    Thanks - tried this out yesterday and it mostly worked!

    I ended up just writing a JSON file into the library folder instead of an SO cause it felt more "transient" and easier to debug, and for anyone else reading there were only a couple other gotchas:
    • Calling `BuildPlayer()` from within the `InitializeOnLoadMethod` seemed to hard-crash the editor fairly often (usually after the first platform switch?).
      • Delaying the rest of the "resume build script" logic by a frame via `EditorApplication.delayCall` seemed to help though
    • During the above crashes, the "build script" file on disk wouldn't get cleaned up, so when I next started the Editor up it'd immediately try to resume that build
      • I ended up using the `SessionState` API to set a bool for like "build in progress", and if that wasn't true when the InitializeOnLoadMethod was called then I'd assume something went wrong and clean up accordingly (since after testing, `SessionState` seemed to always be cleared between editor launches, including crashes)
      • Maybe using an SO with `HideAndDontSave` flags would have sidestepped this problem? Assuming it persisted across domain reloads but never saved to disk?
    I also had to remove the `-quit` command line arg from our batch mode invocation, otherwise the editor would quit after switching platforms (or I guess probably after control left the initial -executeMethod)

    So with this setup we can now have platform-specific code run within the build script, which is nice! Though a downside is that the build script also has to run with the "same" defines as what we want in build. It'd be even nicer if we could just pass through "use this exact list of defines for the player build" without needing editor code to be under the same restrictions.

    And of course not being able to have multiple licenses activated at once means we still need to invoke the editor multiple times :(
     
    Last edited: Jun 6, 2022
    CameronND likes this.
  6. AdViventes

    AdViventes

    Joined:
    Dec 17, 2016
    Posts:
    11
    Thank you for writing down your solution!
    Here's my code, that maybe will help someone. It is not perfect, but I guess I got your idea with EditorApplication.delayCall.

    Code (CSharp):
    1. using UnityEditor;
    2. using System;
    3. using UnityEditor.Build;
    4. using UnityEditor.Build.Reporting;
    5.  
    6. /// <summary>
    7. /// Command to build project outside of Unity Editor. Used in CI/CD process.
    8. /// </summary>
    9. public class BuildCommandWebGL
    10. {
    11.     private const string BUILD_PARAM_KEY_BUILD_PATH = "buildPath";
    12.     private const string BUILD_PARMA_KEY_IS_DEVELOPMENT_BUILD = "isDevelopmentBuild";
    13.  
    14.     /// <summary>
    15.     /// Entry point for builds outside of Editor. Used in CI/CD process.
    16.     /// </summary>
    17.     public static void PerformBuild()
    18.     {
    19.         Console.WriteLine("Performing build state");
    20.  
    21.         var res = EditorUserBuildSettings.SwitchActiveBuildTargetAsync(BuildTargetGroup.WebGL, BuildTarget.WebGL);
    22.         Console.WriteLine(res ? "Manually set EditorUserBuildSettings" : "Manually failed set EditorUserBuildSettings");
    23.         Console.WriteLine("Currently domain should be reloading...");
    24.         EditorApplication.delayCall += AfterDomainReload;
    25.     }
    26.    
    27.     private static void AfterDomainReload()
    28.     {
    29.         Console.WriteLine("Filling _buildPlayerOptions...");
    30.         var buildPlayerOptions = new BuildPlayerOptions();
    31.         buildPlayerOptions.target = BuildTarget.WebGL;
    32.         buildPlayerOptions.targetGroup = BuildTargetGroup.WebGL;
    33.         buildPlayerOptions.locationPathName = GetPrimaryInputParam(BUILD_PARAM_KEY_BUILD_PATH);
    34.  
    35.         var isDevelopmentBuildString = GetInputParam(BUILD_PARMA_KEY_IS_DEVELOPMENT_BUILD, "true");
    36.         var isDevelopmentBuild = bool.Parse(isDevelopmentBuildString);
    37.         buildPlayerOptions.options = isDevelopmentBuild ? BuildOptions.Development : BuildOptions.None;
    38.  
    39.         buildPlayerOptions.scenes = GetEditorTypeScenes();
    40.        
    41.         Console.WriteLine("Got everything setup. Now time to build...");
    42.         var buildReport = BuildPipeline.BuildPlayer(buildPlayerOptions);
    43.         if (buildReport.summary.result != BuildResult.Succeeded)
    44.         {
    45.             throw new BuildFailedException($"Build preparation ended with {buildReport.summary.result} status");
    46.         }
    47.         Console.WriteLine($"Done with build preparation.");
    48.         EditorApplication.delayCall += ExitEditor;
    49.     }
    50.  
    51.     private static void ExitEditor()
    52.     {
    53.         Console.WriteLine($"Time to close editor...");
    54.         try
    55.         {
    56.             EditorApplication.Exit(0);
    57.         }
    58.         catch
    59.         {
    60.             Console.WriteLine($"Exception was caught, but idc...");
    61.         }
    62.     }
    63.    
    64.     private static string GetInputParam(string paramKey, string defaultValue = "")
    65.     {
    66.         var inputParams = Environment.GetCommandLineArgs();
    67.         for (var i = 0; i < inputParams.Length; i++)
    68.         {
    69.             if (inputParams[i].Contains(paramKey))
    70.             {
    71.                 return inputParams[i + 1];
    72.             }
    73.         }
    74.  
    75.         return defaultValue;
    76.     }
    77.  
    78.     private static string GetPrimaryInputParam(string paramKey)
    79.     {
    80.         var paramValue = GetInputParam(paramKey);
    81.         if (string.IsNullOrEmpty(paramValue))
    82.         {
    83.             throw new BuildFailedException($"Param with key {paramKey} is missing");
    84.         }
    85.  
    86.         Console.WriteLine($"Received input param. Key: {paramKey}, Value: {paramValue}");
    87.         return paramValue;
    88.     }
    89.  
    90.     // Warning: Hardcoded names of scenes.
    91.     private static string[] GetEditorTypeScenes()
    92.     {
    93.         return new[]
    94.         {
    95.             "Assets/Scenes/SCENE.unity",
    96.             "Assets/Scenes/Debug/SCENE.unity"
    97.         };
    98.     }
    99. }
     
    FaithlessOne likes this.