Bind asset candidates to one catalog lease

This commit is contained in:
Meta Vens 2026-08-19 08:18:57 +02:00
commit a11b8f7d45
4 changed files with 116 additions and 61 deletions

View file

@ -48,6 +48,10 @@ The mounted provider's trust policy authenticates only that provider. A
provider-qualified asset source in another Verse/runtime uses the configured
cross-target Odin trust for its catalog, subscription, manifests, content, and
bundles; local-development trust does not cross that boundary.
The candidate opens its catalog lease before preload, observes updates in a
candidate-owned buffer, and verifies the leased version again before commit.
An update that lands during preload therefore rebuilds the candidate instead
of disappearing between a one-shot read and a later subscription.
Navigation keeps the outgoing presentation quiesced until the candidate route
is ready, then commits the provider route before activating the candidate.

View file

@ -1270,8 +1270,7 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(source.ManifestRecordRef))
return;
var catalog = await ReadAssetCatalogAsync(source, cancellationToken);
var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken);
var candidate = await BuildAssetGenerationAsync(source, cancellationToken);
CommitAssetGeneration(candidate);
}
@ -1283,7 +1282,6 @@ namespace GameCult.Eve.UnityScene
{
try
{
var catalog = await ReadAssetCatalogAsync(source, cancellationToken);
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
return;
await _assetGenerationGate.WaitAsync(cancellationToken);
@ -1291,7 +1289,7 @@ namespace GameCult.Eve.UnityScene
{
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
return;
var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken);
var candidate = await BuildAssetGenerationAsync(source, cancellationToken);
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
{
candidate.Dispose();
@ -1339,53 +1337,44 @@ namespace GameCult.Eve.UnityScene
rendezvousEndpoints);
}
private async Task<EveAssetCatalogDocument> ReadAssetCatalogAsync(
AssetSource source,
CancellationToken cancellationToken)
{
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
return await AssetMeshClient()
.ReadAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
var crossTarget = IsCrossTarget(source);
var endpoints = AssetRendezvousEndpoints(source, crossTarget);
using var candidateClient = crossTarget || source.RendezvousEndpoints.Count > 0
? CreateMeshClient(
endpoints,
AssetAuthorityTrust(source))
: null;
return await (candidateClient ?? _meshClient ??
throw new InvalidOperationException("The CultMesh client is not open."))
.ReadAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
}
private async Task<AssetGeneration> BuildAssetGenerationAsync(
AssetSource source,
EveAssetCatalogDocument catalog,
CancellationToken cancellationToken)
{
var crossTarget = IsCrossTarget(source);
var endpoints = AssetRendezvousEndpoints(source, crossTarget);
var candidate = new AssetGeneration(
source,
source.Identity,
catalog.Version,
crossTarget || source.RendezvousEndpoints.Count > 0
? CreateMeshClient(
endpoints,
AssetAuthorityTrust(source))
: null);
try
while (true)
{
ConfigureAssetCatalog(candidate, catalog);
ReuseCompatibleBundles(_assetGeneration, candidate);
await PreloadAssetsAsync(candidate, cancellationToken);
await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken);
return candidate;
}
catch
{
candidate.Dispose();
throw;
cancellationToken.ThrowIfCancellationRequested();
var crossTarget = IsCrossTarget(source);
var endpoints = AssetRendezvousEndpoints(source, crossTarget);
var candidate = new AssetGeneration(
source,
source.Identity,
catalogVersion: -1,
crossTarget || source.RendezvousEndpoints.Count > 0
? CreateMeshClient(
endpoints,
AssetAuthorityTrust(source))
: null);
try
{
var catalog = await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken);
ConfigureAssetCatalog(candidate, catalog);
ReuseCompatibleBundles(_assetGeneration, candidate);
await PreloadAssetsAsync(candidate, cancellationToken);
var latest = await candidate.CatalogLease!.Handle.LatestAsync();
candidate.ObserveCatalog(latest);
if (latest.Version != candidate.CatalogVersion)
{
candidate.Dispose();
continue;
}
return candidate;
}
catch
{
candidate.Dispose();
throw;
}
}
}
@ -1407,7 +1396,7 @@ namespace GameCult.Eve.UnityScene
return new[] { _rendezvousEndpoint };
}
private async Task AttachAssetCatalogSubscriptionAsync(
private async Task<EveAssetCatalogDocument> AttachAssetCatalogSubscriptionAsync(
AssetGeneration candidate,
CancellationToken cancellationToken)
{
@ -1419,9 +1408,17 @@ namespace GameCult.Eve.UnityScene
IDisposable? watch = null;
try
{
watch = lease.Handle.Watch(catalog => QueueAssetCatalogUpdate(candidate.SourceIdentity, catalog));
watch = lease.Handle.Watch(catalog =>
{
var pending = candidate.ObserveCatalog(catalog);
if (pending != null)
QueueAssetCatalogUpdate(candidate.SourceIdentity, pending);
});
candidate.CatalogWatch = watch;
candidate.CatalogLease = lease;
var catalog = await lease.Handle.LatestAsync();
candidate.ObserveCatalog(catalog);
return catalog;
}
catch
{
@ -1447,6 +1444,9 @@ namespace GameCult.Eve.UnityScene
var previous = _assetGeneration;
previous.TransferSharedBundlesTo(candidate);
_assetGeneration = candidate;
var pendingCatalog = candidate.ActivateCatalogObservation();
if (pendingCatalog != null)
QueueAssetCatalogUpdate(candidate.SourceIdentity, pendingCatalog);
if (surface != null)
_baseSurface = surface;
try
@ -1535,6 +1535,7 @@ namespace GameCult.Eve.UnityScene
$"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " +
$"Advertised variants: {string.Join(", ", advertisedVariants)}");
}
candidate.SetCatalogVersion(catalog.Version);
ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!));
foreach (var selection in selected)
{
@ -1588,7 +1589,7 @@ namespace GameCult.Eve.UnityScene
update.Catalog.Version == CurrentAssetCatalogVersion)
continue;
var source = _assetGeneration.Source;
var candidate = await BuildAssetGenerationAsync(source, update.Catalog, _lifetime.Token);
var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token);
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
{
candidate.Dispose();
@ -1706,6 +1707,10 @@ namespace GameCult.Eve.UnityScene
private sealed class AssetGeneration : IDisposable
{
private readonly object _catalogObservationGate = new object();
private EveAssetCatalogDocument? _latestObservedCatalog;
private bool _catalogObservationActive;
public AssetGeneration(
AssetSource source,
string sourceIdentity,
@ -1720,9 +1725,9 @@ namespace GameCult.Eve.UnityScene
public AssetSource Source { get; }
public string SourceIdentity { get; }
public long CatalogVersion { get; }
public long CatalogVersion { get; private set; }
public CultMeshClient? MeshClient { get; }
public IDisposable? CatalogLease { get; set; }
public CultMeshDocumentLease<EveAssetCatalogDocument>? CatalogLease { get; set; }
public IDisposable? CatalogWatch { get; set; }
public CultMeshContentTransferService? ContentTransfer { get; set; }
public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; }
@ -1754,6 +1759,33 @@ namespace GameCult.Eve.UnityScene
next.BorrowedAssetBundles.Clear();
}
public void SetCatalogVersion(long catalogVersion)
{
if (CatalogVersion >= 0 && CatalogVersion != catalogVersion)
throw new InvalidOperationException("An asset generation cannot change its catalog version after configuration.");
CatalogVersion = catalogVersion;
}
public EveAssetCatalogDocument? ObserveCatalog(EveAssetCatalogDocument catalog)
{
lock (_catalogObservationGate)
{
_latestObservedCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
return _catalogObservationActive && catalog.Version != CatalogVersion ? catalog : null;
}
}
public EveAssetCatalogDocument? ActivateCatalogObservation()
{
lock (_catalogObservationGate)
{
_catalogObservationActive = true;
return _latestObservedCatalog != null && _latestObservedCatalog.Version != CatalogVersion
? _latestObservedCatalog
: null;
}
}
public void Dispose()
{
CatalogWatch?.Dispose();

View file

@ -482,9 +482,9 @@ namespace GameCult.Eve.UnityScene.Tests
var generationField = transportType.GetField(
"_assetGeneration",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var buildMethod = transportType.GetMethod(
"BuildAssetGenerationAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var configureMethod = transportType.GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic)!;
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
@ -514,18 +514,37 @@ namespace GameCult.Eve.UnityScene.Tests
2,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) });
var source = CreateAssetSource("verse-b", "authority-b", "provider-b", "catalog-b");
var task = (Task)buildMethod.Invoke(
transport,
new[] { source, invalidCatalog, CancellationToken.None })!;
Assert.Throws<InvalidOperationException>(() => task.GetAwaiter().GetResult());
var rejected = CreateAssetGeneration(
"verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1);
var error = Assert.Throws<TargetInvocationException>(() =>
configureMethod.Invoke(null, new[] { rejected, invalidCatalog }));
Assert.That(error!.InnerException, Is.TypeOf<InvalidOperationException>());
((IDisposable)rejected).Dispose();
Assert.That(generationField.GetValue(transport), Is.SameAs(committed));
Assert.That(GenerationProperty<string>(committed, "SourceIdentity"), Is.EqualTo("source-a"));
Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab));
UnityEngine.Object.DestroyImmediate(prefab);
}
[Test]
public void CandidateCatalogObservationSurvivesPrecommitWindow()
{
var candidate = CreateAssetGeneration(
"verse-b", "authority-b", "provider-b", "catalog-b", "source-b", 1);
var candidateType = candidate.GetType();
var observe = candidateType.GetMethod("ObserveCatalog")!;
var activate = candidateType.GetMethod("ActivateCatalogObservation")!;
var variant = new EveAssetVariant(
"unity-scene", "StandaloneWindows64", "url", "https://example.invalid/b", "sha256:01", 1, "prefab.b");
var catalogB = new EveAssetCatalogDocument(
"provider-b", "catalog-b", 2, DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { variant }) });
Assert.That(observe.Invoke(candidate, new object[] { catalogB }), Is.Null);
Assert.That(activate.Invoke(candidate, Array.Empty<object>()), Is.SameAs(catalogB));
((IDisposable)candidate).Dispose();
}
[Test]
public void CrossTargetAssetsUseConfiguredRemoteTrust()
{

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.127",
"version": "0.3.128",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {