Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ public enum WarmUpStage

private bool panelOpenRequested;

private bool contentEditSignaled;
private string? contentEditSrc;

private WarmUpStage warmUpStage;
private string? warmUpSceneId;
private float warmUpElapsedSeconds;
Expand Down Expand Up @@ -280,6 +283,36 @@ public bool TryConsumePanelOpenRequest()
}
}

/// <summary>
/// Signals that the LSD preview server reported a content edit, so the scene's bundles are about
/// to be reconverted. <paramref name="changedSrc" /> names the edited model when the message
/// carried one (UpdateModel); null for whole-scene updates. Rapid successive edits keep the
/// latest name — one reconversion pass covers them all.
/// </summary>
public void OnContentEdit(string? changedSrc)
{
lock (gate)
{
contentEditSignaled = true;

// Protobuf strings default to "" — treat that as an unnamed (whole-scene) edit.
contentEditSrc = string.IsNullOrEmpty(changedSrc) ? null : changedSrc;
}
}

/// <summary>True once per <see cref="OnContentEdit" /> burst — consuming it resets the signal.</summary>
public bool TryConsumeContentEdit(out string? changedSrc)
{
lock (gate)
{
bool signaled = contentEditSignaled;
changedSrc = contentEditSrc;
contentEditSignaled = false;
contentEditSrc = null;
return signaled;
}
}

/// <summary>Adds an informational row to the panel without touching the counters.</summary>
public void OnMilestone(string message)
{
Expand Down
253 changes: 224 additions & 29 deletions Explorer/Assets/DCL/Infrastructure/Global/Dynamic/AbgenSidecar.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ public class DynamicWorldContainer : DCLWorldContainer<DynamicWorldSettings>
public ISystemClipboard SystemClipboard => uiShellContainer.Clipboard;

/// <summary>
/// Completed once the abgen sidecar reaches a terminal state — warm and serving, or given up
/// (see <see cref="AbgenSidecarPlugin.ReadyAsync" />). Already completed when the sidecar is
/// not mounted, so awaiting it costs nothing outside local scene development with local ABs.
/// Completed once the abgen sidecar reaches a terminal state, with <c>true</c> when the server is up and
/// serving or <c>false</c> when it never came up (see <see cref="AbgenSidecarPlugin.ReadyAsync" />).
/// Resolves to <c>true</c> immediately when the sidecar is not mounted (nothing to fall back from), so
/// awaiting it costs nothing outside local scene development with local ABs.
/// </summary>
public UniTask AbgenSidecarReadyAsync => abgenSidecarPlugin?.ReadyAsync ?? UniTask.CompletedTask;
public UniTask<bool> AbgenSidecarReadyAsync => abgenSidecarPlugin?.ReadyAsync ?? UniTask.FromResult(true);

private DynamicWorldContainer(
UIShellContainer uiShellContainer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,13 @@ await bootstrap.InitializeFeatureFlagsAsync(bootstrapContainer.IdentityCache!.Id
// manifest request, whose bundles-vs-GLTFs verdict is final for the session. Hold it
// until the abgen sidecar is warm or has given up; completed immediately when the
// sidecar is not mounted.
await dynamicWorldContainer!.AbgenSidecarReadyAsync.AttachExternalCancellation(ct);
bool sidecarUsable = await dynamicWorldContainer!.AbgenSidecarReadyAsync.AttachExternalCancellation(ct);

// The sidecar the optimized-assets override points at never came up. Drop the override now,
// before any optimized-asset request resolves, so the whole session falls back to production
// cleanly instead of hitting the dead loopback port and recovering per request.
if (!sidecarUsable)
decentralandUrlsSource.ClearOptimizedAssetsOverride();

await LoadStartingRealmAsync(ct);
await LoadUserFlowAsync(playerEntity, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using DCL.SkyBox;
using DCL.SkyBox.Components;
using Decentraland.Sdk.Development;
using ECS.StreamableLoading.AssetBundles;
using Google.Protobuf;
using System;
using System.Threading;
Expand Down Expand Up @@ -91,6 +92,10 @@ private async UniTask ConnectToServerAsync(string localSceneWebsocketServer,
changedModelSrc = null;
}

// Arm the abgen sidecar's reconversion mirror before the reload's manifest request
// lands; without --local-ab nothing consumes the signal and it stays inert.
AbgenConversionMetrics.INSTANCE.OnContentEdit(changedModelSrc);

// Switch to the main thread because `TryReloadSceneAsync` requires that
await UniTask.SwitchToMainThread(cancellationToken: ct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,11 @@ public interface IDecentralandUrlsSource
public string GetOriginalUrl(string url);

string GetHostnameForFeatureFlag();

/// <summary>
/// Drops the "--optimized-assets-url" override (local-ab) so optimized-asset endpoints fall back to their
/// production hosts. No-op when no override is set.
/// </summary>
void ClearOptimizedAssetsOverride();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ protected enum CacheBehaviour
private readonly ILaunchMode launchMode;
private readonly string decentralandDomain;
private readonly string? gatekeeperBaseOverride;
private readonly string? optimizedAssetsBaseOverride;
private string? optimizedAssetsBaseOverride;
private readonly bool isTodayEnvironment;

public DecentralandUrlsSource(
Expand Down Expand Up @@ -188,6 +188,31 @@ private void ResetRealmDependentUrls(RealmKind realmKind)
cache.Remove(url);
}

/// <summary>
/// Drops the "--optimized-assets-url" override so every optimized-asset endpoint re-resolves to its
/// production host. Called when the local-ab abgen sidecar the override pointed at never came up, so the
/// session behaves as if local-ab were never requested instead of routing every scene, wearable, LOD and
/// registry-composed profile/entities-active request at a dead loopback port and recovering per request.
/// </summary>
public void ClearOptimizedAssetsOverride()
{
if (optimizedAssetsBaseOverride == null)
return;

optimizedAssetsBaseOverride = null;

// With the override present these all resolved as FeatureFlagsDependent (the registry-composed
// profile/entities endpoints inherit the registry base's caching), so evicting that class forces
// production re-resolution. Anything already flag-dependent for other reasons simply re-resolves
// to the same value.
using PooledObject<List<DecentralandUrl>> _ = ListPool<DecentralandUrl>.Get(out List<DecentralandUrl>? flagDependentCachedUrls);

flagDependentCachedUrls.AddRange(cache.Where(kvp => kvp.Value.Caching == CacheBehaviour.FeatureFlagsDependent).Select(kvp => kvp.Key));

foreach (DecentralandUrl url in flagDependentCachedUrls)
cache.Remove(url);
}

private string ResolveGatekeeperBaseUrl(string defaultBaseUrl) =>
gatekeeperBaseOverride ?? defaultBaseUrl;

Expand Down
28 changes: 21 additions & 7 deletions Explorer/Assets/DCL/PluginSystem/Global/AbgenSidecarPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ public class AbgenSidecarPlugin : IDCLGlobalPluginWithoutSettings
private readonly string baseUrl;
private readonly RealmUrls realmUrls;
private readonly DecentralandEnvironment environment;
private readonly UniTaskCompletionSource readyCompletionSource = new ();
private readonly UniTaskCompletionSource<bool> readyCompletionSource = new ();

private AbgenSidecar? sidecar;
private CancellationTokenSource? lifeCycleCancellationTokenSource;

/// <summary>
/// Completes when the sidecar reaches a terminal state: warm and serving (whole-scene warm-up
/// finished), or given up (no binary and the download failed, launch failure, cancellation).
/// Never faults. Single awaiter only.
/// Completes when the sidecar reaches a terminal state, with <c>true</c> once the server is healthy and
/// serving (whole-scene warm-up may still be running or have partially failed — the server answers those
/// per request), or <c>false</c> when it never came up (no binary and the download failed, launch failure,
/// cancellation). On <c>false</c> the caller drops the optimized-assets override so the session falls
/// back to production instead of the dead loopback port. Never faults. Single awaiter only.
/// </summary>
public UniTask ReadyAsync => readyCompletionSource.Task;
public UniTask<bool> ReadyAsync => readyCompletionSource.Task;

public AbgenSidecarPlugin(string baseUrl, RealmUrls realmUrls, DecentralandEnvironment environment)
{
Expand All @@ -50,7 +52,8 @@ public void Dispose()
sidecar?.Dispose();

// Covers teardown before InjectToWorld ever ran; otherwise RunAsync's finally completes it.
readyCompletionSource.TrySetResult();
// Not usable: nothing is serving, so any override must fall back to production.
readyCompletionSource.TrySetResult(false);
}

public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder, in GlobalPluginArguments arguments)
Expand All @@ -61,6 +64,8 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder,

private async UniTaskVoid RunAsync(CancellationToken ct)
{
bool usable = false;

try
{
// The canonical LSD realm — the same resolution the rest of the app runs on.
Expand Down Expand Up @@ -88,11 +93,20 @@ private async UniTaskVoid RunAsync(CancellationToken ct)
sidecar = created;

if (await sidecar.StartAsync(ct))
{
// Server is healthy: the override is valid even if the warm-up below fails, since bundles
// JIT on demand and non-scene lanes stream through the read-through.
usable = true;
await sidecar.WarmUpLocalSceneAsync(ct);

// Detached for the sidecar's lifetime (it swallows its own exceptions): mirrors content-edit
// reconversions into the AB panel. RunAsync must return so the boot-hold releases.
sidecar.WatchReconversionsAsync(ct).Forget();
}
}
catch (OperationCanceledException) { }
catch (Exception e) { ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES); }
finally { readyCompletionSource.TrySetResult(); }
finally { readyCompletionSource.TrySetResult(usable); }
}
}
}
29 changes: 28 additions & 1 deletion docs/abgen-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ uses `CreateProcessW` with `CREATE_NO_WINDOW`, macOS/Linux the `DclProcesses` na
`kill(pid, 0)`), not the managed `Exited` event. Only the editor keeps the managed `Process`
path (with drained stdout/stderr pipes).

**Orphan protection**: teardown is cooperative (Dispose kills the child), so a hard crash of the
explorer used to leave the server running — and with the fixed default port an orphan *owns* the
endpoint the next session expects. Two defenses: on Windows (player and editor) every child is
assigned to a kill-on-close Job Object, so the kernel reaps it when the explorer dies for any
reason; and `StartAsync` refuses to adopt a foreign listener — after the health check passes it
verifies our own child is still alive, and if the port is answered by anything else (orphan from a
crashed macOS session, unrelated process) it fails fast with an explicit milestone instead of
silently serving stale bundles. macOS has no job-object equivalent; a parent-pid watchdog in abgen
is the tracked upstream complement.

Measured (Linux x86_64, CPU encoder): cold whole-entity JIT 0.8s (2-GLB scene) / 5.3s (24-GLB,
12MB); warm disk-cache hits <1ms; server RSS ~16MB idle, 130–435MB peak during converts. v0.16.0's
per-file parallelization cut cold whole-scene conversion by ~30 s on an M-series Mac against a real
Expand All @@ -83,13 +93,30 @@ failure). First run therefore enters the world with bundles already served; outs
LSD + `--local-ab` the task is pre-completed and boot is unaffected. The wait is absorbed under
the splash screen, before the authentication screen.

**Clean fallback when the sidecar can't be had**: the readiness task resolves to a bool — false
when the server never came up — and `MainSceneLoader` then drops the optimized-assets override
(`DecentralandUrlsSource.ClearOptimizedAssetsOverride`, which also evicts the flag-dependent URL
cache) before any optimized-asset request has resolved. The whole session — scene bundles,
wearables, emotes, LODs, and the registry-composed profile/entities endpoints — falls back to the
production hosts exactly as if `--local-ab` had not been passed, instead of hitting the dead
loopback port and recovering per request. A server that turns healthy and dies later keeps the
override (bundles JIT per request; supervision restarts it up to 3×).

## Visibility

The scene dev console's AB tab mirrors `/progress/{entity}` live: the summary shows the server's
authoritative `converted/total` counter, per-file rows show whatever the 500 ms poll catches
(backfilled to the full census when the manifest lands), and milestone rows mark every lifecycle
moment — download progress, installed, warm-up started, READY in Ns, already-warm, server-side
failures (manifest exitCode), sidecar failed. The sidebar AB button pulses while conversion runs
failures (manifest exitCode), sidecar failed. Content-edit reconversions are mirrored too: the LSD
reload path (`LocalSceneDevelopmentController`, which already receives the preview server's edit
message — including the changed model's path) raises a consumable signal on
`AbgenConversionMetrics`; the sidecar's session-long watcher (`WatchReconversionsAsync`) consumes it
and re-runs the manifest lane, which coalesces with (or triggers) the server's rebuild — the panel
flips back to converting, names the edited file, tracks the rebuild and settles to READY with a
"reconverted in Ns" milestone, accurate even when the rebuild outpaces the progress poll (texture
edits arrive as unnamed whole-scene updates — sdk-commands only names `.glb/.gltf` changes).
The sidebar AB button pulses while conversion runs
and stays lit after unseen failures. The panel **opens itself** when long-running work starts
(consumable open-request on `AbgenConversionMetrics`, consumed by `DebugMenuController`) and
**closes itself** a few seconds after a clean READY — any failure keeps it open, and a manual
Expand Down
Loading