Bind asset candidates to one catalog lease
This commit is contained in:
parent
12637367e1
commit
a11b8f7d45
4 changed files with 116 additions and 61 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Reference in a new issue