Skip to content

Commit 172057e

Browse files
committed
Potential fixes for ScriptableObjectSingletonCreator on Linux
1 parent 1143a07 commit 172057e

File tree

9 files changed

+456
-17
lines changed

9 files changed

+456
-17
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
See [the roadmap](./docs/overview/roadmap.md) for details
1111

12+
## [3.1.4]
13+
14+
### Added
15+
16+
- **ScriptableObjectSingletonMetadata Sync Button**: Added a `Sync` button to `ScriptableObjectSingletonMetadata` inspector that re-scans all assemblies for `ScriptableObjectSingleton<T>` types and updates their metadata entries. This allows manually refreshing singleton metadata when assets are added, moved, or renamed.
17+
18+
### Fixed
19+
20+
- **ScriptableObjectSingletonCreator race condition**: Fixed issue where newly created singleton assets were immediately deleted because `LoadAssetAtPath` returned null before Unity's AssetDatabase had indexed the file. The fix adds a synchronous import after `CreateAsset` and avoids deleting on-disk files when the file exists but isn't visible to the AssetDatabase yet.
21+
1222
## [3.1.3]
1323

1424
### Added

Editor/Utils/ScriptableObjectSingletonCreator.cs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ ref anyChanges
305305
try
306306
{
307307
AssetDatabase.CreateAsset(instance, targetAssetPath);
308+
// Force Unity to import the asset synchronously so LoadAssetAtPath works immediately.
309+
// This avoids the race condition where the file exists on disk but
310+
// AssetDatabase hasn't indexed it yet.
311+
AssetDatabase.ImportAsset(
312+
targetAssetPath,
313+
ImportAssetOptions.ForceSynchronousImport
314+
);
308315
}
309316
catch (Exception ex)
310317
{
@@ -326,20 +333,32 @@ ref anyChanges
326333
if (createdAsset == null)
327334
{
328335
// Check if file exists on disk but Unity hasn't imported it yet
329-
if (DoesAssetFileExistOnDisk(targetAssetPath))
336+
bool assetExistsOnDisk = DoesAssetFileExistOnDisk(targetAssetPath);
337+
if (assetExistsOnDisk)
330338
{
331339
LogVerbose(
332-
$"ScriptableObjectSingletonCreator: Asset file created at {targetAssetPath} but not yet visible to AssetDatabase. Will retry."
333-
);
334-
}
335-
else
336-
{
337-
Debug.LogError(
338-
$"ScriptableObjectSingletonCreator: CreateAsset appeared to succeed but asset not found at {targetAssetPath}. This may indicate a stale asset database state."
340+
$"ScriptableObjectSingletonCreator: Asset file created at {targetAssetPath} but not yet visible to AssetDatabase. Will retry without deleting the file."
339341
);
342+
// DON'T call SafeDestroyInstance here - the file is valid, just not imported yet.
343+
// Only destroy the in-memory instance without touching the on-disk file.
344+
try
345+
{
346+
Object.DestroyImmediate(instance, true);
347+
}
348+
catch (Exception ex)
349+
{
350+
LogVerbose(
351+
$"ScriptableObjectSingletonCreator: Failed to destroy in-memory instance: {ex.Message}"
352+
);
353+
}
354+
retryRequested = true;
355+
continue;
340356
}
341-
// Use allowDestroyingAssets=true because CreateAsset may have partially succeeded,
342-
// associating the instance with an asset even though LoadAssetAtPath returns null.
357+
358+
Debug.LogError(
359+
$"ScriptableObjectSingletonCreator: CreateAsset appeared to succeed but asset not found at {targetAssetPath}. This may indicate a stale asset database state."
360+
);
361+
// File doesn't exist on disk - this is a real failure, clean up
343362
SafeDestroyInstance(instance, targetAssetPath);
344363
retryRequested = true;
345364
continue;
@@ -348,10 +367,7 @@ ref anyChanges
348367
LogVerbose(
349368
$"ScriptableObjectSingletonCreator: Created missing singleton for type {derivedType.FullName} at {targetAssetPath}."
350369
);
351-
if (UpdateSingletonMetadataEntry(derivedType, targetAssetPath))
352-
{
353-
anyChanges = true;
354-
}
370+
UpdateSingletonMetadataEntry(derivedType, targetAssetPath);
355371
anyChanges = true;
356372
singletonsSucceeded++;
357373
}

Editor/Utils/ScriptableObjectSingletonMetadataUtility.cs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,289 @@ internal static void ResetAssetEditingDepthForTesting()
421421
// which is called by CommonTestBase in setUp/tearDown.
422422
// This method is a no-op kept for backward compatibility.
423423
}
424+
425+
/// <summary>
426+
/// Registers the sync implementation with the Runtime metadata class.
427+
/// Called automatically via InitializeOnLoadMethod.
428+
/// </summary>
429+
[InitializeOnLoadMethod]
430+
private static void RegisterSyncImplementation()
431+
{
432+
ScriptableObjectSingletonMetadata.SyncImplementation = SyncAllSingletonMetadata;
433+
}
434+
435+
/// <summary>
436+
/// Re-scans all assemblies for ScriptableObjectSingleton types and updates their metadata entries.
437+
/// This removes stale entries and adds/updates metadata for all existing singleton assets.
438+
/// </summary>
439+
/// <param name="metadata">The metadata asset to sync. If null, loads or creates the metadata asset.</param>
440+
internal static void SyncAllSingletonMetadata(ScriptableObjectSingletonMetadata metadata)
441+
{
442+
metadata ??= LoadOrCreateMetadataAsset();
443+
if (metadata == null)
444+
{
445+
Debug.LogWarning(
446+
"ScriptableObjectSingletonMetadataUtility.SyncAllSingletonMetadata: "
447+
+ "Could not load or create metadata asset."
448+
);
449+
return;
450+
}
451+
452+
int added = 0;
453+
int updated = 0;
454+
int removed = 0;
455+
456+
// Build a set of existing entries for comparison
457+
IReadOnlyList<ScriptableObjectSingletonMetadata.Entry> existingEntries =
458+
metadata.GetAllEntries();
459+
Dictionary<string, ScriptableObjectSingletonMetadata.Entry> existingByTypeName = new(
460+
StringComparer.Ordinal
461+
);
462+
foreach (ScriptableObjectSingletonMetadata.Entry entry in existingEntries)
463+
{
464+
if (!string.IsNullOrEmpty(entry.assemblyQualifiedTypeName))
465+
{
466+
existingByTypeName[entry.assemblyQualifiedTypeName] = entry;
467+
}
468+
}
469+
470+
// Track which types we find during scanning
471+
HashSet<string> foundTypeNames = new(StringComparer.Ordinal);
472+
473+
// Scan for all singleton types
474+
foreach (
475+
Type derivedType in ReflectionHelpers.GetTypesDerivedFrom(
476+
typeof(ScriptableObjectSingleton<>),
477+
includeAbstract: false
478+
)
479+
)
480+
{
481+
if (derivedType.IsGenericType)
482+
{
483+
continue;
484+
}
485+
486+
// Skip test types unless explicitly included
487+
if (TestAssemblyHelper.IsTestType(derivedType))
488+
{
489+
continue;
490+
}
491+
492+
string assemblyQualifiedName = derivedType.AssemblyQualifiedName;
493+
if (string.IsNullOrEmpty(assemblyQualifiedName))
494+
{
495+
continue;
496+
}
497+
498+
foundTypeNames.Add(assemblyQualifiedName);
499+
500+
// Find the asset for this type
501+
string assetPath = FindSingletonAssetPath(derivedType);
502+
if (string.IsNullOrEmpty(assetPath))
503+
{
504+
// No asset exists - skip (don't create assets, just sync metadata for existing ones)
505+
continue;
506+
}
507+
508+
string loadPath = ToResourcesLoadPath(assetPath);
509+
if (string.IsNullOrEmpty(loadPath))
510+
{
511+
continue;
512+
}
513+
514+
string resourcesFolder = GetResourcesFolderFromLoadPath(loadPath);
515+
string guid = AssetDatabase.AssetPathToGUID(assetPath) ?? string.Empty;
516+
517+
ScriptableObjectSingletonMetadata.Entry newEntry = new()
518+
{
519+
assemblyQualifiedTypeName = assemblyQualifiedName,
520+
resourcesLoadPath = loadPath,
521+
resourcesPath = resourcesFolder,
522+
assetGuid = guid,
523+
};
524+
525+
// Check if entry exists and needs updating
526+
if (
527+
existingByTypeName.TryGetValue(
528+
assemblyQualifiedName,
529+
out ScriptableObjectSingletonMetadata.Entry existingEntry
530+
)
531+
)
532+
{
533+
bool needsUpdate =
534+
!string.Equals(
535+
existingEntry.resourcesLoadPath,
536+
newEntry.resourcesLoadPath,
537+
StringComparison.Ordinal
538+
)
539+
|| !string.Equals(
540+
existingEntry.resourcesPath,
541+
newEntry.resourcesPath,
542+
StringComparison.Ordinal
543+
)
544+
|| !string.Equals(
545+
existingEntry.assetGuid,
546+
newEntry.assetGuid,
547+
StringComparison.Ordinal
548+
);
549+
550+
if (needsUpdate)
551+
{
552+
metadata.SetOrUpdateEntry(newEntry);
553+
updated++;
554+
}
555+
}
556+
else
557+
{
558+
metadata.SetOrUpdateEntry(newEntry);
559+
added++;
560+
}
561+
}
562+
563+
// Remove stale entries (types that no longer exist or have no assets)
564+
foreach (string existingTypeName in existingByTypeName.Keys)
565+
{
566+
if (!foundTypeNames.Contains(existingTypeName))
567+
{
568+
// Type was not found during scan - could be deleted or renamed
569+
// Also check if the asset still exists
570+
ScriptableObjectSingletonMetadata.Entry staleEntry = existingByTypeName[
571+
existingTypeName
572+
];
573+
if (!string.IsNullOrEmpty(staleEntry.resourcesLoadPath))
574+
{
575+
string assetPath = $"Assets/Resources/{staleEntry.resourcesLoadPath}.asset";
576+
Object asset = AssetDatabase.LoadAssetAtPath<Object>(assetPath);
577+
if (asset == null && !string.IsNullOrEmpty(staleEntry.assetGuid))
578+
{
579+
string guidPath = AssetDatabase.GUIDToAssetPath(staleEntry.assetGuid);
580+
if (!string.IsNullOrEmpty(guidPath))
581+
{
582+
asset = AssetDatabase.LoadAssetAtPath<Object>(guidPath);
583+
}
584+
}
585+
586+
if (asset == null)
587+
{
588+
metadata.RemoveEntry(existingTypeName);
589+
removed++;
590+
}
591+
}
592+
else
593+
{
594+
metadata.RemoveEntry(existingTypeName);
595+
removed++;
596+
}
597+
}
598+
}
599+
600+
if (added > 0 || updated > 0 || removed > 0)
601+
{
602+
EditorUtility.SetDirty(metadata);
603+
AssetDatabase.SaveAssets();
604+
Debug.Log(
605+
$"ScriptableObjectSingletonMetadata.Sync: Added {added}, updated {updated}, removed {removed} entries."
606+
);
607+
}
608+
else
609+
{
610+
Debug.Log(
611+
"ScriptableObjectSingletonMetadata.Sync: Metadata is already up to date."
612+
);
613+
}
614+
}
615+
616+
private static string FindSingletonAssetPath(Type type)
617+
{
618+
// First try to find by type name in Resources
619+
string[] guids = AssetDatabase.FindAssets(
620+
$"t:{type.Name}",
621+
new[] { "Assets/Resources" }
622+
);
623+
624+
if (guids != null && guids.Length > 0)
625+
{
626+
foreach (string guid in guids)
627+
{
628+
string path = AssetDatabase.GUIDToAssetPath(guid);
629+
if (string.IsNullOrEmpty(path))
630+
{
631+
continue;
632+
}
633+
634+
Object asset = AssetDatabase.LoadAssetAtPath(path, type);
635+
if (asset != null)
636+
{
637+
return path;
638+
}
639+
}
640+
}
641+
642+
// Try loading from Resources as a fallback
643+
Object[] instances = Resources.LoadAll(string.Empty, type);
644+
if (instances != null && instances.Length > 0)
645+
{
646+
foreach (Object instance in instances)
647+
{
648+
if (instance == null)
649+
{
650+
continue;
651+
}
652+
653+
string path = AssetDatabase.GetAssetPath(instance);
654+
if (!string.IsNullOrEmpty(path))
655+
{
656+
return path;
657+
}
658+
}
659+
}
660+
661+
return null;
662+
}
663+
664+
private static string ToResourcesLoadPath(string assetPath)
665+
{
666+
if (string.IsNullOrWhiteSpace(assetPath))
667+
{
668+
return null;
669+
}
670+
671+
const string resourcesRoot = "Assets/Resources";
672+
string normalized = assetPath.SanitizePath();
673+
if (!normalized.StartsWith(resourcesRoot, StringComparison.OrdinalIgnoreCase))
674+
{
675+
return null;
676+
}
677+
678+
string relative = normalized.Substring(resourcesRoot.Length).TrimStart('/');
679+
if (string.IsNullOrWhiteSpace(relative))
680+
{
681+
return null;
682+
}
683+
684+
if (relative.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
685+
{
686+
relative = relative.Substring(0, relative.Length - ".asset".Length);
687+
}
688+
689+
return relative.Replace("\\", "/");
690+
}
691+
692+
private static string GetResourcesFolderFromLoadPath(string loadPath)
693+
{
694+
if (string.IsNullOrWhiteSpace(loadPath))
695+
{
696+
return string.Empty;
697+
}
698+
699+
int lastSlash = loadPath.LastIndexOf('/');
700+
if (lastSlash <= 0)
701+
{
702+
return string.Empty;
703+
}
704+
705+
return loadPath.Substring(0, lastSlash);
706+
}
424707
}
425708
#endif
426709
}

0 commit comments

Comments
 (0)