fix: dispose never-shown AuthenticationScreenController without NRE - #9802
fix: dispose never-shown AuthenticationScreenController without NRE#9802alejandro-jimenez-dcl wants to merge 2 commits into
Conversation
## Problem At app exit, `MainSceneLoader.Shutdown` logs "`MainUIPlugin`'s thrown an exception on disposal" with an inner `NullReferenceException` from `AuthenticationScreenController.Dispose` (Sentry UNITY-EXPLORER-PC4, 44 events / 8 users past week). Worse than the log line: the first throwing controller aborts `MVCManager.Dispose`'s loop, so every controller registered after it (~200 total) leaks its disposal — event unsubscribes, CTS cancels — on every affected exit. ## Root cause `AuthenticationScreenController.Dispose` unconditionally disposes `fsm` and `audio`, which are only assigned in `OnViewInstantiated()` — and view instantiation is lazy and optional. Sessions started with `--skip-auth-screen` plus a valid cached identity (the Creator Hub preview flow) never show the auth screen, so both fields are still null at shutdown. The field annotations lied about the lifecycle: null-until-shown is the real invariant. ## Fix (~21 LOC, 2 files) 1. `AuthenticationScreenController`: declare `fsm`/`audio` nullable with the null-until-shown invariant; `Dispose()` uses `audio?.Dispose(); fsm?.Dispose();` (mirroring the adjacent `characterPreviewController?.Dispose()`); post-instantiation lifecycle sites use `!.` per the trust-nullability convention (ControllerBase guarantees show-lifecycle callbacks run only after `OnViewInstantiated`). An annotation correction, not a defensive null-check. 2. `MVCManager.Dispose` (defect-class hardening): per-controller try/catch + `ReportHub.LogException(..., ReportCategory.MVC)` so one throwing controller can no longer truncate disposal of the remaining controllers, the destruction CTS, and the windows stack. ## Test - `AuthenticationScreenControllerShould.NotThrowOnDisposeWhenViewWasNeverShown` — constructs the controller with a never-invoked view factory and disposes; RED at pin (NRE from `audio.Dispose()`). - `MVCManagerDisposeShould.DisposeRemainingControllersAndStackWhenOneControllerThrows` — order-independent: a throwing controller must not prevent the other controller and the windows stack from being disposed. ## Validation Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 2/2 as intended (NRE at `AuthenticationScreenController.Dispose`; the substitute's exception aborting `MVCManager.Dispose`) / GREEN PASS 2/2. Related: #9030 (stale-closed tracker for this exact Sentry issue, still occurring), #8972 (same defect, earlier symbolication), #6942 (closed umbrella for the shutdown-disposal class) Includes inspection-warning cleanup in all touched files.
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review — #9802 fix: dispose never-shown AuthenticationScreenController without NRE
STEP 2 — Root-cause check: ✅ PASS
The PR correctly identifies and fixes the root cause: fsm and audio are only assigned in OnViewInstantiated(), which is lazy and optional (sessions using --skip-auth-screen with a valid cached identity never trigger it). The field declarations lied about their lifecycle — they were declared non-nullable but were null in a legitimate runtime scenario. The fix corrects the annotations to match the real invariant (null-until-shown), which is a proper cause fix, not a symptom patch.
The MVCManager.Dispose hardening addresses a secondary defect in the same failure chain: a single throwing controller aborting disposal of all subsequent controllers (~200 total). The per-controller try/catch is a defect-class fix, not a workaround.
STEP 3 — Design & integration: ✅ PASS
No new long-lived units are introduced. Both changes modify existing classes:
- AuthenticationScreenController — A nullability annotation correction on two private fields. No new state, no new lifecycle, no new subscriptions.
- MVCManager.Dispose — A standard resilience pattern (per-item try/catch in a disposal loop). Logged via
ReportHub.LogException(e, ReportCategory.MVC)per project conventions.
Teardown/consumption trace:
audio?.Dispose()/fsm?.Dispose()— these are the teardown for objects created inOnViewInstantiated. When the view was never instantiated, both are null and the?.correctly skips them. When instantiated, both are disposed. ✅- No new subscriptions, events, connections, or buffers are introduced.
STEP 4 — Member audit: ✅ PASS
No new public members are added. The four events (DiscordButtonClicked, OTPVerified, OTPResend, ProfileFinalized) are existing declarations whose annotations are corrected from Action to Action? — C# events are null by default when no subscribers are attached, so this is a truthful annotation. RaiseProfileFinalized() already used ?.Invoke() before this PR.
STEP 5 — Line-level review: ✅ No issues found
Null-forgiving operators (!): The PR adds 8 ! usages on fsm and audio across lifecycle methods (OnBeforeViewShow, OnViewShow, OnViewClose, TryAutoLoginAndProceedAsync, ChangeAccountAsync). These are justified:
- Invariant documented at the field declaration:
// Null until OnViewInstantiated: the view is created lazily on first Show and may never be instantiated. - Lifecycle guarantee verified in
ControllerBase.LaunchViewLifeCycleAsync():OnViewInstantiated()always runs beforeOnBeforeViewShow()andOnViewShow();HideViewAsync()(which callsOnViewClose()) can only execute afterLaunchViewLifeCycleAsyncwas called.TryAutoLoginAndProceedAsyncandChangeAccountAsyncare only reachable fromOnBeforeViewShowand the view's UI respectively — both post-instantiation. - This follows the trust-nullability convention per CLAUDE.md: the annotation is correct (
T?), the!is used only where the lifecycle guarantees non-null, and the invariant is documented. This is an annotation correction, not a defensive null-check.
MVCManager.Dispose try/catch: Catches Exception broadly, which is correct for a disposal loop — you want to ensure cleanup continues regardless of what a single controller throws. The exception is logged (not swallowed) via ReportHub.LogException with the correct ReportCategory.MVC.
Disposal order: audio?.Dispose() before fsm?.Dispose() is consistent — audio is created first in OnViewInstantiated, and mirrors the adjacent characterPreviewController?.Dispose() pattern.
Tests:
AuthenticationScreenControllerShould.NotThrowOnDisposeWhenViewWasNeverShown— Minimal, focused. Usesnull!for all constructor params because the constructor only stores dependencies (none are dereferenced before view instantiation). Directly tests the RED scenario described in the PR.MVCManagerDisposeShould.DisposeRemainingControllersAndStackWhenOneControllerThrows— Clean NSubstitute setup, properLogAssert.ignoreFailingMessagesfor the expected ReportHub error log, verifies both the surviving controller and the windows stack are disposed. Uses existing namespace-level test helpers (ITestView,TestInputDatafromMVCManagerShould.cs) and definesIOtherTestViewfor the second controller registration — consistent with existing patterns.
STEP 3 (security pass): ✅ No security issues found
The changes are in disposal paths and nullable annotations. No secrets, no user input handling, no auth/authz logic changes, no sensitive data exposure.
STEP 6 — Complexity
COMPLEX — The diff touches MVCManager.Dispose, which is a resource cleanup path.
STEP 7 — QA assessment
YES — Both files are runtime code that ships in the Unity player. The auth screen disposal and MVC manager disposal execute during app shutdown.
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies MVCManager.Dispose (resource cleanup path) and AuthenticationScreenController disposal lifecycle.
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub
|
🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging. |
|
PR #9802, run #32266856354 Builds: Windows change, Windows baseline, macOS change, macOS baseline How to read this table
Intel Core i5
Exception breakdown
Apple M1
|
alejandro-jimenez-dcl
left a comment
There was a problem hiding this comment.
Approved
Problem
At app exit,
MainSceneLoader.Shutdownlogs "MainUIPlugin's thrown an exception ondisposal" with an inner
NullReferenceExceptionfromAuthenticationScreenController.Dispose(Sentry UNITY-EXPLORER-PC4, 44 events / 8 userspast week). Worse than the log line: the first throwing controller aborts
MVCManager.Dispose's loop, so every controller registered after it (~200 total) leaks itsdisposal - event unsubscribes, CTS cancels - on every affected exit.
Root cause
AuthenticationScreenController.Disposeunconditionally disposesfsmandaudio, whichare only assigned in
OnViewInstantiated()- and view instantiation is lazy and optional.Sessions started with
--skip-auth-screenplus a valid cached identity (the Creator Hubpreview flow) never show the auth screen, so both fields are still null at shutdown. The
field annotations lied about the lifecycle: null-until-shown is the real invariant.
Fix (~21 LOC, 2 files)
AuthenticationScreenController: declarefsm/audionullable with thenull-until-shown invariant;
Dispose()usesaudio?.Dispose(); fsm?.Dispose();(mirroring the adjacent
characterPreviewController?.Dispose()); post-instantiationlifecycle sites use
!.per the trust-nullability convention (ControllerBase guaranteesshow-lifecycle callbacks run only after
OnViewInstantiated). An annotation correction,not a defensive null-check.
MVCManager.Dispose(defect-class hardening): per-controller try/catch +ReportHub.LogException(..., ReportCategory.MVC)so one throwing controller can nolonger truncate disposal of the remaining controllers, the destruction CTS, and the
windows stack.
Test
AuthenticationScreenControllerShould.NotThrowOnDisposeWhenViewWasNeverShown- constructsthe controller with a never-invoked view factory and disposes; RED at pin (NRE from
audio.Dispose()).MVCManagerDisposeShould.DisposeRemainingControllersAndStackWhenOneControllerThrows-order-independent: a throwing controller must not prevent the other controller and the
windows stack from being disposed.
Validation
Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 2/2 as intended (NRE at
AuthenticationScreenController.Dispose; the substitute's exception abortingMVCManager.Dispose) / GREEN PASS 2/2.Related: #9030 (stale-closed tracker for this exact Sentry issue, still occurring), #8972
(same defect, earlier symbolication), #6942 (closed umbrella for the shutdown-disposal
class)