Skip to content

feat(abgen): local-ab follow-ups — reconversion mirroring, production fallback, orphan hardening - #9756

Open
dalkia wants to merge 4 commits into
mainfrom
feat/local-ab-followups
Open

feat(abgen): local-ab follow-ups — reconversion mirroring, production fallback, orphan hardening#9756
dalkia wants to merge 4 commits into
mainfrom
feat/local-ab-followups

Conversation

@dalkia

@dalkia dalkia commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Follow-ups to #9704 (explorer-owned abgen sidecar). Closes #9749, plus orphan-process hardening. Based on main (where #9704 landed); to be rebased/retargeted as the trunks sync.

1. AB panel mirrors content-edit reconversions (#9749)

QA-confirmed on Windows + macOS: replacing a GLB reconverted correctly, but the panel kept saying "SCENE ALREADY CONVERTED" — the /progress mirroring only existed inside the boot warm-up.

Now event-driven end to end: LocalSceneDevelopmentController (the websocket handler that already receives the preview server's edit messages) raises a consumable signal on AbgenConversionMetrics — carrying the changed model's path for UpdateModel, null for whole-scene updates — and the sidecar's session-long watcher consumes it and re-runs the manifest lane itself. That request coalesces with (or front-runs) the server's rebuild and returns when the build finishes, so the panel flips to converting, names the edited file, and settles to READY with an accurate "reconverted in Ns" even when the rebuild outpaces the 500 ms progress poll. Named edits always report "reconverted"; unnamed (code-only) updates with no observed build report "revalidated — bundles already up to date". The manifest+progress+census loop is extracted into MirrorManifestBuildAsync, shared verbatim with the boot warm-up. No new assembly wiring (SceneLifeCycle already references ECS.Unity); outside --local-ab the signal is never consumed and stays inert.

Known limit: texture edits arrive as unnamed whole-scene updates (sdk-commands names only .glb/.gltf in file-watch-notifier.ts) — mirrored anonymously; widening that gate is a candidate js-sdk-toolchain follow-up.

2. Clean production fallback when the sidecar can't be had (#9749)

ReserveBaseUrl seeds the optimized-assets override before the sidecar ever starts, and a download/launch failure used to leave the dead loopback port as the base for the whole session: the scene degraded per request, wearables/emotes lost their production-CDN bundles entirely (per-request GLTF fallback), and the registry-composed Profiles/ProfilesMetadata/EntitiesActiveElements endpoints ate a dead-port round trip each.

AbgenSidecarPlugin.ReadyAsync is now UniTask<bool> — true once the server is healthy (warm-up outcome doesn't gate it: bundles JIT per request), false when it never came up (download failure, launch failure, port owned by someone else, teardown before start). On false, MainSceneLoader calls DecentralandUrlsSource.ClearOptimizedAssetsOverride() at the boot-hold — before any optimized-asset URL has resolved — which nulls the override and evicts the FeatureFlagsDependent URL cache class (the one every affected endpoint, registry-composed included, resolves under). The session then behaves byte-for-byte as if --local-ab had not been passed. A server that turns healthy and dies later keeps the override: supervision restarts it up to 3×, and per-request recovery remains the safety net.

3. Orphan-process hardening

Teardown was purely cooperative (Dispose kills the child), so a hard crash of the explorer orphaned the server — and with #9704's fixed default port (127.0.0.1:5147), the orphan owns the endpoint the next session expects. The health check accepted any listener, so the new session's child died on bind while a stale server (old version after a pin bump, or another workspace's realm) was silently adopted. Reproduced in the wild during Windows testing.

  • Refuse adoption: after the health check passes, StartAsync verifies our own child is still alive. If the port is answered by anything else, it fails fast with an explicit milestone ("another process owns … — kill it and relaunch") instead of serving stale bundles — and with §2, the session then falls back to production cleanly.
  • Windows kill-on-close Job Object (player + editor): every child (including supervision restarts) is assigned to a job with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so the kernel reaps it when the explorer dies for any reason, crash included. Pure kernel32 P/Invoke, IL2CPP-safe, best-effort (failure leaves behavior exactly as before). macOS has no equivalent primitive; a parent-pid watchdog in abgen is the tracked upstream complement.

4. Scene-entry manifest reuse (~6 s on a large scene)

Boot already holds until the warm-up's manifest request returns — then the scene lane re-fetched the exact same URL seconds later, paying abgen's content revalidation (ABGEN_JIT_CONTENT_DIGEST) twice: ~6 s on a Genesis-Plaza-sized scene.

The sidecar now hands the awaited response over (AbgenManifestPrewarm, a single-entry holder keyed by the exact URL) and LoadAssetBundleManifestSystem consumes it instead of re-fetching, logging the reuse. A content edit invalidates the entry immediately (the file census may have changed) and the reconversion watcher's re-fetch repopulates it, so post-edit reloads benefit too; a reload that races the watcher just misses and fetches fresh. Any URL mismatch degrades silently to the normal network path, and outside --local-ab the holder is never populated.

Related upstream work (not in this PR)

  • abgen#47 (open): namespaces the bind env vars as ABGEN_HTTP_HOST/ABGEN_HTTP_PORT with the legacy generic names kept as fallbacks. Once released and the pin is bumped, the explorer can return to a per-session free port — dissolving the single-instance limitation and shrinking the orphan-collision surface further.
  • Disk usage (analyzed, fix pending upstream): the sidecar's LSD store measured ~981 MB for one Genesis-Plaza-sized scene — ~340 MB of built bundles stored twice (JIT bundle cache + the corpus layout the HTTP routes serve from; build_entity_into_corpus rewrites the bytes bundle() just cached) plus ~300 MB of mirrored source content. The corpus write becoming a hard link (the idiom that function already uses for the digest alias) halves the bundle storage; abgen PR to follow. The CLEAR CACHE button in the AB panel remains the manual relief valve.
  • macOS crash-orphan reaping: no job-object equivalent exists there, so the complement to §3 is a parent-pid watchdog in abgen (exit when reparented); to be filed upstream.

Test plan

  • Cold boot into a large scene: log shows "manifest for {hash} served from the abgen warm-up hand-off" and scene entry skips the second ~6 s manifest wait.
  • Edit a GLB with the scene warm: panel flips to converting, names the file, settles to "reconverted in Ns"; repeat with a sub-second edit — same, with accurate elapsed.
  • Code-only edit: "scene content changed — revalidating bundles" → "revalidated — bundles already up to date"; no false "reconverted".
  • Break the download (offline) on a machine without the binary: boot proceeds, wearables/emotes load their production-CDN bundles (not GLTFs), profiles resolve against production directly.
  • Windows: kill the explorer from Task Manager mid-session → abgen.exe disappears with it; start a bare abgen manually, then launch the explorer → "another process owns http://127.0.0.1:5147" milestone, session on production/GLTFs.
  • Outside --local-ab: no behavior change (signal unconsumed, readiness pre-completed true, no job object, no clear call).

🤖 Generated with Claude Code

dalkia and others added 3 commits August 14, 2026 13:21
The /progress mirroring only existed inside the boot warm-up, so a GLB edit
reconverted correctly but the panel kept saying ALREADY CONVERTED (QA, both
platforms). The LSD reload path now raises a consumable signal on
AbgenConversionMetrics (carrying UpdateModel's src; null for whole-scene
updates), and a session-long watcher consumes it and re-runs the manifest
lane — coalescing with the server's rebuild, so elapsed/READY are accurate
even when the rebuild outpaces the 500ms progress poll. The manifest+progress
+census loop is extracted into MirrorManifestBuildAsync, shared verbatim with
the warm-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReserveBaseUrl seeds the optimized-assets override before the sidecar starts;
on download/launch failure the dead loopback port previously stayed the base
for the whole session — wearables degraded to raw GLTFs per request and the
registry-composed profile/entities endpoints ate a dead-port round trip each.
ReadyAsync now resolves a bool (healthy server = true; warm-up outcome does
not matter, bundles JIT per request), and on false MainSceneLoader drops the
override at the boot-hold — before any optimized-asset URL has resolved — via
ClearOptimizedAssetsOverride, which evicts the flag-dependent URL cache class.
The session then behaves exactly as if --local-ab had not been passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Windows)

With the fixed default port an abgen orphaned by a crashed session owns the
endpoint the next session expects; the health check accepted any listener, so
a stale server (old version, other workspace's realm) was silently adopted
while our own child died on bind. StartAsync now verifies our child is alive
after the health check and fails fast otherwise. On Windows (player and
editor) every child is additionally assigned to a kill-on-close Job Object,
so the kernel reaps it when the explorer dies for any reason — crash included.
macOS keeps cooperative teardown; a parent-pid watchdog in abgen is the
upstream complement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dalkia
dalkia requested review from a team as code owners August 14, 2026 16:23
@github-actions
github-actions Bot requested a review from DafGreco August 14, 2026 16:23
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit 1780173
Logs https://github.com/decentraland/unity-explorer/actions/runs/31827738325
Download Windows https://github.com/decentraland/unity-explorer/suites/86348703889/artifacts/9230703319
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/feat/local-ab-followups/pr-25155-1780173/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/86348703889/artifacts/9230470346
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/feat/local-ab-followups/pr-25155-1780173/Decentraland_macos.zip
Built on 2026-08-14T18:59:38Z

Lint

Warnings not reduced: 13156 => 13274 — remove at least 119 warnings to merge.

Warnings/errors in files changed by this PR (11)
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleManifestSystem.cs:30  InconsistentNaming  Name 'assetBundleURL' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'assetBundleUrl'.
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleManifestSystem.cs:35  InconsistentNaming  Name 'assetBundleURL' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'assetBundleUrl'.
Assets/DCL/Infrastructure/Global/Dynamic/AbgenSidecar.cs:672  ParameterHidesMember  Parameter 'executablePath' hides field 'Global.Dynamic.AbgenSidecar.executablePath'
Assets/DCL/Infrastructure/Global/Dynamic/AbgenSidecar.cs:701  ParameterHidesMember  Parameter 'executablePath' hides field 'Global.Dynamic.AbgenSidecar.executablePath'
Assets/DCL/Infrastructure/Global/Dynamic/AbgenSidecar.cs:308  RedundantNameQualifier  Qualifier is redundant
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AbgenConversionMetrics.cs:1  RedundantNullableDirective  Redundant nullable directive
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AbgenManifestPrewarm.cs:1  RedundantNullableDirective  Redundant nullable directive
Assets/DCL/Infrastructure/Global/Dynamic/AbgenSidecar.cs:1  RedundantNullableDirective  Redundant nullable directive
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleManifestSystem.cs:8  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleManifestSystem.cs:22  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Infrastructure/SceneLifeCycle/LocalSceneDevelopment/LocalSceneDevelopmentController.cs:5  RedundantUsingDirective  Using directive is not required by the code and can be safely removed

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24972 0 13
PlayMode ✅ Passed 236 0 37

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — feat(abgen): local-ab follow-ups

STEP 2 — Root-cause check

The diff addresses three distinct root causes:

  1. Reconversion mirroring: the /progress mirror was inlined in the boot warm-up with no event-driven continuation for content edits — fixed by extracting MirrorManifestBuildAsync and adding WatchReconversionsAsync driven by a consumable signal.
  2. Production fallback: a download/launch failure left the dead loopback port as the optimized-assets base for the whole session — fixed by returning a usability bool from ReadyAsync and clearing the override on failure.
  3. Orphan hardening: teardown was purely cooperative, orphaning the server on crashes — fixed with Windows Job Objects and a child-alive check after the health check.

Root-cause verdict: PASS — the diff fixes causes, not symptoms.


STEP 3 — Design & integration

Owner search

New unit Owner searched Files examined Verdict
WatchReconversionsAsync AbgenSidecar already owns HTTP communication, warm-up, progress, census AbgenSidecar.cs, AbgenSidecarPlugin.cs Correct owner — method on existing class, CancellationToken from plugin's lifecycle CTS (SafeCancelAndDispose()'d in Dispose())
MirrorManifestBuildAsync Private extraction from existing warm-up body AbgenSidecar.cs Two callers, same class — clean extraction
OnContentEdit/TryConsumeContentEdit AbgenConversionMetrics.INSTANCE — the established cross-assembly signal channel AbgenConversionMetrics.cs Identical pattern to existing RequestPanelOpen/TryConsumePanelOpenRequest (same lock, same consume-resets semantics)
ClearOptimizedAssetsOverride DecentralandUrlsSource owns the override field DecentralandUrlsSource.cs, MainSceneLoader.cs URL source is the natural owner; MainSceneLoader is the correct caller (boot-hold, before any optimized-asset request resolves)
TieChildLifetimeToUs / Job Object AbgenSidecar already owns the child process (handles, kill, dispose) AbgenSidecar.cs Job handle managed alongside process handle in KillChild()/Dispose()

Teardown / consumption trace

  • contentEditSignaled/contentEditSrc: consumed (reset) by TryConsumeContentEdit under lock. Singleton lifetime — no disposal needed. ✓
  • WatchReconversionsAsync: cancelled via CT from lifeCycleCancellationTokenSource, SafeCancelAndDispose()'d in AbgenSidecarPlugin.Dispose(). ✓
  • jobHandle: closed in KillChild() → called from Dispose(). ✓
  • warmedEntityId: nullable string field, written-before-read on same async chain. No teardown needed. ✓

Design verdict: PASS — all new logic lives on existing owners; no new lifecycle units; signal pattern matches existing precedent.


STEP 4 — Member audit

Member Consumers Assessment
OnContentEdit(string?) LocalSceneDevelopmentController (1) Producer half of produce/consume pair — 1 producer correct for single-pipeline singleton
TryConsumeContentEdit(out string?) WatchReconversionsAsync (1) Consumer half — 1 consumer correct
WatchReconversionsAsync(CancellationToken) AbgenSidecarPlugin.RunAsync (1) Public for cross-class call; self-contained exception handling justifies .Forget()
MirrorManifestBuildAsync(string, CancellationToken) WarmUpLocalSceneAsync + WatchReconversionsAsync (2) Clean private extraction
ClearOptimizedAssetsOverride() MainSceneLoader (1) Interface method; NSubstitute auto-implements for test fakes
warmedEntityId field Written WarmUpLocalSceneAsync, read WatchReconversionsAsync Both on same async chain, write-before-read guaranteed

No single-use-should-inline, absent≠false, or redundant-guard issues found.


STEP 5 — Line-level findings

No blocking issues found. Detailed verification:

  • Thread safety: All shared state on AbgenConversionMetrics is lock(gate) guarded, consistent with every existing member. The optimizedAssetsBaseOverride mutation is main-thread-only (boot flow). OnContentEdit is called from the thread-pool (websocket receive loop) — safe because it acquires the lock.
  • Exception handling: OperationCanceledException suppressed at the right boundaries; all other exceptions reported via ReportHub.LogException. Inner-to-outer OCE propagation in WatchReconversionsAsync (rethrow → outer catch suppresses) is correct.
  • P/Invoke: Struct layouts match the Windows SDK. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 and JOB_OBJECT_INFO_CLASS_EXTENDED_LIMIT = 9 are correct. The full JOBOBJECT_EXTENDED_LIMIT_INFORMATION struct (including IO_COUNTERS) is required for correct LayoutKind.Sequential marshaling.
  • .Forget() on WatchReconversionsAsync: Acceptable — the method handles its own exceptions (§9 compliant), is not essential setup (server-side conversion is independent), and the boot-hold must release.
  • Bare catch { … throw; } in MirrorManifestBuildAsync: Correctly handles both OCE and general exceptions by cleaning up convertingFile and rethrowing to the caller.
  • LINQ in ClearOptimizedAssetsOverride: Matches the existing ResetRealmDependentUrls pattern; called at most once per session — not a hot path.
  • AssignProcessToJobObject return unchecked: Documented as best-effort; failure leaves behavior exactly as before.

Security review

  • No secrets, tokens, or credentials exposed in the diff.
  • Loopback-only binding (127.0.0.1:5147): not network-exposed.
  • Orphan detection correctly refuses foreign processes: after health check, ChildAlive() verifies our child is live; a foreign listener is rejected with an explicit milestone.
  • P/Invoke safety: all kernel32 handles created/used/closed in KillChild. No unsafe memory operations.
  • No path traversal or injection: BaseUrl is a fixed loopback, entity IDs parsed from trusted server responses, env var names are compile-time constants.

No security issues found.


STEP 6 — Complexity

COMPLEX

STEP 7 — QA

YES — changes runtime code under Explorer/ that ships in the build; affects local scene development workflow, asset loading behavior (production fallback), and process management.

STEP 8 — Warnings

None. Main.unity is not in the changed files.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Async plugin wiring (UniTask return type change), cross-assembly consumable-signal pattern, kernel32 P/Invoke Job Objects, and URL resolution cache eviction across 9 files
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@decentraland-bot

This comment has been minimized.

Boot already holds until the warm-up's manifest request returns, then the
scene lane re-fetched the same URL seconds later — paying abgen's content
revalidation twice (~6s on a large scene under ABGEN_JIT_CONTENT_DIGEST).
The sidecar now hands the awaited response over (AbgenManifestPrewarm, keyed
by exact URL) and LoadAssetBundleManifestSystem reuses it; a content edit
invalidates the entry (the census may change) and the reconversion watcher's
re-fetch repopulates it, so post-edit reloads reuse it too. Any URL mismatch
degrades silently to the normal fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9756, run #31831283599

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2701 (×3) 2361 (×3)
CPU average 33.2 ms (33.2–34.6) 37.8 ms (35.0–38.0) 4.6 ms 🔴 14% slower
CPU 1% worst 34.3 ms (33.5–184.5) 291.6 ms (223.6–298.0) 257.4 ms 🔴 751% slower
CPU 0.1% worst 41.4 ms (33.7–332.0) 304.5 ms (278.4–310.3) 263.1 ms ⚪ within noise
GPU average 9.3 ms (9.2–9.4) 9.5 ms (9.2–9.5) 0.2 ms ⚪ within noise
GPU 1% worst 20.7 ms (19.8–26.9) 32.6 ms (27.2–32.6) 11.9 ms 🔴 57% slower
GPU 0.1% worst 36.3 ms (31.6–37.7) 37.9 ms (37.1–40.4) 1.6 ms ⚪ within noise
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4368 (×3) 3989 (×3)
CPU average 20.5 ms (20.3–21.6) 22.5 ms (22.0–23.0) 2.0 ms 🔴 10% slower
CPU 1% worst 34.7 ms (33.9–34.7) 219.8 ms (217.3–222.0) 185.1 ms 🔴 534% slower
CPU 0.1% worst 34.9 ms (34.9–35.3) 228.0 ms (225.6–228.4) 193.1 ms 🔴 553% slower
GPU average 1.0 ms (0.1–1.6) 5.5 ms (2.4–8.6) 4.6 ms ⚪ within noise
GPU 1% worst 34.2 ms (7.7–34.8) 35.2 ms (34.6–37.0) 1.1 ms ⚪ within noise
GPU 0.1% worst 35.9 ms (35.1–37.2) 37.4 ms (36.2–37.7) 1.5 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants