@@ -16,7 +16,13 @@ public static class ImmutableAudience
1616 // `volatile _initialized` flag first so they never see a half-initialised state.
1717 // _consent and _session are written only inside _initLock but read outside,
1818 // so they stay `volatile` to make writes visible across threads.
19- // _userId is written outside the lock (Identify, Reset) — `volatile` for the same reason.
19+ // _userId is written outside the lock (Identify) — `volatile` for the same reason.
20+ //
21+ // Init / Shutdown / Reset / SetConsent hold _initLock only to flip state
22+ // and capture references; they release the lock before running blocking
23+ // teardown (Session.Dispose, timer drain, queue shutdown, transport
24+ // flush, disposes). This keeps the hold time to nanoseconds so a caller
25+ // arriving on a different thread is not stranded behind those budgets.
2026 private static AudienceConfig ? _config ;
2127 private static DiskStore ? _store ;
2228 private static EventQueue ? _queue ;
@@ -258,23 +264,22 @@ public static void Alias(string fromId, string fromType, string toId, string toT
258264 // and then purged). Call FlushAsync() first to preserve queued events.
259265 public static void Reset ( )
260266 {
261- // Phase 1 under _initLock: swap _session and reset identity. Blocking
262- // work (session drain, disk purge, new session_start) runs outside
263- // the lock so callers racing on _initLock don't wait on it.
267+ // Phase 1 under _initLock: swap _session and clear _userId. Blocking
268+ // work (session drain, disk purge, identity wipe, new session_start)
269+ // runs outside the lock so callers racing on _initLock don't wait.
270+ AudienceConfig ? config ;
264271 Session ? oldSession ;
265272 Session ? newSession = null ;
266273 EventQueue ? queueForPurge ;
267274
268275 lock ( _initLock )
269276 {
270277 if ( ! _initialized ) return ;
271- var config = _config ;
278+ config = _config ;
272279 if ( config == null ) return ;
273280
274281 oldSession = _session ;
275282 queueForPurge = _queue ;
276-
277- Identity . Reset ( config . PersistentDataPath ! ) ;
278283 _userId = null ;
279284
280285 // Swap under the lock so racing SetConsent/OnPause/OnResume see
@@ -283,11 +288,13 @@ public static void Reset()
283288 newSession = _session ;
284289 }
285290
286- // Phase 2 outside _initLock:
287- // Dispose enqueues session_end → PurgeAll wipes it → Start emits the
288- // new session_start. Order matches the in-lock sequence this replaces.
291+ // Phase 2 outside _initLock. Order: Dispose enqueues session_end →
292+ // PurgeAll wipes it → Identity.Reset clears the anonymousId file →
293+ // Start emits the new session_start against the fresh id. Matches
294+ // the in-lock sequence this replaces.
289295 oldSession ? . Dispose ( ) ;
290296 queueForPurge ? . PurgeAll ( ) ;
297+ Identity . Reset ( config . PersistentDataPath ! ) ;
291298 newSession ? . Start ( ) ;
292299 }
293300
@@ -367,69 +374,91 @@ public static void SetConsent(ConsentLevel level)
367374 {
368375 if ( ! _initialized ) return ;
369376
370- // Serialised under _initLock: prevents concurrent upgrades from each
371- // building a fresh Session (stranding timers), and prevents racing
372- // Init's _session assignment.
373- Session ? sessionToStart = null ;
377+ // Phase 1 under _initLock: flip _consent and swap _session / _userId.
378+ // Phase 2 outside the lock runs the blocking side effects (persist,
379+ // dispose, purge, downgrade, backend sync, new session_start) so a
380+ // concurrent Shutdown / Init / Reset isn't held waiting on them.
381+ ConsentLevel previous ;
382+ AudienceConfig ? config ;
383+ EventQueue ? queue ;
384+ Session ? oldSession = null ;
385+ Session ? newSession = null ;
386+ string ? anonymousIdForPut ;
387+ bool downgradeFullToAnonymous = false ;
388+
374389 lock ( _initLock )
375390 {
376391 if ( ! _initialized ) return ;
377392
378- var config = _config ;
379- var queue = _queue ;
393+ config = _config ;
394+ queue = _queue ;
380395 if ( config == null ) return ;
381396
382- var previous = _consent ;
397+ previous = _consent ;
383398 if ( level == previous ) return ;
384399
385400 // Snapshot anonymousId before Identity.Reset (on None) wipes it.
386401 // The PUT audit trail needs to record whose consent changed.
387- var anonymousIdForPut = previous == ConsentLevel . None
402+ anonymousIdForPut = previous == ConsentLevel . None
388403 ? Identity . GetOrCreate ( config . PersistentDataPath ! , level )
389404 : Identity . Get ( config . PersistentDataPath ! ) ;
390405
391406 _consent = level ;
392407
393- try
394- {
395- // PersistentDataPath validated non-null in Init; compiler can't propagate that.
396- ConsentStore . Save ( config . PersistentDataPath ! , level ) ;
397- }
398- catch ( Exception ex ) when ( ex is IOException || ex is UnauthorizedAccessException )
399- {
400- Log . Warn ( $ "SetConsent — failed to persist consent level: { ex . GetType ( ) . Name } : { ex . Message } . " +
401- "In-memory level is updated but will revert on next launch." ) ;
402- NotifyErrorCallback ( config . OnError , AudienceErrorCode . ConsentPersistFailed ,
403- $ "Consent persist failed: { ex . Message } ") ;
404- }
405-
406408 if ( level == ConsentLevel . None )
407409 {
408- // Dispose for timer cleanup. session_end is gated out by
409- // CanTrack (post-flip), matching revocation semantics.
410- _session ? . Dispose ( ) ;
410+ // Swap the session reference under the lock; dispose outside.
411+ // session_end is gated out by CanTrack (post-flip), matching
412+ // revocation semantics.
413+ oldSession = _session ;
411414 _session = null ;
412-
413- queue ? . PurgeAll ( ) ;
414- Identity . Reset ( config . PersistentDataPath ! ) ;
415415 }
416416 else if ( previous == ConsentLevel . Full && level == ConsentLevel . Anonymous )
417417 {
418418 _userId = null ;
419- queue ? . ApplyAnonymousDowngrade ( ) ;
419+ downgradeFullToAnonymous = true ;
420420 }
421421 else if ( previous == ConsentLevel . None && _session == null )
422422 {
423- // Upgrade from None: previous session was disposed. Start a
424- // fresh one. Start() deferred to outside the lock.
425- _session = new Session ( Track ) ;
426- sessionToStart = _session ;
423+ // Upgrade from None: allocate + publish the new Session under
424+ // the lock so a concurrent SetConsent / Init sees the new
425+ // reference and the double-allocation guard above fires.
426+ newSession = new Session ( Track ) ;
427+ _session = newSession ;
427428 }
429+ }
428430
429- SyncConsentToBackend ( config , level , anonymousIdForPut ) ;
431+ // Phase 2 outside _initLock.
432+ try
433+ {
434+ // PersistentDataPath validated non-null in Init; compiler can't propagate that.
435+ ConsentStore . Save ( config . PersistentDataPath ! , level ) ;
436+ }
437+ catch ( Exception ex ) when ( ex is IOException || ex is UnauthorizedAccessException )
438+ {
439+ Log . Warn ( $ "SetConsent — failed to persist consent level: { ex . GetType ( ) . Name } : { ex . Message } . " +
440+ "In-memory level is updated but will revert on next launch." ) ;
441+ NotifyErrorCallback ( config . OnError , AudienceErrorCode . ConsentPersistFailed ,
442+ $ "Consent persist failed: { ex . Message } ") ;
430443 }
431444
432- sessionToStart ? . Start ( ) ;
445+ if ( level == ConsentLevel . None )
446+ {
447+ oldSession ? . Dispose ( ) ;
448+ queue ? . PurgeAll ( ) ;
449+ Identity . Reset ( config . PersistentDataPath ! ) ;
450+ }
451+ else if ( downgradeFullToAnonymous )
452+ {
453+ // Synchronous: EventQueue.ApplyAnonymousDowngrade holds _drainLock
454+ // while it rewrites on-disk files, blocking the in-queue drain
455+ // and shutting the race with HttpTransport. See the method's comment.
456+ queue ? . ApplyAnonymousDowngrade ( ) ;
457+ }
458+
459+ newSession ? . Start ( ) ;
460+
461+ SyncConsentToBackend ( config , level , anonymousIdForPut ) ;
433462 }
434463
435464 // Fire-and-forget PUT /v1/audience/tracking-consent.
0 commit comments