Editor/Prism/Integration/PrismHotloadIntegration.cs

Editor integration helper that bootstraps and repairs the Prism editor addon after assembly hotloads. It ensures one-time initialization, refreshes menus, flushes various Prism caches, drops stale delegates and windows, and raises a Reinitialized event so other components can rebuild.

File AccessProcess ExecutionNetworking
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Preview;
using Editor.Prism.Serialization;
using Editor.Prism.Toolchain;

namespace Editor.Prism.Integration;

/// <summary>
/// Bootstraps Prism inside the editor and puts it back together after every hotload.
/// <para>
/// <c>EditorCompiler.WatchForChanges()</c> means this assembly is swapped every time a <c>.cs</c> file
/// under <c>Editor/</c> is saved, which for the people building Prism is dozens of times an hour. Four
/// things break every single time and all four are repaired here:
/// </para>
/// <list type="number">
/// <item><description>Static caches keyed by <see cref="Type"/> hand out metadata from the outgoing
/// assembly. <c>PrismHotload</c> already drops the core ones; the rest are dropped below.</description></item>
/// <item><description>Delegates stored on long-lived engine objects — the asset browser's
/// <c>OnFileSelected</c>, our own static events — point into the old assembly.</description></item>
/// <item><description>Cached window instances are zombies: the native widget is alive so
/// <c>IsValid</c> says true, but every handler on it is dangling.</description></item>
/// <item><description><c>IAssetEditor</c>'s two static maps keep stale entries that make
/// double-clicking an asset silently do nothing.</description></item>
/// </list>
/// <para>
/// Menu and toolbar options are the one thing we do <b>not</b> have to rebuild: <c>MenuBar</c> keeps a
/// registry and tears the whole thing down and back up on the <c>refresh</c> event, which the engine
/// raises the frame after any assembly changes.
/// </para>
/// </summary>
public static class PrismHotloadIntegration
{
	static bool s_initialized;

	/// <summary>
	/// Raised after Prism has been re-armed following a hotload, so anything holding integration state
	/// can rebuild. Cleared and re-subscribed by whoever needs it — it does not survive the reload.
	/// </summary>
	public static event Action Reinitialized;

	/// <summary>True once the one-time startup work has run in this assembly instance.</summary>
	public static bool IsInitialized => s_initialized;

	// ---- startup -----------------------------------------------------------

	/// <summary>
	/// One-time startup, driven from the frame tick because there is no assembly-load callback an
	/// editor addon can hook. Cheap to call and safe to call every frame.
	/// </summary>
	public static void EnsureInitialized()
	{
		if ( s_initialized ) return;

		s_initialized = true;

		PrismLog.Guard( "Starting Prism", () =>
		{
			PrismLog.Verbose = PrismCookies.VerboseLogging;

			// Deliberately no NodeRegistry.EnsureBuilt() here. Reflecting over ~300 node types is a
			// cost every editor session would pay whether or not Prism is ever opened; the registry
			// builds itself the first time something asks for a node type.
			CodeFileEditor.ApplyCodeEditorPreference();

			// Flipping the toggle in the preferences page has to take effect without a restart.
			PrismCookies.Changed -= CodeFileEditor.ReconcileCodeEditorPreference;
			PrismCookies.Changed += CodeFileEditor.ReconcileCodeEditorPreference;

			PrismAssetEditor.PruneStaleEditors();

			// Scratch shaders from sessions that never got to clean up after themselves.
			TempWorkspace.CollectGarbage();

			PrismAutosave.ScanForRecovery();

			PrismLog.Trace( $"Prism {PrismConstants.EditorVersion} integration ready" );
		} );
	}

	/// <summary>
	/// Per-frame upkeep: the parts of integration that depend on editor widgets which may not exist
	/// yet, or which something else may have overwritten since.
	/// </summary>
	[EditorEvent.Frame]
	static void Frame()
	{
		EnsureInitialized();

		// Twice a second is plenty for "has a dock been rebuilt underneath us", and it keeps the
		// per-frame cost of having Prism installed at one integer compare.
		if ( ++s_ticks < 30 ) return;

		s_ticks = 0;

		EnsureMenusRegistered();

		if ( PrismCookies.ClaimShaderFiles ) CodeFileEditor.EnsureAssetBrowserRouting();
	}

	static int s_ticks;
	static bool s_menusRefreshed;

	/// <summary>
	/// Ask the editor to rebuild its menus once, shortly after this assembly comes up.
	/// <para>
	/// Both <c>[Menu]</c> and <c>[EditorApp]</c> entries are built by handlers of the engine's
	/// <c>refresh</c> event — <c>MenuBar.RegisterAll</c> and <c>EditorMainWindow.RebuildApps</c>. That
	/// event fires from <c>ManagedTools</c> when <c>AssembliesDirty</c> is set, and the tools enroller
	/// only sets that flag in <c>OnAssemblyRemoved</c>, never in <c>OnAssemblyAdded</c>. Bootstrap sets
	/// it once, then consumes it; the current project and its libraries are enrolled *after* that, so on
	/// a cold start our assembly joins the type library with no refresh behind it and neither entry is
	/// ever built. Every subsequent hotload works, because a reload removes the outgoing assembly and
	/// sets the flag — which is exactly why this only ever looks broken on a fresh editor launch.
	/// </para>
	/// <para>
	/// So we raise it ourselves, once per assembly instance, a beat after the frame loop starts (by
	/// which point the main window and its menu bar exist). <c>RegisterAll</c> tears down and rebuilds
	/// the whole registry, so doing it a second time after a hotload is idempotent and costs microseconds.
	/// </para>
	/// </summary>
	static void EnsureMenusRegistered()
	{
		if ( s_menusRefreshed ) return;

		s_menusRefreshed = true;

		PrismLog.Guard( "Refreshing the editor menus so Prism's entries appear",
			() => EditorEvent.Run( "refresh" ) );
	}

	// ---- hotload -----------------------------------------------------------

	/// <summary>
	/// Runs after <c>PrismHotload.FlushAll</c> — hence the priority — and repairs everything the core
	/// flush does not know about.
	/// </summary>
	[EditorEvent.Hotload( Priority = 100 )]
	static void OnHotload()
	{
		PrismLog.Guard( "Rebuilding Prism after a hotload", () =>
		{
			FlushRemainingCaches();
			DropStaleDelegates();
			DropStaleWindows();
			PrismAssetEditor.PruneStaleEditors();

			// The frame tick re-arms routing and re-runs startup against the new assembly.
			s_initialized = false;

			PrismLog.Guard( "Raising PrismHotloadIntegration.Reinitialized", () => Reinitialized?.Invoke() );
		} );
	}

	/// <summary>
	/// Caches <c>PrismHotload.FlushAll</c> does not reach. It owns the node registry, port reflection,
	/// node properties, backends, the subgraph cache and the legacy import table; these are the rest.
	/// </summary>
	static void FlushRemainingCaches()
	{
		PrismLog.Guard( "Flushing schema migrations", SchemaMigrations.Reset );
		PrismLog.Guard( "Flushing node descriptors", NodeDescriptors.Flush );
		PrismLog.Guard( "Flushing preview meshes", PreviewMeshes.Flush );

		// The lexers, language database, intrinsic tables, s&box symbol table, include resolver and
		// document-symbol index. PrismHotload.FlushAll already calls this and runs first — it is
		// priority 0 against this handler's 100 — but the two are independent registrations and the
		// ordering guarantee is the event system's, not ours. TextCaches.Flush is idempotent by
		// construction, so calling it twice costs a few null assignments and removes the ordering
		// dependency entirely.
		PrismLog.Guard( "Flushing the text caches", Text.TextCaches.Flush );

		// Asynchronous on purpose. Probing spawns slangc and waits for it, and this runs inside an
		// [EditorEvent.Hotload] handler on the main thread — so with a toolchain installed every single
		// hotload used to block the editor on a process start. Nothing reads the result synchronously;
		// SlangToolchain.Changed is what the status chip and the preferences page listen to.
		PrismLog.Guard( "Flushing the Slang toolchain probe", () => _ = SlangToolchain.ProbeAsync( true ) );
	}

	/// <summary>
	/// Hand back or drop every delegate we installed on something that outlives the assembly.
	/// <para>
	/// The asset browser's <c>OnFileSelected</c> is the dangerous one: it is a public field on a widget
	/// that survives the reload, so the handler sitting in it belongs to the outgoing assembly. It is
	/// re-armed by the frame tick; what matters here is forgetting the chain we captured, or the next
	/// install would chain a dead delegate onto a live one and grow a link on every reload.
	/// </para>
	/// <para>
	/// There is nothing to do about <c>FileWatch</c>: Prism deliberately owns none. Change detection
	/// goes through the engine's <c>content.changed</c> event instead, which already watches every
	/// active project's <c>Assets/</c> folder and hands us a path rather than holding a lambda it will
	/// outlive.
	/// </para>
	/// </summary>
	static void DropStaleDelegates()
	{
		PrismLog.Guard( "Dropping stale Prism delegates", () =>
		{
			CodeFileEditor.ForgetRouting();
			CodeFileEditor.FlushFallback();
			AssetHooks.ClearSubscribers();
			PrismCookies.ClearSubscribers();
			PrismCookies.FlushCache();
			PrismAutosave.ClearSubscribers();
		} );
	}

	/// <summary>
	/// Re-check every window Prism holds a reference to.
	/// <para>
	/// Deliberately not a reset. The hotload system replaces instances of swapped types in place and
	/// fixes up the fields that point at them, so a window the user had open is still open and still
	/// theirs — throwing the reference away would open a duplicate next time and silently stop
	/// autosaving the document in the old one. Only entries that failed to survive are dropped.
	/// </para>
	/// </summary>
	static void DropStaleWindows()
	{
		PrismLog.Guard( "Revalidating Prism windows", () =>
		{
			PrismDocumentation.Revalidate();
			PrismPreferences.Revalidate();
			PrismAutosave.Revalidate();
		} );
	}
}