Retry transient provider bootstrap connections

This commit is contained in:
Meta Vens 2026-07-20 00:04:45 +01:00
commit 002fbdf357
4 changed files with 119 additions and 29 deletions

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
@ -190,16 +191,27 @@ namespace GameCult.Eve.UnityScene
if (_bootstrapped)
return;
var elapsed = Stopwatch.StartNew();
ResolveAdvertisement(forceRefresh: true);
TraceStartup("advertisement", elapsed);
RefreshSurface();
TraceStartup("surface", elapsed);
RefreshAssetCatalog();
TraceStartup("asset-catalog-and-bundles", elapsed);
EnsureLiveSubscriptions();
TraceStartup("subscriptions", elapsed);
_bootstrapped = true;
try
{
_snapshot ??= CreateSnapshotSession();
var elapsed = Stopwatch.StartNew();
ResolveAdvertisement(forceRefresh: true);
TraceStartup("advertisement", elapsed);
RefreshSurface();
TraceStartup("surface", elapsed);
RefreshAssetCatalog();
TraceStartup("asset-catalog-and-bundles", elapsed);
EnsureLiveSubscriptions();
TraceStartup("subscriptions", elapsed);
_bootstrapped = true;
}
catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException)
{
ResetSnapshotSession();
throw new InvalidOperationException(
$"CultMesh provider '{_providerId}' bootstrap transport failed; the snapshot lane was reset for retry.",
error);
}
}
private static void TraceStartup(string phase, Stopwatch elapsed)
@ -510,7 +522,19 @@ namespace GameCult.Eve.UnityScene
})
.GetAwaiter()
.GetResult();
_snapshot = CultMesh.SnapshotSession(
_snapshot = CreateSnapshotSession();
_meshClient = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { _endpoint },
RealtimeConnectors = new ICultMeshRealtimeTransportConnector[]
{
new CultMeshNativeQuicRealtimeTransportConnector()
}
});
}
private CultMeshSnapshotSession CreateSnapshotSession() =>
CultMesh.SnapshotSession(
_endpoint,
new CultMeshSnapshotRequestOptions
{
@ -523,14 +547,11 @@ namespace GameCult.Eve.UnityScene
RudpMaxFragmentBytes = 1024
},
_networkRegistry);
_meshClient = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { _endpoint },
RealtimeConnectors = new ICultMeshRealtimeTransportConnector[]
{
new CultMeshNativeQuicRealtimeTransportConnector()
}
});
private void ResetSnapshotSession()
{
_snapshot?.Dispose();
_snapshot = null;
}
private void ResolveAdvertisement(bool forceRefresh = false)

View file

@ -239,8 +239,18 @@ namespace GameCult.Eve.UnityScene
_transport.SurfaceDocumentAvailable += OnSurfaceDocumentAvailable;
_transport.AssetManifestDocumentAvailable += OnAssetManifestDocumentAvailable;
_transport.CommandReceiptAvailable += OnCommandReceiptAvailable;
_transport.Connect();
_connected = true;
try
{
_transport.Connect();
_connected = true;
}
catch
{
_transport.SurfaceDocumentAvailable -= OnSurfaceDocumentAvailable;
_transport.AssetManifestDocumentAvailable -= OnAssetManifestDocumentAvailable;
_transport.CommandReceiptAvailable -= OnCommandReceiptAvailable;
throw;
}
}
public void Disconnect()

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
@ -981,6 +982,37 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(transport.RefreshCount, Is.EqualTo(0));
}
[Test]
public void LiveProviderBridgeRollsBackSubscriptionsWhenTransportConnectFails()
{
var transport = new FakeLiveProviderTransport(
new EveUnitySceneProviderSurfaceDocument(
PlayableArpgDocument(),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
1),
new EveUnityPlayableWorldAssetManifestDocument(
"cultmesh://aetheria/assets/manifest",
Array.Empty<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"))
{
FailNextConnect = true
};
using var bridge = new EveUnitySceneLiveProviderBridge(transport);
Assert.Throws<IOException>(() => bridge.Connect());
Assert.That(transport.SurfaceSubscriberCount, Is.Zero);
Assert.That(transport.AssetManifestSubscriberCount, Is.Zero);
Assert.That(transport.ReceiptSubscriberCount, Is.Zero);
bridge.Connect();
Assert.That(transport.ConnectCount, Is.EqualTo(2));
Assert.That(transport.SurfaceSubscriberCount, Is.EqualTo(1));
Assert.That(transport.AssetManifestSubscriberCount, Is.EqualTo(1));
Assert.That(transport.ReceiptSubscriberCount, Is.EqualTo(1));
}
[Test]
public void PlayableWorldClientHostMountsInterfaceProviderWithoutProviderTypes()
{
@ -2817,6 +2849,10 @@ namespace GameCult.Eve.UnityScene.Tests
private sealed class FakeLiveProviderTransport : IEveUnitySceneLiveProviderTransport
{
private Action<EveUnitySceneProviderSurfaceDocument>? _surfaceDocumentAvailable;
private Action<EveUnityPlayableWorldAssetManifestDocument>? _assetManifestDocumentAvailable;
private Action<EveUnitySceneCommandReceipt>? _commandReceiptAvailable;
public FakeLiveProviderTransport(
EveUnitySceneProviderSurfaceDocument surfaceDocument,
EveUnityPlayableWorldAssetManifestDocument assetManifestDocument)
@ -2843,15 +2879,38 @@ namespace GameCult.Eve.UnityScene.Tests
public int RefreshCount { get; private set; }
public event Action<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable;
public bool FailNextConnect { get; set; }
public event Action<EveUnityPlayableWorldAssetManifestDocument>? AssetManifestDocumentAvailable;
public int SurfaceSubscriberCount => _surfaceDocumentAvailable?.GetInvocationList().Length ?? 0;
public event Action<EveUnitySceneCommandReceipt>? CommandReceiptAvailable;
public int AssetManifestSubscriberCount => _assetManifestDocumentAvailable?.GetInvocationList().Length ?? 0;
public int ReceiptSubscriberCount => _commandReceiptAvailable?.GetInvocationList().Length ?? 0;
public event Action<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable
{
add => _surfaceDocumentAvailable += value;
remove => _surfaceDocumentAvailable -= value;
}
public event Action<EveUnityPlayableWorldAssetManifestDocument>? AssetManifestDocumentAvailable
{
add => _assetManifestDocumentAvailable += value;
remove => _assetManifestDocumentAvailable -= value;
}
public event Action<EveUnitySceneCommandReceipt>? CommandReceiptAvailable
{
add => _commandReceiptAvailable += value;
remove => _commandReceiptAvailable -= value;
}
public void Connect()
{
ConnectCount++;
if (!FailNextConnect) return;
FailNextConnect = false;
throw new IOException("transient transport failure");
}
public void Disconnect()
@ -2872,18 +2931,18 @@ namespace GameCult.Eve.UnityScene.Tests
public void PublishSurface(EveUnitySceneProviderSurfaceDocument document)
{
CurrentSurfaceDocument = document;
SurfaceDocumentAvailable?.Invoke(document);
_surfaceDocumentAvailable?.Invoke(document);
}
public void PublishAssetManifest(EveUnityPlayableWorldAssetManifestDocument document)
{
CurrentAssetManifestDocument = document;
AssetManifestDocumentAvailable?.Invoke(document);
_assetManifestDocumentAvailable?.Invoke(document);
}
public void PublishReceipt(EveUnitySceneCommandReceipt receipt)
{
CommandReceiptAvailable?.Invoke(receipt);
_commandReceiptAvailable?.Invoke(receipt);
}
}

View file

@ -1,11 +1,11 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.97",
"version": "0.3.98",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.36",
"org.gamecult.cultlib": "1.0.38",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.4",
"com.unity.mathematics": "1.3.2",