// MIT License - Copyright (c) 2026 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.AssetProcessors
{
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEditor;
using UnityEngine;
using WallstopStudios.UnityHelpers.Editor.Settings;
///
/// Shared deferral primitive for callbacks.
/// Routes work out of Unity's asset-import phase via EditorApplication.delayCall
/// so that APIs like AssetDatabase.LoadAllAssetsAtPath and component queries do
/// not trigger Unity's "SendMessage cannot be called during Awake, CheckConsistency, or
/// OnValidate" warnings relayed from internal sprite/renderer lifecycle notifications.
///
internal static class AssetPostprocessorDeferral
{
private static readonly List PendingDrains = new();
private static bool _scheduled;
private static bool _draining;
private static int? _mainThreadId;
///
/// Enqueues to run one editor tick after the current
/// asset-import phase completes. Invocations are deduplicated by delegate
/// reference (using , not
/// ): scheduling the same delegate
/// reference multiple times before the drain fires coalesces into a single
/// invocation. Structurally-equal-but-distinct delegates (for example,
/// lambdas produced by a local function that captures only outer-method
/// variables — the C# compiler lowers all such lambdas to the same Method
/// and Target) are intentionally NOT deduplicated: callers cache their drain
/// in a static readonly field so the dedup target is identity-based
/// (see .llm/skills/asset-postprocessor-safety.md). If
/// is
/// , drains inline for users who require synchronous
/// callback invocation.
///
internal static void Schedule(Action drain)
{
if (drain == null)
{
return;
}
AssertOnMainThread();
if (!ShouldDefer())
{
// Setting is disabled: run inline. Any items already queued from a
// prior deferred schedule (before the setting was toggled) remain
// scheduled via the existing delayCall and will still fire one tick
// later. This is the intended behavior — the toggle changes the
// mode for future calls, not retroactively for in-flight work.
RunSafely(drain);
return;
}
// Per-caller dedup: if the same delegate REFERENCE is already pending,
// skip the append rather than invoking it twice in one drain batch.
// Intentional reference-equality (not structural Delegate.Equals):
// callers cache their drain in a static readonly field (see
// .llm/skills/asset-postprocessor-safety.md), so the dedup target is
// identity-based. Using List.Contains would invoke
// Delegate.Equals and collapse structurally-equal-but-semantically-distinct
// lambdas — for example, lambdas produced by a local function that
// captures only outer-method variables all share the same Method+Target
// because the compiler lowers them onto the outer method's display class.
bool alreadyPending = false;
for (int i = 0; i < PendingDrains.Count; i++)
{
if (ReferenceEquals(PendingDrains[i], drain))
{
alreadyPending = true;
break;
}
}
if (!alreadyPending)
{
PendingDrains.Add(drain);
}
if (_scheduled)
{
return;
}
_scheduled = true;
EditorApplication.delayCall += DrainScheduled;
}
///
/// Safety cap on iterations. A handler whose
/// drain re-schedules itself (directly or transitively) would loop forever;
/// bounds that to the smallest number that
/// still absorbs realistic reentrant fan-out (tests that create N assets,
/// each of whose handlers re-schedules a cleanup). Reaching the cap surfaces
/// a warning so the caller can investigate rather than silently leaking drains.
///
private const int FlushIterationCap = 32;
///
/// Synchronously drains any pending actions, iterating until the queue is
/// stable so a drain that reentrantly calls does
/// not leave items in the queue for the next test's setup to inherit.
/// Intended for tests to avoid yielding an editor frame.
///
/// Bounded by iterations to prevent a
/// buggy handler that re-schedules itself from hanging the test run; if
/// the cap is hit, the method returns with drains still pending, logs a
/// warning, and those drains fire on the next editor tick (potentially
/// polluting the next test — the warning is the caller's signal to
/// investigate).
///
/// Note on dormant delayCalls: when a drain appends to
/// during its execution,
/// re-arms an
/// subscription for the next editor tick. This loop then drains that
/// queue synchronously in the next iteration, so the delayCall (when it
/// eventually fires) observes an empty queue and returns as a harmless
/// no-op. Within a single reentrant iteration, at most ONE dormant
/// delayCall is registered: both and
/// gate on _scheduled and will not
/// double-register. Across a full flush cycle, however, the top of each
/// iteration clears _scheduled = false, so up to
/// dormant DrainScheduled
/// callbacks can accumulate on
/// — each one a harmless no-op when it fires. Editor-tick telemetry may
/// therefore show between zero and
/// no-op DrainScheduled invocations per flush cycle (zero when
/// no reentrant appends happened, one per iteration that had them).
///
internal static void FlushForTesting()
{
if (_draining)
{
// Calling FlushForTesting from inside a drain callback is always a
// test bug: the flush cannot reliably drain the queue it is already
// iterating. Warn loudly rather than silently no-op so the caller
// notices. We still return early — throwing would abort the outer
// drain mid-iteration.
Debug.LogWarning(
"FlushForTesting called reentrantly during drain — flush is a no-op; "
+ "ensure tests don't call FlushForTesting from a handler callback."
);
return;
}
for (int iteration = 0; iteration < FlushIterationCap; iteration++)
{
// Clear the scheduled flag before draining so the invariant holds
// even if a drain action calls Schedule() reentrantly (the
// reentrant Schedule will append to PendingDrains and re-arm the
// delayCall via DrainPending's fallback).
_scheduled = false;
DrainPending();
if (PendingDrains.Count == 0)
{
_scheduled = false;
return;
}
// Reentrant append(s) happened — DrainPending re-armed delayCall
// to fire them in the next editor tick. Clear the flag so the next
// loop iteration takes ownership synchronously rather than racing
// the tick.
}
Debug.LogWarning(
"FlushForTesting hit the iteration cap ("
+ FlushIterationCap
+ ") with "
+ PendingDrains.Count
+ " drain(s) still pending. A drain handler is likely re-scheduling itself. "
+ "Remaining drains will fire on the next editor tick, which may pollute the next test."
);
}
private static void DrainScheduled()
{
_scheduled = false;
DrainPending();
}
private static void DrainPending()
{
if (PendingDrains.Count == 0)
{
return;
}
if (_draining)
{
return;
}
_draining = true;
try
{
// Drain a snapshot and clear the pending queue before invocation so
// reentrant Schedule() calls can enqueue the next batch (including
// self-reschedules of the currently-running delegate) rather than
// being deduplicated against the active batch.
Action[] drainsToRun = PendingDrains.ToArray();
PendingDrains.Clear();
for (int i = 0; i < drainsToRun.Length; i++)
{
RunSafely(drainsToRun[i]);
}
// Reentrant additions happened while draining. Re-arm delayCall so
// they run in the next editor tick.
if (PendingDrains.Count > 0 && !_scheduled)
{
_scheduled = true;
EditorApplication.delayCall += DrainScheduled;
}
}
finally
{
_draining = false;
}
}
private static void RunSafely(Action drain)
{
try
{
drain();
}
catch (Exception ex)
when (ex is not OutOfMemoryException and not StackOverflowException)
{
Debug.LogException(ex);
}
}
private static bool ShouldDefer()
{
try
{
return UnityHelpersSettings.GetDeferAssetPostprocessorCallbacks();
}
catch (Exception ex)
when (ex is not OutOfMemoryException and not StackOverflowException)
{
// Settings inaccessible (e.g. during domain reload); default to the
// safe behavior.
Debug.LogException(ex);
return true;
}
}
[InitializeOnLoadMethod]
private static void RegisterDomainCleanup()
{
_mainThreadId = Thread.CurrentThread.ManagedThreadId;
AssemblyReloadEvents.beforeAssemblyReload -= ResetForDomainReload;
AssemblyReloadEvents.beforeAssemblyReload += ResetForDomainReload;
}
private static void ResetForDomainReload()
{
PendingDrains.Clear();
_scheduled = false;
_draining = false;
}
///
/// Test-only reset hook. Wipes and the
/// scheduling flags, mirroring . Tests
/// that deliberately exercise edge cases (e.g. hitting
/// ) may leave drains queued; calling
/// this from a TearDown guarantees the next test starts with a
/// quiescent deferral.
///
/// Caveat — dormant subscriptions
/// are NOT purged by this reset. Each call to or
/// 's fallback appends
/// to Unity's multicast delayCall, and Unity does not expose a
/// safe way to dequeue a specific subscription mid-flight. Those
/// subscriptions remain pending and fire on subsequent editor ticks —
/// but because early-returns on an empty
/// , each dormant fire is a harmless no-op.
/// Consequence: do NOT treat
/// as a proxy for "no delayCall callback is pending". It only reflects
/// the drain queue; the delayCall multicast may still hold stale
/// subscriptions that will quietly no-op when they fire.
///
internal static void ResetForTesting()
{
ResetForDomainReload();
}
///
/// Test-only snapshot of the pending-drain count. Used by regression
/// tests that verify cap/drain behavior without pulling in the full
/// reflection machinery.
///
internal static int PendingDrainCountForTesting => PendingDrains.Count;
[System.Diagnostics.Conditional("UNITY_ASSERTIONS")]
[System.Diagnostics.Conditional("DEBUG")]
private static void AssertOnMainThread()
{
int? mainThreadId = _mainThreadId;
if (mainThreadId == null)
{
// Pre-InitializeOnLoadMethod — no captured main-thread id to compare
// against. Schedule calls in this window are implausible in practice
// (AssetPostprocessor callbacks fire after InitializeOnLoad completes),
// so we skip the assertion rather than producing a false positive.
return;
}
if (Thread.CurrentThread.ManagedThreadId != mainThreadId.Value)
{
Debug.LogError(
"AssetPostprocessorDeferral.Schedule called from a background thread. "
+ "Schedule must be invoked from the Unity main thread."
);
}
}
}
}