-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathAbgenSidecar.cs
More file actions
1022 lines (860 loc) · 47.2 KB
/
Copy pathAbgenSidecar.cs
File metadata and controls
1022 lines (860 loc) · 47.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#nullable enable
using Cysharp.Threading.Tasks;
using DCL.Diagnostics;
using DCL.Utility;
using ECS.StreamableLoading.AssetBundles;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Threading;
using UnityEngine;
using UnityEngine.Networking;
using Utility.Multithreading;
#if UNITY_EDITOR
using System.Diagnostics;
#elif !UNITY_STANDALONE_WIN
using Plugins.DclNativeProcesses;
using RichTypes;
#endif
// ReSharper disable InconsistentNaming
namespace Global.Dynamic
{
/// <summary>
/// Runs the abgen JIT asset-bundle server as a supervised localhost sidecar. The client's unchanged
/// loading path consumes its base URL as the optimized-assets source; the server JIT-converts the
/// local scene and answers everything else from the production upstream (ab-cdn read-through and
/// registry pass-through), caching converted bundles on disk.
/// Two-step lifecycle: <see cref="ReserveBaseUrl" /> (synchronous — a loopback port only, so the
/// URL can seed the URL sources built early in startup), then AbgenSidecarPlugin creates the
/// instance on that URL (<see cref="TryCreate" />) and launches it (<see cref="StartAsync" />),
/// owning it from there on.
/// The binary is never embedded in the build: on first run the pinned release is downloaded
/// (<see cref="EnsurePinnedBinaryAsync" />) and verified against its compile-time sha256. Only the
/// pinned version is ever executed — a compromised GitHub release cannot propagate here without a
/// deliberate pin+checksum bump in this file. StreamingAssets acts as an explicit developer override.
/// An explicit --optimized-assets-url always takes precedence.
/// </summary>
public sealed class AbgenSidecar : IDisposable
{
private const string PINNED_VERSION = "0.16.0";
private const int MAX_RESTARTS = 3;
private const int HEALTH_TIMEOUT_MS = 15000;
private const int HEALTH_POLL_MS = 250;
private const int PROGRESS_POLL_MS = 500;
private const int CONTENT_EDIT_SIGNAL_POLL_MS = 250;
/// <summary>abgen's built-in default bind port (crate/src/abcdn/config.rs) — never exported as an env var.</summary>
private const int ABGEN_DEFAULT_PORT = 5147;
private const int SUPERVISION_POLL_MS = 2000;
/// <summary>Path under the realm root where a content server exposes its entities and files.</summary>
private const string CONTENT_PATH = "/content";
private readonly string executablePath;
private readonly string realmRoot;
private readonly string catalystContentUrl;
private readonly string upstreamCdnUrl;
private readonly string cacheRoot;
private readonly bool jitContentDigest;
// System.Diagnostics.Process cannot spawn under IL2CPP (Win32Exception "Native error= Success"),
// so player builds hold the child as a raw OS handle/pid; only the editor's Mono runtime keeps
// the managed Process object (and its drained stdout/stderr pipes).
#if UNITY_EDITOR
private Process? process;
#elif UNITY_STANDALONE_WIN
private IntPtr processHandle;
#else
private int processId;
#endif
private int restarts;
private volatile bool disposed;
private string? warmedEntityId;
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
// Kill-on-close Job Object tying the child's lifetime to this process on Windows: when the
// explorer dies for ANY reason — crash included — the kernel closes the handle and reaps the
// child, so orderly Dispose is just the graceful path. Always IntPtr.Zero on the macOS editor.
private IntPtr jobHandle;
#endif
public string BaseUrl { get; }
private AbgenSidecar(string baseUrl, string executablePath, string realmRoot, string upstreamCdnUrl, string cacheRoot, bool jitContentDigest)
{
BaseUrl = baseUrl;
this.executablePath = executablePath;
this.realmRoot = realmRoot;
catalystContentUrl = realmRoot + CONTENT_PATH;
this.upstreamCdnUrl = upstreamCdnUrl;
this.cacheRoot = cacheRoot;
this.jitContentDigest = jitContentDigest;
}
public static string StreamingAssetsExecutablePath =>
Path.Combine(Application.streamingAssetsPath, IsWindows ? "abgen.exe" : "abgen");
private static bool IsWindows => Application.platform is RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsEditor;
/// <summary>
/// The server's endpoint WITHOUT creating or starting anything — synchronous, so the URL can
/// seed the URL sources built early in startup. The server is created on this URL later via
/// <see cref="TryCreate" />. Always abgen's default bind (127.0.0.1:5147): exporting the port
/// would take the generic HTTP_SERVER_HOST/PORT names, which leak into every child process
/// spawned after <see cref="Launch" />. A second --local-ab instance loses the port and its
/// scene degrades to raw GLTFs — acceptable for a dev-only tool.
/// </summary>
public static string ReserveBaseUrl() =>
$"http://127.0.0.1:{ABGEN_DEFAULT_PORT}";
/// <summary>
/// Resolves the server binary and creates the (not yet started) sidecar on
/// <paramref name="baseUrl" />; <see cref="StartAsync" /> launches it. Returns null when no
/// binary is installed — <see cref="EnsurePinnedBinaryAsync" /> downloads it.
/// <para>
/// <paramref name="realmRootOverride" /> points the server at a non-catalyst realm (the
/// local-scene-development preview server), whose /content endpoints the scene is read through;
/// its cache is kept apart from the catalyst one.
/// <paramref name="jitContentDigest" /> enables abgen's dev-mode freshness: every manifest request
/// re-downloads and re-hashes the entity's content, so edits reconvert even under LSD's
/// path-derived hashes, which never change.
/// </para>
/// </summary>
public static AbgenSidecar? TryCreate(string baseUrl, string environmentDomain, string? cacheRoot = null, string? realmRootOverride = null, bool jitContentDigest = false)
{
string? exe = TryFindPinnedExecutable() ?? (File.Exists(StreamingAssetsExecutablePath) ? StreamingAssetsExecutablePath : null);
if (exe == null)
return null;
return new AbgenSidecar(baseUrl,
exe,
realmRootOverride?.TrimEnd('/') ?? $"https://peer.decentraland.{environmentDomain}",
$"https://ab-cdn.decentraland.{environmentDomain}",
cacheRoot ?? Path.Combine(Application.persistentDataPath, realmRootOverride == null ? AbgenBundleDiskCache.SIDECAR_DIR : AbgenBundleDiskCache.SIDECAR_LSD_DIR),
jitContentDigest);
}
/// <summary>
/// Launches the reserved server process and waits until it answers on <see cref="BaseUrl" />.
/// False when it could not start or never became healthy — the process is disposed and a
/// milestone row reports it; requests to <see cref="BaseUrl" /> then fail fast on the dead
/// loopback port.
/// </summary>
public async UniTask<bool> StartAsync(CancellationToken ct)
{
if (Launch(executablePath) && await WaitHealthyAsync(ct))
{
// The port answered, but only our own live child counts: with the fixed default
// endpoint, an orphaned abgen from a crashed session (or any foreign process on
// the port) could be the one listening — serving stale bundles from a stale
// version or another workspace's realm. Refuse to adopt it.
if (!ChildAlive())
{
AbgenConversionMetrics.INSTANCE.OnMilestone($"another process owns {BaseUrl} (our server exited on startup) — kill it and relaunch; the scene loads as raw GLTFs");
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, $"abgen sidecar: {BaseUrl} answers but our child is dead — a foreign/orphaned server owns the port; refusing to adopt it");
Dispose();
return false;
}
SuperviseAsync(ct).Forget();
return true;
}
AbgenConversionMetrics.INSTANCE.OnMilestone("abgen sidecar failed to start — the scene loads as raw GLTFs");
Dispose();
return false;
}
/// <summary>
/// Eager scene pre-conversion: resolves the realm's scene entity from its /about and requests that
/// entity's manifest, which makes the server JIT-convert every convertible file of the scene into
/// its corpus in one pass (observable at <c>/progress/{entity}</c>). Bundle requests that arrive
/// while the build runs coalesce with it; anything requested after is a disk hit. Failures are
/// logged and harmless — the lazy per-request lane still converts on demand.
/// </summary>
public async UniTask WarmUpLocalSceneAsync(CancellationToken ct)
{
try
{
using UnityWebRequest aboutRequest = UnityWebRequest.Get($"{realmRoot}/about");
aboutRequest.timeout = 10;
await aboutRequest.SendWebRequest().WithCancellation(ct);
string? entityId = ParseFirstSceneEntityId(aboutRequest.downloadHandler.text)
?? await ResolveEntityIdFromParcelAsync(aboutRequest.downloadHandler.text, ct);
if (entityId == null)
{
AbgenConversionMetrics.INSTANCE.OnMilestone("warm-up skipped — could not resolve the scene entity (no scenesUrn or localSceneParcels in the realm's /about)");
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, "abgen warm-up skipped: could not resolve the scene entity from the realm's /about");
return;
}
warmedEntityId = entityId;
AbgenConversionMetrics.INSTANCE.OnWarmUpStarted(entityId);
AbgenConversionMetrics.INSTANCE.OnMilestone($"warm-up started — converting scene {entityId} in the background");
ReportHub.Log(ReportCategory.ASSET_BUNDLES, $"abgen warm-up: converting scene {entityId} — asset bundles are being built in the background");
(float elapsedSeconds, bool sawBuildProgress, int exitCode) = await MirrorManifestBuildAsync(entityId, ct);
AbgenConversionMetrics.INSTANCE.OnWarmUpReady(elapsedSeconds, alreadyWarm: !sawBuildProgress);
AbgenConversionMetrics.INSTANCE.OnMilestone(sawBuildProgress
? $"manifest retrieved — asset bundles ready in {elapsedSeconds:F1}s"
: "asset bundles already converted — manifest served from warm cache");
ReportHub.Log(ReportCategory.ASSET_BUNDLES, sawBuildProgress
? $"abgen warm-up: manifest retrieved — asset bundles READY for scene {entityId} in {elapsedSeconds:F1}s"
: $"abgen warm-up: asset bundles already converted (warm cache) — manifest for scene {entityId} served in {elapsedSeconds:F1}s");
if (exitCode != 0)
{
AbgenConversionMetrics.INSTANCE.OnMilestone($"some files failed server-side conversion (manifest exitCode {exitCode})");
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, $"abgen warm-up: manifest exitCode {exitCode} — some files failed server-side conversion; check the sidecar's cache logs");
}
}
catch (OperationCanceledException) { }
catch (Exception e)
{
AbgenConversionMetrics.INSTANCE.OnWarmUpFailed();
AbgenConversionMetrics.INSTANCE.OnMilestone($"warm-up failed ({e.Message}) — bundles still convert lazily per request");
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, "abgen warm-up failed — bundles still convert lazily per request");
ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES);
}
}
/// <summary>
/// Session-long reconversion mirror: waits for the content-edit signal the LSD reload path
/// raises (see LocalSceneDevelopmentController) and re-runs the manifest lane for the warmed
/// scene — the request coalesces with (or triggers) the server's rebuild of the edited files,
/// so the AB panel flips back to converting, tracks the rebuild and settles to READY with the
/// reconversion time even when the rebuild outpaces the progress poll. Runs detached until
/// cancelled; never throws. No-op when the warm-up never resolved the scene entity.
/// </summary>
public async UniTask WatchReconversionsAsync(CancellationToken ct)
{
string? entityId = warmedEntityId;
if (entityId == null) return;
try
{
while (!ct.IsCancellationRequested)
{
await UniTask.Delay(CONTENT_EDIT_SIGNAL_POLL_MS, DelayType.Realtime, cancellationToken: ct);
if (!AbgenConversionMetrics.INSTANCE.TryConsumeContentEdit(out string? changedSrc))
continue;
AbgenConversionMetrics metrics = AbgenConversionMetrics.INSTANCE;
try
{
metrics.OnWarmUpStarted(entityId);
metrics.OnMilestone(changedSrc != null
? $"{changedSrc} changed — reconverting"
: "scene content changed — revalidating bundles");
(float elapsedSeconds, bool sawBuildProgress, int exitCode) = await MirrorManifestBuildAsync(entityId, ct);
// A named model edit always rebuilt its bundle, even faster than the progress poll
// samples; a whole-scene (code) update may genuinely have had nothing to rebuild.
bool rebuilt = changedSrc != null || sawBuildProgress;
metrics.OnWarmUpReady(elapsedSeconds, alreadyWarm: !rebuilt);
metrics.OnMilestone(rebuilt
? $"reconverted in {elapsedSeconds:F1}s"
: $"revalidated in {elapsedSeconds:F1}s — bundles already up to date");
ReportHub.Log(ReportCategory.ASSET_BUNDLES, $"abgen: scene {entityId} {(rebuilt ? "reconverted" : "revalidated")} after a content edit in {elapsedSeconds:F1}s");
if (exitCode != 0)
metrics.OnMilestone($"some files failed server-side conversion (manifest exitCode {exitCode})");
}
catch (OperationCanceledException) { throw; }
catch (Exception e)
{
metrics.OnWarmUpFailed();
metrics.OnMilestone($"reconversion failed ({e.Message}) — bundles still convert lazily per request");
ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES);
}
}
}
catch (OperationCanceledException) { }
catch (Exception e) { ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES); }
}
/// <summary>
/// Requests the entity's manifest — making the server build every stale bundle of the scene,
/// coalescing with any build already running — while mirroring <c>/progress/{entity}</c> into
/// the AB panel rows, then backfills the census. Returns the request's wall time, whether any
/// build progress was observed (false when everything was already warm or the build outpaced
/// the poll), and the manifest's exitCode (non-zero when files failed server-side).
/// </summary>
private async UniTask<(float elapsedSeconds, bool sawBuildProgress, int exitCode)> MirrorManifestBuildAsync(string entityId, CancellationToken ct)
{
string? convertingFile = null;
try
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
using UnityWebRequest manifestRequest = UnityWebRequest.Get($"{BaseUrl}/manifest/{entityId}{PlatformUtils.GetCurrentPlatform()}.json");
manifestRequest.timeout = 0; // a cold heavy scene converts for minutes; the server paces the build
UnityWebRequestAsyncOperation manifestOperation = manifestRequest.SendWebRequest();
// While the server holds the manifest request, mirror its per-file build progress into
// the metrics the scene dev console's AB tab renders. done/total are the server's own
// authoritative counters — the sampled per-file rows are color, not the count.
var lastDone = -1;
var lastTotal = -1;
var sawBuildProgress = false;
while (!manifestOperation.isDone)
{
await UniTask.Delay(PROGRESS_POLL_MS, DelayType.Realtime, cancellationToken: ct);
BuildProgress? progress = await TryGetBuildProgressAsync(entityId, ct);
if (progress == null) continue;
sawBuildProgress = true;
AbgenConversionMetrics metrics = AbgenConversionMetrics.INSTANCE;
if (progress.done != lastDone || progress.total != lastTotal)
{
lastDone = progress.done;
lastTotal = progress.total;
metrics.OnWarmUpProgress(progress.done, progress.total);
}
if (progress.file != convertingFile)
{
if (convertingFile != null)
metrics.OnProcessed(convertingFile);
convertingFile = null;
if (!string.IsNullOrEmpty(progress.file))
{
metrics.OnStarted(progress.file);
convertingFile = progress.file;
}
}
}
if (convertingFile != null)
{
AbgenConversionMetrics.INSTANCE.OnProcessed(convertingFile);
convertingFile = null;
}
if (manifestRequest.result != UnityWebRequest.Result.Success)
throw new IOException($"manifest request failed ({manifestRequest.responseCode}): {manifestRequest.error}");
// The progress poll only samples whichever file is converting at each tick, so fast files
// leave no row; backfill the panel with the scene's full convertible file list.
await ReconcileCensusAsync(entityId, ct);
return ((float)stopwatch.Elapsed.TotalSeconds, sawBuildProgress, JsonUtility.FromJson<CorpusManifest>(manifestRequest.downloadHandler.text).exitCode);
}
catch
{
if (convertingFile != null)
AbgenConversionMetrics.INSTANCE.OnCancelled(convertingFile);
throw;
}
}
/// <summary>
/// Backfills the AB panel with every convertible file of the scene entity, sourced from the
/// entity definition's content list (readable paths — the manifest only carries hashed artifact
/// names). Best effort: a failure leaves the sampled rows as they are.
/// </summary>
private async UniTask ReconcileCensusAsync(string entityId, CancellationToken ct)
{
try
{
using UnityWebRequest request = UnityWebRequest.Get($"{catalystContentUrl}/contents/{entityId}");
request.timeout = 10;
await request.SendWebRequest().WithCancellation(ct);
EntityContent? entity = JsonUtility.FromJson<EntityContent>(request.downloadHandler.text);
if (entity?.content == null) return;
var files = new List<string>(entity.content.Length);
foreach (EntityContent.FileEntry entry in entity.content)
if (IsConvertible(entry.file))
files.Add(entry.file);
AbgenConversionMetrics.INSTANCE.ReconcileWarmUpCensus(files);
}
catch (Exception e) when (e is not OperationCanceledException)
{
ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES);
}
}
/// <summary>The extensions abgen's corpus build converts: models and the standalone images they reference.</summary>
private static bool IsConvertible(string file) =>
file.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)
|| file.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase);
/// <summary>Null when no build for the entity is in flight (the route 404s before the build registers and after it finishes).</summary>
private async UniTask<BuildProgress?> TryGetBuildProgressAsync(string entityId, CancellationToken ct)
{
using UnityWebRequest request = UnityWebRequest.Get($"{BaseUrl}/progress/{entityId}");
request.timeout = 2;
try { await request.SendWebRequest().WithCancellation(ct); }
catch (OperationCanceledException) { throw; }
catch { return null; }
return JsonUtility.FromJson<BuildProgress>(request.downloadHandler.text);
}
/// <summary>
/// Resolves the scene entity by parcel pointer, for realms whose /about carries no scenesUrn —
/// the LSD preview server advertises <c>localSceneParcels</c> instead. Null when that field is
/// absent too or the content server returns no active entity for the parcel.
/// </summary>
private async UniTask<string?> ResolveEntityIdFromParcelAsync(string aboutJson, CancellationToken ct)
{
string? parcel = ParseJsonStringAfter(aboutJson, "\"localSceneParcels\":[\"");
if (parcel == null) return null;
using UnityWebRequest request = UnityWebRequest.Post($"{catalystContentUrl}/entities/active", $"{{\"pointers\":[\"{parcel}\"]}}", "application/json");
request.timeout = 10;
await request.SendWebRequest().WithCancellation(ct);
return ParseJsonStringAfter(request.downloadHandler.text, "\"id\":\"");
}
private static string? ParseJsonStringAfter(string json, string marker)
{
int start = json.IndexOf(marker, StringComparison.Ordinal);
if (start < 0) return null;
start += marker.Length;
int end = json.IndexOf('"', start);
return end > start ? json[start..end] : null;
}
/// <summary>First <c>urn:decentraland:entity:{id}</c> in the /about JSON; the id runs until the urn's query string or the JSON string ends.</summary>
private static string? ParseFirstSceneEntityId(string aboutJson)
{
const string URN_PREFIX = "urn:decentraland:entity:";
int start = aboutJson.IndexOf(URN_PREFIX, StringComparison.Ordinal);
if (start < 0) return null;
start += URN_PREFIX.Length;
int end = start;
while (end < aboutJson.Length && aboutJson[end] != '?' && aboutJson[end] != '"' && aboutJson[end] != '\\') end++;
return end > start ? aboutJson[start..end] : null;
}
public void Dispose()
{
disposed = true;
KillChild();
}
/// <summary>Release target triple and the pinned release's archive sha256 for the current platform; null when unsupported.</summary>
private static (string target, string sha256)? Platform() =>
Application.platform switch
{
RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsEditor => ("x86_64-pc-windows-gnu", "026c425d2d203c173876d7a33af66a292cd54e3db3d99677b05503f1a3826d1a"),
RuntimePlatform.OSXPlayer or RuntimePlatform.OSXEditor => RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? ("aarch64-apple-darwin", "ccff87a8192d5f329f0427e68fa850a490d4cefa53839f11062f91afe8420278")
: ("x86_64-apple-darwin", "8e3cb8957a8f1916b820e008d369be4cff0f1b024ff6764998f5c2bc5d151950"),
RuntimePlatform.LinuxPlayer or RuntimePlatform.LinuxEditor => ("x86_64-unknown-linux-gnu", "c155d8f27653bd357f42ca3cf7b848809d0c07804445f79011bce79c6a8594d3"),
_ => null,
};
/// <summary>The pinned version installed under <c>bin/{version}/abgen-v{version}-{target}/</c>, or null. Other installed versions are never executed.</summary>
private static string? TryFindPinnedExecutable()
{
string? target = Platform()?.target;
if (target == null) return null;
string exe = Path.Combine(Application.persistentDataPath, AbgenBundleDiskCache.SIDECAR_DIR, "bin",
PINNED_VERSION, $"abgen-v{PINNED_VERSION}-{target}", IsWindows ? "abgen.exe" : "abgen");
return File.Exists(exe) ? exe : null;
}
/// <summary>
/// Downloads and installs the pinned release, verified against its compile-time sha256.
/// Progress is reported to the AB panel as milestone rows. True when the binary is installed
/// and <see cref="TryCreate" /> will resolve it; false on an unsupported platform,
/// cancellation or a failed download.
/// </summary>
public static async UniTask<bool> EnsurePinnedBinaryAsync(CancellationToken ct)
{
if (Platform() == null)
return false;
try
{
(string target, string sha256) = Platform()!.Value;
await DownloadAndInstallAsync(PINNED_VERSION, target, sha256, ct);
return true;
}
catch (OperationCanceledException) { return false; }
catch (Exception e)
{
AbgenConversionMetrics.INSTANCE.OnMilestone($"abgen download failed ({e.Message}) — retried on the next launch");
ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES);
return false;
}
}
private static async UniTask DownloadAndInstallAsync(string version, string target, string sha256, CancellationToken ct)
{
string url = $"https://github.com/decentraland/abgen/releases/download/v{version}/abgen-v{version}-{target}.tar.gz";
AbgenConversionMetrics.INSTANCE.OnMilestone($"abgen binary not installed — downloading the pinned release v{version}");
using UnityWebRequest req = UnityWebRequest.Get(url);
req.timeout = 600;
UnityWebRequestAsyncOperation downloadOperation = req.SendWebRequest();
var lastReportedQuarter = 0;
// Disposing the request (the using above) aborts the transfer when ct fires mid-download.
while (!downloadOperation.isDone)
{
await UniTask.Delay(500, DelayType.Realtime, cancellationToken: ct);
var quarter = (int)(req.downloadProgress * 4f);
if (quarter > lastReportedQuarter && quarter < 4)
{
lastReportedQuarter = quarter;
AbgenConversionMetrics.INSTANCE.OnMilestone($"downloading abgen v{version} — {quarter * 25}% ({req.downloadedBytes / (1024 * 1024)} MB)");
}
}
if (req.result != UnityWebRequest.Result.Success)
throw new IOException($"abgen archive download failed: {req.error}");
byte[] archive = req.downloadHandler.data;
using (var sha = SHA256.Create())
{
string actual = BitConverter.ToString(sha.ComputeHash(archive)).Replace("-", "").ToLowerInvariant();
if (actual != sha256)
throw new IOException($"abgen archive checksum mismatch: {actual}");
}
await DCLTask.RunOnThreadPool(() =>
{
string finalDir = Path.Combine(Application.persistentDataPath, AbgenBundleDiskCache.SIDECAR_DIR, "bin", version);
string tmpDir = finalDir + ".tmp";
if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
ExtractTarGz(archive, tmpDir);
if (!IsWindows)
{
// Straight through libc — a chmod subprocess needs System.Diagnostics.Process,
// which cannot spawn under IL2CPP.
PosixChmod(tmpDir, UNIX_MODE_755);
foreach (string entry in Directory.GetFileSystemEntries(tmpDir, "*", SearchOption.AllDirectories))
PosixChmod(entry, UNIX_MODE_755);
}
if (Directory.Exists(finalDir)) Directory.Delete(finalDir, true);
Directory.Move(tmpDir, finalDir);
});
AbgenConversionMetrics.INSTANCE.OnMilestone($"abgen v{version} installed");
ReportHub.Log(ReportCategory.ASSET_BUNDLES, $"abgen sidecar binary v{version} downloaded and installed");
}
/// <summary>Minimal ustar reader: extracts regular files and directories, preserving relative paths.</summary>
private static void ExtractTarGz(byte[] archive, string destination)
{
using var gz = new GZipStream(new MemoryStream(archive), CompressionMode.Decompress);
var header = new byte[512];
while (ReadBlock(gz, header) && header[0] != 0)
{
string name = ReadString(header, 0, 100);
string prefix = ReadString(header, 345, 155);
if (prefix.Length > 0) name = prefix + "/" + name;
long size = Convert.ToInt64(ReadString(header, 124, 12).Trim(), 8);
byte type = header[156];
string path = Path.Combine(destination, name);
// Defense in depth: a ".." component would escape the destination. The archive is
// sha256-pinned, so this only fires on a hostile or corrupt file — skip the entry.
if (name.Contains(".."))
SkipBytes(gz, size);
else if (type == (byte)'5')
Directory.CreateDirectory(path);
else if (type is (byte)'0' or 0 && size >= 0)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using FileStream file = File.Create(path);
var buffer = new byte[81920];
long remaining = size;
while (remaining > 0)
{
int n = gz.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining));
if (n == 0) throw new IOException("truncated tar entry");
file.Write(buffer, 0, n);
remaining -= n;
}
}
else
SkipBytes(gz, size);
SkipBytes(gz, (512 - (size % 512)) % 512);
}
}
private static bool ReadBlock(Stream stream, byte[] block)
{
var read = 0;
while (read < block.Length)
{
int n = stream.Read(block, read, block.Length - read);
if (n == 0) return false;
read += n;
}
return true;
}
private static void SkipBytes(Stream stream, long count)
{
var buffer = new byte[512];
while (count > 0)
{
int n = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, count));
if (n == 0) return;
count -= n;
}
}
private static string ReadString(byte[] block, int offset, int length)
{
int end = offset;
while (end < offset + length && block[end] != 0) end++;
return System.Text.Encoding.UTF8.GetString(block, offset, end - offset);
}
private bool Launch(string executablePath)
{
try
{
// abgen is configured entirely through environment variables, and every spawn path
// below launches the child with this process's environment. The bind endpoint is NOT
// exported: the server's defaults already match ReserveBaseUrl (127.0.0.1:5147), and
// HTTP_SERVER_HOST/PORT are generic names any later-spawned child could misread.
Environment.SetEnvironmentVariable("ABGEN_CACHE_DIR", Path.Combine(cacheRoot, "cache"));
Environment.SetEnvironmentVariable("ABGEN_OUT_ROOT", Path.Combine(cacheRoot, "out"));
Environment.SetEnvironmentVariable("ABGEN_CATALYST_URL", catalystContentUrl);
Environment.SetEnvironmentVariable("ABGEN_UPSTREAM_AB_CDN", upstreamCdnUrl);
Environment.SetEnvironmentVariable("ABGEN_JIT_CONTENT_DIGEST", jitContentDigest ? "1" : null);
// With no backend pinned abgen auto-tries its GPU BC7/BC5 encoder, and arming CUDA
// costs ~60s before the HTTP listener binds — far past HEALTH_TIMEOUT_MS, so the
// sidecar is killed before it ever answers. The CPU encoder produces byte-identical
// bundles, so pinning it off only trades encode throughput for a ~1s startup.
Environment.SetEnvironmentVariable("ABGEN_GPU_BACKEND", "off");
return LaunchChild(executablePath);
}
catch (Exception e)
{
ReportHub.LogException(e, ReportCategory.ASSET_BUNDLES);
return false;
}
}
private bool LaunchChild(string executablePath)
{
#if UNITY_EDITOR
var psi = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
process = Process.Start(psi);
if (process == null) return false;
// Drain pipes so the child never blocks on a full stdout/stderr buffer.
process.OutputDataReceived += static (_, _) => { };
process.ErrorDataReceived += static (_, _) => { };
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (IsWindows)
TieChildLifetimeToUs(process.Handle);
return true;
#elif UNITY_STANDALONE_WIN
// CREATE_NO_WINDOW keeps the console-subsystem server from opening a console window;
// the child's null std handles are swallowed by its runtime.
var startupInfo = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
if (!CreateProcessW(null, new System.Text.StringBuilder($"\"{executablePath}\""), IntPtr.Zero, IntPtr.Zero, false,
CREATE_NO_WINDOW, IntPtr.Zero, null, ref startupInfo, out PROCESS_INFORMATION processInformation))
{
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, $"abgen CreateProcess failed (err {Marshal.GetLastWin32Error()})");
return false;
}
CloseHandle(processInformation.hThread);
if (processHandle != IntPtr.Zero) CloseHandle(processHandle);
processHandle = processInformation.hProcess;
TieChildLifetimeToUs(processHandle);
return true;
#else
Result<int> result = DclProcesses.Start(executablePath, Array.Empty<string>());
if (!result.Success)
{
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, $"abgen spawn failed: {result.ErrorMessage}");
return false;
}
processId = result.Value;
return true;
#endif
}
private bool ChildAlive()
{
#if UNITY_EDITOR
try { return process is { HasExited: false }; }
catch (Exception) { return false; }
#elif UNITY_STANDALONE_WIN
return processHandle != IntPtr.Zero && WaitForSingleObject(processHandle, 0) == WAIT_TIMEOUT;
#else
return processId > 0 && kill(processId, 0) == 0;
#endif
}
private void KillChild()
{
#if UNITY_EDITOR
try
{
if (process is { HasExited: false }) process.Kill();
process?.Dispose();
}
catch (Exception)
{
// Already exited or inaccessible — nothing to clean up.
}
process = null;
#elif UNITY_STANDALONE_WIN
if (processHandle == IntPtr.Zero) return;
TerminateProcess(processHandle, 0);
CloseHandle(processHandle);
processHandle = IntPtr.Zero;
#else
if (processId > 0) kill(processId, SIGKILL);
processId = 0;
#endif
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
if (jobHandle != IntPtr.Zero)
{
// Closing the kill-on-close job reaps anything still assigned to it.
CloseJobHandle(jobHandle);
jobHandle = IntPtr.Zero;
}
#endif
}
/// <summary>
/// Liveness is polled rather than event-driven — an Exited event needs the managed Process
/// object, which player builds don't have. A dead child is relaunched on the same port
/// (consumers already hold <see cref="BaseUrl" />) up to <see cref="MAX_RESTARTS" /> times.
/// </summary>
private async UniTaskVoid SuperviseAsync(CancellationToken ct)
{
while (!disposed && !ct.IsCancellationRequested)
{
await UniTask.Delay(SUPERVISION_POLL_MS, DelayType.Realtime, cancellationToken: ct).SuppressCancellationThrow();
if (disposed || ct.IsCancellationRequested) return;
if (ChildAlive()) continue;
// Main-thread only: UniTask.Delay resumes this loop on the player loop, so no atomicity is needed.
if (++restarts > MAX_RESTARTS)
{
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, "abgen sidecar keeps exiting; asset bundles fall back to direct CDN errors");
return;
}
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, $"abgen sidecar exited; restart {restarts}/{MAX_RESTARTS}");
if (!Launch(executablePath))
{
ReportHub.LogWarning(ReportCategory.ASSET_BUNDLES, "abgen sidecar restart failed; asset bundles fall back to direct CDN errors");
return;
}
}
}
private async UniTask<bool> WaitHealthyAsync(CancellationToken ct)
{
float deadline = Time.realtimeSinceStartup + (HEALTH_TIMEOUT_MS / 1000f);
while (Time.realtimeSinceStartup < deadline && !ct.IsCancellationRequested)
{
using (UnityWebRequest req = UnityWebRequest.Head(BaseUrl))
{
req.timeout = 1;
try { await req.SendWebRequest(); } catch { /* not up yet */ }
// Any HTTP response (even 404) proves the server is listening.
if (req.responseCode > 0) return true;
}
await UniTask.Delay(HEALTH_POLL_MS, DelayType.Realtime, cancellationToken: ct).SuppressCancellationThrow();
}
return false;
}
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
/// <summary>
/// Assigns the child to a kill-on-close Job Object, tying its lifetime to this process at the
/// kernel level. Best effort: on any failure the child just stays untethered, exactly as before.
/// Restarted children join the same job. Windows only — call sites are IsWindows-guarded in the
/// editor, so the kernel32 imports never bind on macOS.
/// </summary>
private void TieChildLifetimeToUs(IntPtr childProcessHandle)
{
if (jobHandle == IntPtr.Zero)
{
IntPtr job = CreateJobObjectW(IntPtr.Zero, null);
if (job == IntPtr.Zero) return;
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JOB_OBJECT_INFO_CLASS_EXTENDED_LIMIT, ref info, (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
{
CloseJobHandle(job);
return;
}
jobHandle = job;
}
AssignProcessToJobObject(jobHandle, childProcessHandle);
}
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
private const int JOB_OBJECT_INFO_CLASS_EXTENDED_LIMIT = 9;
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr CreateJobObjectW(IntPtr lpJobAttributes, string? lpName);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetInformationJobObject(IntPtr hJob, int jobObjectInformationClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInformation, uint cbJobObjectInformationLength);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
// Alias so the editor build (which lacks the player's CloseHandle import) can close the job.
[DllImport("kernel32.dll", EntryPoint = "CloseHandle", SetLastError = true)]
private static extern bool CloseJobHandle(IntPtr hObject);
#endif
private const uint UNIX_MODE_755 = 0x1ED; // rwxr-xr-x
// Never called on Windows (IsWindows-guarded call sites); the import only binds on first call.
[DllImport("libc", EntryPoint = "chmod", SetLastError = true)]
private static extern int PosixChmod(string path, uint mode);
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
private const uint CREATE_NO_WINDOW = 0x08000000;
private const uint WAIT_TIMEOUT = 0x102;
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFO
{
public int cb;
public IntPtr lpReserved;
public IntPtr lpDesktop;
public IntPtr lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessW(string? lpApplicationName, System.Text.StringBuilder lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string? lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
#elif !UNITY_EDITOR
private const int SIGKILL = 9;
[DllImport("libc", SetLastError = true)]
private static extern int kill(int pid, int sig);
#endif
/// <summary>abgen <c>GET /progress/{entity}</c> response (crate/src/abcdn/handlers/status.rs).</summary>
[Serializable]
private class BuildProgress
{
public int done;
public int total;
public string file = null!;
}