diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs new file mode 100644 index 000000000..a0f4414a9 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -0,0 +1,365 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Immutable.Audience +{ + // Fires a session event (session_start / session_heartbeat / session_end) + // through ImmutableAudience.Track. Declared as a named delegate so Session + // can be driven by tests with a mock without touching the static SDK surface. + internal delegate void TrackDelegate(string eventName, Dictionary properties); + + // Unity session lifecycle. Emits session_start / session_heartbeat / session_end. + // duration is engagement time (excludes pause). The heartbeat runs on a + // background thread; other methods run on the thread that called them. The + // track callback is invoked with the internal lock released. + // + // Start / End / Dispose are not safe to call from multiple threads at once. + // Callers run them one at a time (ImmutableAudience holds its init lock while + // calling Init / SetConsent / Shutdown / Reset — the only public entry points + // that touch a Session). Pause / Resume / OnHeartbeat are safe to call from + // any thread. + internal sealed class Session : IDisposable + { + internal const int HeartbeatIntervalMs = 60_000; + + // 30s: alt-tab beyond this rolls the session on Resume. + internal const int PauseTimeoutMs = 30_000; + + private readonly TrackDelegate _track; + private readonly Func>? _performanceSnapshot; + private readonly Func _getUtcNow; + private readonly int _heartbeatIntervalMs; + private readonly object _lock = new object(); + + private Timer? _heartbeatTimer; + private string? _sessionId; + private DateTime _sessionStart; + private DateTime? _pausedAt; + // Subtracted from wall-clock so duration reflects engagement. + private TimeSpan _accumulatedPause; + private bool _disposed; + + // Current session ID. Null before Start() is called and after End()/Dispose(). + internal string? SessionId + { + get { lock (_lock) return _sessionId; } + } + + // track: fires session events. performanceSnapshot: merges fps/memory + // into heartbeats (null on non-Unity). getUtcNow/heartbeatIntervalMs: test seams. + internal Session( + TrackDelegate track, + Func>? performanceSnapshot = null, + Func? getUtcNow = null, + int heartbeatIntervalMs = HeartbeatIntervalMs) + { + _track = track ?? throw new ArgumentNullException(nameof(track)); + _performanceSnapshot = performanceSnapshot; + _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); + _heartbeatIntervalMs = heartbeatIntervalMs; + } + + // Starts a session. Fires session_start and arms the heartbeat timer. + internal void Start() + { + // Phase 1: shut down the old timer with the internal lock released + // (the callback takes that lock itself). Old state left intact so a + // trailing callback sends a heartbeat for the old session — the + // backend receives it before the new session_start. + Timer? oldTimer; + lock (_lock) + { + if (_disposed) return; + oldTimer = _heartbeatTimer; + if (oldTimer != null) + { + oldTimer.Change(Timeout.Infinite, Timeout.Infinite); + _heartbeatTimer = null; + } + } + + if (oldTimer != null) + { + using var waited = new ManualResetEvent(false); + try + { + // 500ms budget (double-Start is a misuse path). + if (oldTimer.Dispose(waited)) + waited.WaitOne(TimeSpan.FromMilliseconds(500)); + } + catch (ObjectDisposedException) + { + } + } + + // Phase 2: populate new state. Re-check _disposed (may have flipped during drain). + string sessionId; + lock (_lock) + { + if (_disposed) return; + + _sessionId = Guid.NewGuid().ToString(); + _sessionStart = _getUtcNow(); + _pausedAt = null; + _accumulatedPause = TimeSpan.Zero; + + sessionId = _sessionId; + _heartbeatTimer = new Timer(_ => OnHeartbeat(), null, _heartbeatIntervalMs, _heartbeatIntervalMs); + } + + SafeTrack("session_start", new Dictionary + { + ["sessionId"] = sessionId + }); + } + + // Pause on focus-loss. Quiesces heartbeat; 30s threshold evaluated on next Resume. + internal void Pause() + { + lock (_lock) + { + if (_disposed || _sessionId == null) return; + // Keep the original anchor. Shifting forward shrinks Resume's + // pauseDuration (and ComputeEngagedSecondsLocked's live pause + // when End fires while paused), over-crediting engagement. + if (_pausedAt.HasValue) + { + Log.Debug("Session: Pause while already paused — ignoring."); + return; + } + _pausedAt = _getUtcNow(); + } + } + + // Resume on focus-gain. Pause >30s rolls the session (End + Start). + internal void Resume() + { + bool extended; + lock (_lock) + { + if (_disposed || _sessionId == null || _pausedAt == null) return; + + var pauseDuration = _getUtcNow() - _pausedAt.Value; + _pausedAt = null; + + // Clamp: wall-clock rewind (NTP) would otherwise over-credit engagement. + if (pauseDuration < TimeSpan.Zero) pauseDuration = TimeSpan.Zero; + + extended = pauseDuration.TotalMilliseconds > PauseTimeoutMs; + + // Credit in both paths. End (and then Start) reset the accumulator + // on the extended-pause rollover so there is no double-count. + _accumulatedPause += pauseDuration; + } + + if (extended) + { + // Extended pause: roll the session. End/Start fire _track outside _lock. + // Between End and Start other public methods early-return on _sessionId=null. + End(); + Start(); + } + } + + // Ends the session. Drains heartbeat before emitting session_end so wire + // order holds (drain timeout is best-effort; logs a warning on timeout). + internal void End() + { + // Phase 1: drain outside _lock (OnHeartbeat re-enters _lock). + DrainHeartbeatTimer(); + + // Phase 2: capture fields and reset so subsequent Start/Dispose sees clean state. + string sessionId; + long duration; + lock (_lock) + { + if (_sessionId == null) return; + sessionId = _sessionId!; + + // ComputeEngagedSecondsLocked folds in the live pause. + duration = ComputeEngagedSecondsLocked(); + ResetSessionStateLocked(); + } + + // duration is engagement-aware (excludes pause). Web SDK emits + // wall-clock; dashboards should not assume parity. + SafeTrack("session_end", new Dictionary + { + ["sessionId"] = sessionId, + ["durationSec"] = duration + }); + } + + // Emits session_end and seals the session without draining the heartbeat + // timer. Use when the caller needs to fire session_end inside a short + // gating lock (e.g. ImmutableAudience.Shutdown under _initLock while + // _initialized is still true) and will drain + dispose the timer after + // releasing the lock. Idempotent: a subsequent Dispose() → End() will + // find _sessionId null and no-op the re-emission. + internal void EmitEndAndSeal() + { + string sessionId; + long duration; + lock (_lock) + { + if (_disposed || _sessionId == null) return; + sessionId = _sessionId!; + duration = ComputeEngagedSecondsLocked(); + ResetSessionStateLocked(); + } + + SafeTrack("session_end", new Dictionary + { + ["sessionId"] = sessionId, + ["durationSec"] = duration + }); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + } + + // End does the drain + emit. Dispose adds the _disposed latch + // which blocks subsequent Start/Pause/Resume. + End(); + } + + // ----------------------------------------------------------------- + // Private + // ----------------------------------------------------------------- + + // Fires a heartbeat. Internal so tests can drive without waiting 60s. + // Skips while paused so backgrounded games don't dribble heartbeats. + internal void OnHeartbeat() + { + string sessionId; + long duration; + lock (_lock) + { + if (_disposed || _sessionId == null) return; + // A paused session doesn't send heartbeats. The timer keeps + // firing internally; this check stops the event from going out. + if (_pausedAt.HasValue) return; + sessionId = _sessionId!; + + duration = ComputeEngagedSecondsLocked(); + } + + // Build outside _lock so snapshot + track don't re-enter. + var properties = new Dictionary + { + ["sessionId"] = sessionId, + ["durationSec"] = duration + }; + + var perf = SafePerformanceSnapshot(); + if (perf != null) + { + foreach (var kv in perf) + { + // Don't let the provider clobber core fields. + if (properties.ContainsKey(kv.Key)) continue; + properties[kv.Key] = kv.Value; + } + } + + SafeTrack("session_heartbeat", properties); + } + + // Stops exceptions from the track callback from reaching upstream. + // Heartbeat runs on a background timer — an uncaught exception there + // crashes the game on modern .NET. Start / End run on the caller's + // thread, where it would bubble into Init / Shutdown. + private void SafeTrack(string eventName, Dictionary properties) + { + try + { + _track(eventName, properties); + } + catch (Exception ex) + { + Log.Warn($"Session: {eventName} track callback threw {ex.GetType().Name}. Event dropped."); + } + } + + // Stops exceptions from the studio-supplied snapshot callback from + // reaching the background timer. + private Dictionary? SafePerformanceSnapshot() + { + if (_performanceSnapshot == null) return null; + try + { + return _performanceSnapshot(); + } + catch (Exception ex) + { + Log.Warn($"Session: performance snapshot threw {ex.GetType().Name}. Heartbeat ships without performance fields."); + return null; + } + } + + // Stops the timer and waits for the in-flight callback. Runs outside + // _lock (OnHeartbeat re-enters). 1s budget (quits must not hang). Warns on timeout. + private void DrainHeartbeatTimer() + { + Timer? timer; + lock (_lock) + { + timer = _heartbeatTimer; + _heartbeatTimer = null; + } + if (timer == null) return; + + using var waited = new ManualResetEvent(false); + try + { + // Timer was already disposed. The signal handle won't fire, so + // don't wait for it. + if (!timer.Dispose(waited)) + return; + + if (!waited.WaitOne(TimeSpan.FromSeconds(1))) + { + Log.Warn("Session: heartbeat callback did not complete within 1s on timer stop. " + + "A trailing session_heartbeat may race with the next session lifecycle event."); + } + } + catch (ObjectDisposedException) + { + } + } + + // Caller must hold _lock. Engagement seconds = wall-clock − accumulated − live pause. + // Rounded to match Web SDK's Math.round. Clamped ≥0 for clock rewinds. + private long ComputeEngagedSecondsLocked() + { + var now = _getUtcNow(); + var livePause = _pausedAt.HasValue ? now - _pausedAt.Value : TimeSpan.Zero; + // Clamp: mirrors the Resume() guard. If the clock rewinds while the + // session is still paused and End / EmitEndAndSeal fires (e.g. + // Shutdown while backgrounded), livePause would be negative and, + // being subtracted, would inflate engagedSeconds past the wall-clock + // window. The final ≥0 clamp catches negatives but not inflation. + if (livePause < TimeSpan.Zero) livePause = TimeSpan.Zero; + var engagedSeconds = ((now - _sessionStart) - _accumulatedPause - livePause).TotalSeconds; + if (engagedSeconds < 0) return 0; + return (long)Math.Round(engagedSeconds, MidpointRounding.AwayFromZero); + } + + // Caller must hold _lock. Clears per-session state after End. + // Start inlines equivalent assignments; new state fields must update both. + private void ResetSessionStateLocked() + { + _sessionId = null; + _pausedAt = null; + _accumulatedPause = TimeSpan.Zero; + } + } +} diff --git a/src/Packages/Audience/Runtime/Core/Session.cs.meta b/src/Packages/Audience/Runtime/Core/Session.cs.meta new file mode 100644 index 000000000..a7ef5b1da --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/Session.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 159b16ab94bb14a51b0d3642318a3d6e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index e90ef7b46..53027a429 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -12,8 +12,17 @@ namespace Immutable.Audience // Entry point for the Immutable Audience SDK. public static class ImmutableAudience { - // Reference fields are written inside _initLock; readers fence off the volatile _initialized load. - // _consent and _userId are mutated outside the lock and need volatile themselves. + // Reference fields are written inside _initLock; readers check the + // `volatile _initialized` flag first so they never see a half-initialised state. + // _consent and _session are written only inside _initLock but read outside, + // so they stay `volatile` to make writes visible across threads. + // _userId is written outside the lock (Identify) — `volatile` for the same reason. + // + // Init / Shutdown / Reset / SetConsent hold _initLock only to flip state + // and capture references; they release the lock before running blocking + // teardown (Session.Dispose, timer drain, queue shutdown, transport + // flush, disposes). This keeps the hold time to nanoseconds so a caller + // arriving on a different thread is not stranded behind those budgets. private static AudienceConfig? _config; private static DiskStore? _store; private static EventQueue? _queue; @@ -26,23 +35,21 @@ public static class ImmutableAudience private static volatile bool _initialized; private static readonly object _initLock = new object(); - // Guard against overlapping timer ticks. System.Threading.Timer fires - // callbacks on independent ThreadPool threads and does not serialise - // them; without this gate, a slow SendBatchAsync (up to the HTTP - // timeout) would stack on every interval tick, each tick holding its - // own thread blocked on a pending request. + // Gate against overlapping timer ticks (Timer callbacks run on independent ThreadPool threads). private static int _sendInFlight; - // AudienceUnityHooks sets this at SubsystemRegistration so Unity studios - // can omit PersistentDataPath from AudienceConfig and Init will fill it - // from Application.persistentDataPath. Non-Unity callers must still set - // PersistentDataPath on the config. + // AudienceUnityHooks sets these at SubsystemRegistration. + // DefaultPersistentDataPathProvider fills PersistentDataPath from + // Application.persistentDataPath. LaunchContextProvider supplies + // Unity context for game_launch without Core referencing UnityEngine. internal static Func? DefaultPersistentDataPathProvider; - - // AudienceUnityHooks sets this so game_launch can auto-include - // Unity context without the core referencing UnityEngine. internal static Func>? LaunchContextProvider; + // Active session. Created at Init (or on upgrade from None) and disposed + // on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see + // assignments from SetConsent without taking _initLock. + private static volatile Session? _session; + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { @@ -56,6 +63,7 @@ public static void Init(AudienceConfig config) throw new ArgumentException("PersistentDataPath is required", nameof(config)); ConsentLevel consentAtInit; + Session? sessionToStart; lock (_initLock) { if (_initialized) @@ -67,7 +75,7 @@ public static void Init(AudienceConfig config) _config = config; Log.Enabled = config.Debug; - // Persisted consent overrides the config default so a prior runtime downgrade survives restart. + // Persisted consent overrides the config default (prior downgrade survives restart). _consent = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent; _store = new DiskStore(config.PersistentDataPath); @@ -85,23 +93,47 @@ public static void Init(AudienceConfig config) _initialized = true; - // Snapshot under the lock so a racing SetConsent(None) can't drop the launch event. + // Snapshot so a racing SetConsent(None) can't drop the launch event. consentAtInit = _consent; + + // Session created under the lock; Start() deferred until after + // release because session_start → Track takes its own locks. + if (consentAtInit.CanTrack()) + _session = new Session(Track); + + // Captured reference: a later SetConsent(None) may dispose this + // Session (Start then no-ops on _disposed). Either way no duplicate + // session_start and no post-revocation leak. + sessionToStart = _session; } + // session_start fires before game_launch so the wire stream + // shows the new sessionId ahead of the launch event. + sessionToStart?.Start(); + FireGameLaunch(config, consentAtInit); } + // Pause/Resume hooks for the Unity lifecycle bridge. + // Internal; reached via InternalsVisibleTo from the Unity assembly. + internal static void OnPause() + { + if (!_initialized) return; + _session?.Pause(); + } + + internal static void OnResume() + { + if (!_initialized) return; + _session?.Resume(); + } + // ----------------------------------------------------------------- // Track // ----------------------------------------------------------------- - // Send a typed event. - // - // Prefer this overload for predefined event names (e.g. purchase) — the - // IEvent implementation enforces required fields and value types at - // compile time. The string overload accepts any property shape and - // cannot catch missing or mistyped fields. + // Sends a typed event. Prefer this over the string overload — + // IEvent implementations validate required fields at compile time. public static void Track(IEvent evt) { if (!CanTrack()) return; @@ -140,16 +172,9 @@ public static void Track(IEvent evt) Enqueue(msg); } - // Send a custom event. - // - // For predefined event names (e.g. purchase, progression, resource, - // milestone_reached), prefer the typed overload — - // Track(new Purchase { Currency = "USD", Value = 9.99m }) — which - // validates required fields at send time. This overload accepts any - // property shape and does not: Track("purchase", new Dictionary...) - // that omits currency or value still enqueues and ships, but breaks - // attribution and conversion reporting downstream because the - // payload is missing the fields CDP needs to reconstruct the event. + // Sends a custom event. For predefined names (purchase, progression, + // resource, milestone_reached), prefer the typed overload which + // validates required fields. public static void Track(string eventName, Dictionary? properties = null) { if (!CanTrack()) return; @@ -172,16 +197,12 @@ public static void Track(string eventName, Dictionary? propertie // Identity // ----------------------------------------------------------------- - // Attach a known user id to subsequent events. + // Attaches a known user id to subsequent events. public static void Identify(string userId, IdentityType identityType, Dictionary? traits = null) => Identify(userId, identityType.ToLowercaseString(), traits); - // Attach a known user id to subsequent events. String overload for - // providers not in IdentityType. - // - // identityType is required: data-deletion processing relies on it to - // match identify events to the correct identity namespace, so an - // event without one cannot be cleaned up. + // String overload for providers outside the IdentityType enum. + // identityType is required — data-deletion matches events by this namespace. public static void Identify(string userId, string identityType, Dictionary? traits = null) { if (!_initialized) return; @@ -209,15 +230,12 @@ public static void Identify(string userId, string identityType, Dictionary Alias(fromId, fromType.ToLowercaseString(), toId, toType.ToLowercaseString()); - // Link two user ids for the same player. String overload for - // providers not in IdentityType. - // - // fromType and toType are required: data-deletion processing uses - // them to match alias events to the correct identity namespaces. + // String overload for providers outside the IdentityType enum. + // from/toType are required — data-deletion matches by these namespaces. public static void Alias(string fromId, string fromType, string toId, string toType) { if (!_initialized) return; @@ -240,31 +258,47 @@ public static void Alias(string fromId, string fromType, string toId, string toT Enqueue(msg); } - // Log out the current player. Clears the user id, generates a fresh - // anonymous id, and discards queued events (in-memory and on-disk) - // so the next player on this device isn't attributed to the previous - // one. - // - // To send queued events before they're discarded, - // invoke await FlushAsync() first: - // - // await ImmutableAudience.FlushAsync(); - // ImmutableAudience.Reset(); + // Logs out the current player. Clears userId, discards queued events, + // mints a fresh anonymousId, and starts a new session. Matches Web SDK + // reset(): no session_end is emitted for the old session (it is enqueued + // and then purged). Call FlushAsync() first to preserve queued events. public static void Reset() { - if (!_initialized) return; + // Phase 1 under _initLock: swap _session and clear _userId. Blocking + // work (session drain, disk purge, identity wipe, new session_start) + // runs outside the lock so callers racing on _initLock don't wait. + AudienceConfig? config; + Session? oldSession; + Session? newSession = null; + EventQueue? queueForPurge; - var config = _config; - if (config == null) return; + lock (_initLock) + { + if (!_initialized) return; + config = _config; + if (config == null) return; + + oldSession = _session; + queueForPurge = _queue; + _userId = null; + + // Swap under the lock so racing SetConsent/OnPause/OnResume see + // either the old, the new, or null — never a torn reference. + _session = _consent.CanTrack() ? new Session(Track) : null; + newSession = _session; + } - _userId = null; - _queue?.PurgeAll(); + // Phase 2 outside _initLock. Order: Dispose enqueues session_end → + // PurgeAll wipes it → Identity.Reset clears the anonymousId file → + // Start emits the new session_start against the fresh id. Matches + // the in-lock sequence this replaces. + oldSession?.Dispose(); + queueForPurge?.PurgeAll(); Identity.Reset(config.PersistentDataPath!); + newSession?.Start(); } - // Ask the backend to erase this player's data. Returns a task the - // caller can await to know when the request is acknowledged, or - // discard for fire-and-forget. + // Asks the backend to erase this player's data. Await for ack, or discard for fire-and-forget. public static Task DeleteData(string? userId = null) { if (!_initialized) return Task.CompletedTask; @@ -280,7 +314,7 @@ public static Task DeleteData(string? userId = null) } else { - // Get, not GetOrCreate — a brand-new install must not register an ID just to delete it. + // Get (not GetOrCreate): a fresh install must not register an id just to delete it. var anonymousId = Identity.Get(config.PersistentDataPath!); if (string.IsNullOrEmpty(anonymousId)) return Task.CompletedTask; @@ -308,7 +342,7 @@ public static Task DeleteData(string? userId = null) } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Shutdown cancelled the request — no error fired; caller is tearing down. + // Shutdown cancelled — caller is tearing down, no error fired. } catch (Exception ex) { @@ -327,7 +361,7 @@ private static void NotifyErrorCallback(Action? onError, Audience } catch { - // Swallow: a buggy OnError must not crash the SDK surface. + // Swallow: a buggy OnError must not crash the SDK. } } @@ -335,30 +369,79 @@ private static void NotifyErrorCallback(Action? onError, Audience // Consent // ----------------------------------------------------------------- - // Change the player's consent level. + // Changes the player's consent level. public static void SetConsent(ConsentLevel level) { if (!_initialized) return; var config = _config; - var queue = _queue; if (config == null) return; - var previous = _consent; - if (level == previous) return; - - // Snapshot the anonymousId BEFORE Identity.Reset (on downgrade to - // None) wipes the file. The PUT audit trail needs it to record - // whose consent changed. - var anonymousIdForPut = previous == ConsentLevel.None + // Snapshot check before any I/O: no-op if already at target consent. + var snapshotPrevious = _consent; + if (level == snapshotPrevious) return; + + // Capture anonymousId for the PUT audit trail outside _initLock. + // Identity methods hold their own _sync lock; disk I/O on a cold + // cache (None → Anonymous/Full upgrade creates the UUID file) does + // not block _initLock. A racing SetConsent may change _consent + // between this read and our lock acquire — acceptable, the racing + // call fires its own PUT and our slightly-stale ID still + // identifies the user. + var anonymousIdForPut = snapshotPrevious == ConsentLevel.None ? Identity.GetOrCreate(config.PersistentDataPath!, level) : Identity.Get(config.PersistentDataPath!); - _consent = level; + // Phase 1 under _initLock: flip _consent and swap _session / _userId. + // Phase 2 outside the lock runs the blocking side effects (persist, + // dispose, purge, downgrade, backend sync, new session_start) so a + // concurrent Shutdown / Init / Reset isn't held waiting on them. + ConsentLevel previous; + EventQueue? queue; + Session? oldSession = null; + Session? newSession = null; + bool downgradeFullToAnonymous = false; + + lock (_initLock) + { + if (!_initialized) return; + + config = _config; + queue = _queue; + if (config == null) return; + + previous = _consent; + if (level == previous) return; + + _consent = level; + + if (level == ConsentLevel.None) + { + // Swap the session reference under the lock; dispose outside. + // session_end is gated out by CanTrack (post-flip), matching + // revocation semantics. + oldSession = _session; + _session = null; + } + else if (previous == ConsentLevel.Full && level == ConsentLevel.Anonymous) + { + _userId = null; + downgradeFullToAnonymous = true; + } + else if (previous == ConsentLevel.None && _session == null) + { + // Upgrade from None: allocate + publish the new Session under + // the lock so a concurrent SetConsent / Init sees the new + // reference and the double-allocation guard above fires. + newSession = new Session(Track); + _session = newSession; + } + } + // Phase 2 outside _initLock. try { - // PersistentDataPath is validated non-null in Init; compiler can't propagate that. + // PersistentDataPath validated non-null in Init; compiler can't propagate that. ConsentStore.Save(config.PersistentDataPath!, level); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) @@ -371,20 +454,24 @@ public static void SetConsent(ConsentLevel level) if (level == ConsentLevel.None) { + oldSession?.Dispose(); queue?.PurgeAll(); Identity.Reset(config.PersistentDataPath!); } - else if (previous == ConsentLevel.Full && level == ConsentLevel.Anonymous) + else if (downgradeFullToAnonymous) { - _userId = null; + // Synchronous: EventQueue.ApplyAnonymousDowngrade holds _drainLock + // while it rewrites on-disk files, blocking the in-queue drain + // and shutting the race with HttpTransport. See the method's comment. queue?.ApplyAnonymousDowngrade(); } + newSession?.Start(); + SyncConsentToBackend(config, level, anonymousIdForPut); } - // Fire-and-forget PUT /v1/audience/tracking-consent. Failures do not - // block or surface; the local consent change has already applied. + // Fire-and-forget PUT /v1/audience/tracking-consent. private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel level, string? anonymousId) { var client = _controlClient; @@ -399,7 +486,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev { ["status"] = level.ToLowercaseString(), ["source"] = Constants.ConsentSource, - // Json.Serialize emits null → "anonymousId": null. Preserves the backend's ability to distinguish "unknown" from a missing field. + // Explicit null lets the backend distinguish "unknown" from a missing field. ["anonymousId"] = anonymousId!, }); @@ -420,7 +507,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Shutdown cancelled the request — no error fired. + // Shutdown cancelled. } catch (Exception ex) { @@ -434,7 +521,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev // Flush / Shutdown // ----------------------------------------------------------------- - // Send pending events now. + // Sends all pending events now. public static async Task FlushAsync() { if (!_initialized) return; @@ -451,14 +538,77 @@ await transport.SendBatchAsync().ConfigureAwait(false)) } } - // Flush and stop the SDK. + // Flushes and stops the SDK. public static void Shutdown() { - if (!_initialized) return; + // Fire session_end before taking _initLock. _initialized is still + // true here so Track's CanTrack gate lets it through. Idempotent + // under concurrent Shutdown / SetConsent(None) via the _sessionId + // reset inside EmitEndAndSeal — a second call finds _sessionId + // null and no-ops. Heartbeat timer drain still runs in Phase 2 + // via session.Dispose(); its re-emission inside End() also no-ops. + _session?.EmitEndAndSeal(); + + // Phase 1 under _initLock: flip _initialized and capture references. + // Other callers racing on _initLock re-check _initialized once they + // acquire and early-return, so they don't wait on Phase 2's drain / + // flush / dispose budget (up to ~10s worst case). + Session? session; + Timer? timer; + EventQueue? queue; + HttpTransport? transport; + HttpClient? controlClient; + CancellationTokenSource? cts; + int timeoutMs; + + lock (_initLock) + { + if (!_initialized) return; + + // Race guard: a concurrent Reset or SetConsent(upgrade-from-None) + // may have swapped _session to a new instance that has already + // fired session_start. Seal it too so its session_end lands + // before the flag flip. Idempotent on the same instance (no-op + // via _sessionId null check); the slow path only runs when + // Reset fully completed its Start() between the outside-lock + // call above and this point — a narrow window. + _session?.EmitEndAndSeal(); + + // Flip the gate. Init / SetConsent / Reset acquiring after + // this see _initialized == false and return cleanly. + _initialized = false; + + session = _session; + _session = null; + timer = _sendTimer; + _sendTimer = null; + queue = _queue; + _queue = null; + transport = _transport; + _transport = null; + controlClient = _controlClient; + _controlClient = null; + cts = _shutdownCancellationSource; + _shutdownCancellationSource = null; + + timeoutMs = _config?.ShutdownFlushTimeoutMs ?? 2_000; + + // Drop Identity's in-memory cache so a later Init with a different + // persistentDataPath reads the new file, not the stale cached id. + Identity.ClearCache(); + + _config = null; + _store = null; + _userId = null; + } + + // Phase 2 outside _initLock: end session, drain timers, flush, dispose. + + // End session first so session_end hits the queue before the final flush. + session?.Dispose(); // Drain in-flight timer callbacks before disposing dependents. - // Parameterless Timer.Dispose returns immediately and would race SendBatch. - var timer = _sendTimer; + // Parameterless Timer.Dispose would return immediately and race SendBatch. if (timer != null) { using var disposed = new ManualResetEvent(false); @@ -466,24 +616,20 @@ public static void Shutdown() { disposed.WaitOne(TimeSpan.FromSeconds(2)); } - _sendTimer = null; } - // Clear the in-flight guard in case the WaitOne above timed out - // with a SendBatch callback still running: without this, a later - // Init would leave _sendInFlight stranded at 1 and suppress every - // tick of the new timer. + // Clear the gate in case WaitOne timed out with SendBatch still running + // — a later Init would otherwise be stranded at 1. Interlocked.Exchange(ref _sendInFlight, 0); - _queue?.Shutdown(); + queue?.Shutdown(); // Best-effort final send, capped so a slow network can't hang quit. - if (_transport != null) + if (transport != null) { - var timeoutMs = _config?.ShutdownFlushTimeoutMs ?? 2_000; try { - var send = _transport.SendBatchAsync(); + var send = transport.SendBatchAsync(); if (!send.Wait(timeoutMs)) { Log.Warn($"Shutdown flush exceeded {timeoutMs}ms — abandoning. " + @@ -496,60 +642,49 @@ public static void Shutdown() } } - // Cancel in-flight control-plane HTTP requests (DeleteData / SyncConsentToBackend) - // before disposing the client so awaiting callers observe OperationCanceledException - // rather than ObjectDisposedException. - _shutdownCancellationSource?.Cancel(); - - _transport?.Dispose(); - _queue?.Dispose(); - _controlClient?.Dispose(); - _shutdownCancellationSource?.Dispose(); - _shutdownCancellationSource = null; - - // Drop Identity's in-memory cache so a subsequent Init with a - // different persistentDataPath reads the file from the new path - // instead of returning the previous session's id. - Identity.ClearCache(); - - _initialized = false; - _config = null; - _store = null; - _queue = null; - _transport = null; - _controlClient = null; - _userId = null; + // Cancel in-flight control-plane requests before disposing the client + // so awaiters see OperationCanceledException, not ObjectDisposedException. + cts?.Cancel(); + + transport?.Dispose(); + queue?.Dispose(); + controlClient?.Dispose(); + cts?.Dispose(); } // ----------------------------------------------------------------- // Internal — shared with tests and AudienceUnityHooks // ----------------------------------------------------------------- - // Shuts down (if initialised) and clears per-session state so a - // fresh Init starts clean. Used on test teardown and by Unity - // SubsystemRegistration to survive "disable domain reload". - // LaunchContextProvider is not cleared: AudienceUnityHooks - // re-assigns it on the same SubsystemRegistration call. + // Shuts down (if initialised) and clears per-session state. Used on + // test teardown and Unity SubsystemRegistration to survive "disable + // domain reload". LaunchContextProvider is re-assigned by AudienceUnityHooks. internal static void ResetState() { - if (_initialized) - Shutdown(); - - _consent = ConsentLevel.None; - // Drop Identity's static cache so a subsequent Init with a different - // persistentDataPath (tests, domain reload with changed config) reads - // the file from the new path, not the previous session's cached id. - Identity.ClearCache(); + // Shutdown manages its own serialisation and releases _initLock before + // its Phase 2 teardown, so calling it here does not strand waiters. + Shutdown(); + + lock (_initLock) + { + _consent = ConsentLevel.None; + // Defensive: Shutdown nulls _session too, but a future refactor + // that bails before that null must not leak a stale Session. + _session = null; + Identity.ClearCache(); + } } internal static ConsentLevel CurrentConsentForTesting => _consent; internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync(); - // Invokes the timer callback body directly so the overlapping-tick - // guard can be exercised without a real timer. + // Drives SendBatch without a real timer so the overlapping-tick guard is testable. internal static void SendBatchForTesting() => SendBatch(); + // Drives a single heartbeat so lifecycle tests don't wait the 60s cadence. + internal static void InvokeSessionHeartbeatForTesting() => _session?.OnHeartbeat(); + // ----------------------------------------------------------------- // Private // ----------------------------------------------------------------- @@ -559,7 +694,8 @@ private static bool CanTrack() return _initialized && _consent.CanTrack(); } - // Shallow-copy the caller's dict so a post-call mutation cannot race the drain-thread serialiser. + // Copy the dictionary so the caller editing it later can't corrupt the + // message while the background thread is writing it to disk. private static Dictionary? SnapshotCallerDict(Dictionary? src) => src != null ? new Dictionary(src) : null; @@ -568,16 +704,14 @@ private static void Enqueue(Dictionary? msg) var queue = _queue; if (queue == null) return; - // Re-check consent inside the drain lock so a SetConsent(None) racing - // the caller's CanTrack cannot leak this event past the purge. + // Re-check consent inside _drainLock so a racing SetConsent(None) can't leak past the purge. queue.EnqueueChecked(msg, () => _consent.CanTrack()); } private static void SendBatch() { - // CAS in the guard before doing any work; a previous tick still - // running means skip entirely, including the reschedule — the - // in-flight tick will reschedule on its own finally path. + // If a previous send is still running, skip this one. That send + // will reschedule the next tick when it finishes. if (Interlocked.CompareExchange(ref _sendInFlight, 1, 0) != 0) return; @@ -594,7 +728,7 @@ private static void SendBatch() } catch (Exception ex) { - // ThreadPool timer thread; no caller above to catch. + // Timer-thread callback; no caller above to catch. Log.Warn($"SendBatch unexpected exception: {ex.GetType().Name}: {ex.Message}"); } } @@ -607,7 +741,7 @@ private static void SendBatch() } } - // Realigns the timer to NextAttemptAt so we don't repoll through a long backoff window. + // Realigns the timer to NextAttemptAt so backoff windows aren't repolled. private static void RescheduleSendTimer(HttpTransport transport) { var timer = _sendTimer; @@ -627,18 +761,15 @@ private static void RescheduleSendTimer(HttpTransport transport) timer.Change(nextMs, sendIntervalMs); } - // consentAtInit snapshot is only used to skip the launch event under None; - // Track still consults live _consent via CanTrack, so a SetConsent(None) - // landing between Init returning and here still drops the event. + // consentAtInit only gates the launch; Track still checks live _consent via CanTrack. private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit) { if (!consentAtInit.CanTrack()) return; var properties = new Dictionary(); - // Unity-side auto-detected context (platform, version, buildGuid, - // unityVersion) from AudienceUnityHooks. Core stays pure C#; the - // Unity layer fills these via LaunchContextProvider. + // Unity auto-detected context (platform, version, buildGuid, unityVersion). + // Core stays pure C#; Unity layer fills via LaunchContextProvider. var provider = LaunchContextProvider; if (provider != null) { @@ -657,11 +788,12 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt } } - // Config-supplied distributionPlatform wins over any provider value; - // studios set it explicitly because Unity cannot auto-detect the store. + // Config-supplied distributionPlatform overrides the provider value. if (config.DistributionPlatform != null) properties["distributionPlatform"] = config.DistributionPlatform; + // No sessionId on game_launch per Event Reference. Pipeline correlates + // via eventTimestamp with the session_start that fires just before. Track("game_launch", properties.Count > 0 ? properties : null); } } diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index af7ee940d..d22537ce7 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -22,12 +22,22 @@ internal static void Warn(string message) => private static void Emit(string line) { - if (Writer != null) + // Swallow anything the Writer or Console throws so Log.Warn and + // Log.Debug never throw themselves. If they did, an exception from + // logging inside a catch block would reach the background timer + // and crash the game on modern .NET. + try + { + if (Writer != null) + { + Writer(line); + return; + } + Console.WriteLine(line); + } + catch { - Writer(line); - return; } - Console.WriteLine(line); } } } diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs new file mode 100644 index 000000000..0024d5ac0 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -0,0 +1,994 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class SessionTests + { + private List<(string name, Dictionary props)> _events; + + [SetUp] + public void SetUp() + { + _events = new List<(string, Dictionary)>(); + } + + private void MockTrack(string name, Dictionary props) + { + _events.Add((name, props)); + } + + // ----------------------------------------------------------------- + // Start / End + // ----------------------------------------------------------------- + + [Test] + public void Start_FiresSessionStart_WithSessionId() + { + using var session = new Session(MockTrack); + session.Start(); + + Assert.AreEqual(1, _events.Count); + Assert.AreEqual("session_start", _events[0].name); + Assert.IsTrue(_events[0].props.ContainsKey("sessionId")); + Assert.IsNotEmpty((string)_events[0].props["sessionId"]); + } + + [Test] + public void Start_GeneratesUniqueSessionId() + { + using var session = new Session(MockTrack); + session.Start(); + var id1 = session.SessionId; + + session.End(); + session.Start(); + var id2 = session.SessionId; + + Assert.AreNotEqual(id1, id2); + } + + [Test] + public void End_FiresSessionEnd_WithDuration() + { + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + session.Start(); + now = now.AddSeconds(2); + session.End(); + + var endEvent = _events.FirstOrDefault(e => e.name == "session_end"); + Assert.IsNotNull(endEvent.props); + Assert.IsTrue(endEvent.props.ContainsKey("sessionId")); + Assert.IsTrue(endEvent.props.ContainsKey("durationSec")); + Assert.AreEqual(2L, (long)endEvent.props["durationSec"]); + } + + [Test] + public void Dispose_FiresSessionEnd() + { + var session = new Session(MockTrack); + session.Start(); + session.Dispose(); + + Assert.IsTrue(_events.Any(e => e.name == "session_end")); + } + + [Test] + public void Dispose_CalledTwice_DoesNotFireTwice() + { + var session = new Session(MockTrack); + session.Start(); + session.Dispose(); + var count = _events.Count(e => e.name == "session_end"); + session.Dispose(); + Assert.AreEqual(count, _events.Count(e => e.name == "session_end")); + } + + // ----------------------------------------------------------------- + // Heartbeat + // ----------------------------------------------------------------- + + [Test] + public void Heartbeat_FiresAfterInterval() + { + // Timer-driven heartbeat path. Uses the heartbeatIntervalMs + // constructor override so we can observe the timer without waiting + // the production 60-second cadence, and a ManualResetEvent to + // rendezvous on the thread-pool callback. + using var heartbeatFired = new ManualResetEvent(false); + var events = new List<(string name, Dictionary props)>(); + var gate = new object(); + + void Track(string name, Dictionary props) + { + lock (gate) events.Add((name, props)); + if (name == "session_heartbeat") heartbeatFired.Set(); + } + + using var session = new Session(Track, heartbeatIntervalMs: 50); + session.Start(); + + Assert.IsTrue(heartbeatFired.WaitOne(TimeSpan.FromSeconds(5)), + "timer-driven heartbeat should fire within 5s of a 50ms interval"); + + lock (gate) + { + var beat = events.FirstOrDefault(e => e.name == "session_heartbeat"); + Assert.IsNotNull(beat.props, "heartbeat event should carry a properties dictionary"); + Assert.IsTrue(beat.props.ContainsKey("sessionId")); + Assert.IsTrue(beat.props.ContainsKey("durationSec")); + } + } + + [Test] + public void Heartbeat_WithoutPerformanceSnapshot_OnlyCarriesCoreProperties() + { + using var session = new Session(MockTrack); + session.Start(); + + session.OnHeartbeat(); + + var beat = _events.Last(e => e.name == "session_heartbeat"); + CollectionAssert.AreEquivalent( + new[] { "sessionId", "durationSec" }, + beat.props.Keys); + } + + [Test] + public void Heartbeat_MergesPerformanceSnapshotProperties() + { + Func> snapshot = () => new Dictionary + { + ["fpsAvg"] = 58.4, + ["fpsMin"] = 42.1, + ["memoryUsedMb"] = 512L, + ["memoryReservedMb"] = 768L, + }; + using var session = new Session(MockTrack, snapshot); + session.Start(); + + session.OnHeartbeat(); + + var beat = _events.Last(e => e.name == "session_heartbeat"); + Assert.AreEqual(58.4, beat.props["fpsAvg"]); + Assert.AreEqual(42.1, beat.props["fpsMin"]); + Assert.AreEqual(512L, beat.props["memoryUsedMb"]); + Assert.AreEqual(768L, beat.props["memoryReservedMb"]); + Assert.IsTrue(beat.props.ContainsKey("sessionId")); + Assert.IsTrue(beat.props.ContainsKey("durationSec")); + } + + [Test] + public void Heartbeat_SnapshotCannotOverwriteCoreFields() + { + // Core fields (sessionId, duration) are owned by Session. A + // provider that returns a dictionary containing either key must + // not be able to clobber the wire values — otherwise a buggy + // studio-side snapshot could silently rewrite session identity + // or engagement arithmetic on every heartbeat. Sabotage: removing + // the ContainsKey guard lets "spoofed-id" and 99999L land on the + // wire and both assertions below fail. + Func> snapshot = () => new Dictionary + { + ["sessionId"] = "spoofed-id", + ["durationSec"] = 99999L, + ["fpsAvg"] = 60.0, + }; + using var session = new Session(MockTrack, snapshot); + session.Start(); + + session.OnHeartbeat(); + + var beat = _events.Last(e => e.name == "session_heartbeat"); + Assert.AreNotEqual("spoofed-id", (string)beat.props["sessionId"], + "snapshot must not overwrite Session-owned sessionId"); + Assert.AreNotEqual(99999L, (long)beat.props["durationSec"], + "snapshot must not overwrite Session-owned duration"); + Assert.AreEqual(60.0, beat.props["fpsAvg"], + "non-colliding snapshot fields should still merge"); + } + + [Test] + public void Heartbeat_SnapshotReturningNull_DoesNotThrowAndOmitsFields() + { + Func> snapshot = () => null; + using var session = new Session(MockTrack, snapshot); + session.Start(); + + session.OnHeartbeat(); + + var beat = _events.Last(e => e.name == "session_heartbeat"); + Assert.IsFalse(beat.props.ContainsKey("fpsAvg")); + } + + // ----------------------------------------------------------------- + // Pause / Resume + // ----------------------------------------------------------------- + + [Test] + public void Pause_ThenResume_ShortPause_ContinuesSession() + { + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + session.Start(); + var originalId = session.SessionId; + + session.Pause(); + now = now.AddSeconds(5); // well under the 30 s threshold + session.Resume(); + + Assert.AreEqual(originalId, session.SessionId, "short pause should not change session"); + Assert.IsFalse(_events.Any(e => e.name == "session_end"), + "short pause should not fire session_end"); + } + + [Test] + public void Pause_ThenResume_LongPause_StartsNewSession() + { + // Uses the injected clock to jump past the 30-second threshold + // without sleeping. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + session.Start(); + var id1 = session.SessionId; + + session.Pause(); + now = now.AddSeconds(31); // > 30 second PauseTimeoutMs + session.Resume(); + + Assert.AreNotEqual(id1, session.SessionId, + "pause longer than PauseTimeoutMs should end the old session and start a new one"); + Assert.IsTrue(_events.Any(e => e.name == "session_end"), + "old session should have fired session_end"); + Assert.AreEqual(2, _events.Count(e => e.name == "session_start"), + "a new session_start should fire after the long pause"); + } + + [Test] + public void Pause_CalledTwice_SecondCallIsNoOp() + { + // Double-Pause without an intervening Resume must not advance + // _pausedAt. The live-pause window stays anchored to the first + // Pause so engagement arithmetic covers the full backgrounded + // interval. Sabotage: removing the already-paused guard makes + // _pausedAt jump to the second Pause and this test reports a + // larger duration (over-crediting engagement by the double-pause + // gap). + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(5); + session.Pause(); // first Pause anchors _pausedAt at T=5 + + now = now.AddSeconds(3); + session.Pause(); // second Pause at T=8 — should be ignored + + now = now.AddSeconds(2); // paused for another 2s + session.Resume(); // resume at T=10, pause window spans T=5 to T=10 + + now = now.AddSeconds(3); // 3s more engagement + session.End(); + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + // Wall-clock Start→End = 13s, paused from T=5 to T=10 = 5s, engaged = 8s. + Assert.AreEqual(8L, duration, + "double Pause must preserve the first Pause timestamp so engagement arithmetic covers the full pause window"); + } + + [Test] + public void Resume_WithoutPause_IsNoOp() + { + using var session = new Session(MockTrack); + session.Start(); + var eventsBefore = _events.Count; + + session.Resume(); + + Assert.AreEqual(eventsBefore, _events.Count, "resume without pause should not fire events"); + } + + // ----------------------------------------------------------------- + // Pause-adjusted duration + // ----------------------------------------------------------------- + + [Test] + public void Resume_NegativePauseDuration_ClampsAccumulatorToZero() + { + // Wall-clock can rewind during a pause: NTP correction, manual + // clock change, or a debugger that froze the process. Without + // the clamp at Resume, a negative pauseDuration would shrink + // _accumulatedPause and hand the next session_end an + // artificial engagement credit. Sabotage: removing the clamp + // would let this test report a duration that exceeds the + // wall-clock window, which the assertion below pins. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(10); // 10 s engaged + session.Pause(); + + now = now.AddSeconds(-5); // clock rewinds 5 s during the pause + session.Resume(); + + now = now.AddSeconds(2); // 2 s further engagement after resume + session.End(); + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + // Wall-clock from Start to End is 10 + (-5) + 2 = 7 s. The + // pause duration was clamped to 0, so engaged seconds = 7 - 0 = 7. + // Without the clamp, _accumulatedPause would be -5, the + // subtraction would over-credit, and engaged seconds would + // be 12 — well outside the wall-clock window. + Assert.AreEqual(7L, duration, + "negative pauseDuration must clamp to zero so the accumulator does not over-credit engagement"); + } + + [Test] + public void End_ClockRewindsSinceStart_ClampsDurationToZero() + { + // Wall-clock can rewind between Start and End with no pause + // in between (server-side NTP correction, headless build with + // a manual clock set). Without the clamp in + // ComputeEngagedSecondsLocked, End would ship a negative + // duration. The Resume-side clamp does not cover this path + // because it only fires when _pausedAt was set. Sabotage: + // removing `if (engagedSeconds < 0) return 0;` would let this + // test report -3 instead of 0. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(-3); // clock rewinds after Start, no pause + session.End(); + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + Assert.AreEqual(0L, duration, + "negative engaged time from a wall-clock rewind must clamp to zero"); + } + + [Test] + public void End_ClockRewindsWhilePaused_DoesNotInflateDuration() + { + // Wall-clock rewinds while the session is still paused (e.g. the + // app is backgrounded and NTP corrects backwards before Shutdown + // fires End). Without the livePause ≥ 0 clamp in + // ComputeEngagedSecondsLocked, livePause goes negative and, + // being subtracted, inflates duration past the wall-clock window + // — the final engagedSeconds ≥ 0 clamp only catches negatives, + // not over-credit. Sabotage: removing the livePause clamp lets + // this test report 15s instead of the ≤ 5s wall-clock window. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(10); + session.Pause(); + + now = now.AddSeconds(-5); // clock rewinds 5s while paused + session.End(); + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + Assert.LessOrEqual(duration, 5L, + "clock rewind while paused must not over-credit engagement past the wall-clock window"); + } + + [Test] + public void End_AfterShortPause_ReportsDurationMinusPause() + { + // 10 seconds session, 3 seconds paused inside it → 7 seconds engaged. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(4); + session.Pause(); + + now = now.AddSeconds(3); // 3 s paused + session.Resume(); + + now = now.AddSeconds(3); + session.End(); + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + Assert.AreEqual(7L, duration, + "session_end duration should exclude the 3s paused interval"); + } + + [Test] + public void End_WhilePaused_ExcludesInFlightPauseFromDuration() + { + // Session running 5s, then paused for 2s and ended without resuming. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(5); + session.Pause(); + + now = now.AddSeconds(2); + session.End(); // ends while paused + + var sessionEnd = _events.Last(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + Assert.AreEqual(5L, duration, + "session_end fired while paused should count only pre-pause engaged time"); + } + + [Test] + public void End_AfterExtendedPauseRollover_ReportsPrePauseDuration() + { + // Extended pause (>30 s) on Resume runs End → Start. The + // session_end event for the old session must report only the + // engaged time before the pause, not wall-clock from start to + // resume (which would include the pause). Regression guard + // for the extended-pause rollover path: a naive duration that + // forgot to credit the in-flight pause before End fires would + // ship wall-clock seconds and break engagement dashboards. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(10); // 10 s engaged before pause + session.Pause(); + + now = now.AddSeconds(40); // 40 s paused — extended (>30 s threshold) + session.Resume(); + + var sessionEnd = _events.First(e => e.name == "session_end"); + var duration = (long)sessionEnd.props["durationSec"]; + Assert.AreEqual(10L, duration, + "session_end on extended-pause rollover should report pre-pause engaged time, not wall-clock"); + } + + [Test] + public void Heartbeat_AfterShortPause_ReportsPauseAdjustedDuration() + { + // Engaged 6s, paused 2s, resumed, then heartbeat → 6 s engaged. + var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); + DateTime Clock() => now; + + using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + session.Start(); + + now = now.AddSeconds(4); + session.Pause(); + + now = now.AddSeconds(2); + session.Resume(); + + now = now.AddSeconds(2); + session.OnHeartbeat(); + + var heartbeat = _events.Last(e => e.name == "session_heartbeat"); + var duration = (long)heartbeat.props["durationSec"]; + Assert.AreEqual(6L, duration, + "heartbeat duration should exclude the 2s paused interval"); + } + + // ----------------------------------------------------------------- + // Double-Start safety + // ----------------------------------------------------------------- + + [Test] + public void Start_WithoutPriorEnd_DoesNotStrandTheOldTimer() + { + // Call Start twice in a row. The implementation should stop the + // first timer cleanly; the second heartbeat scheduling should + // succeed and the second session id takes over. + using var session = new Session(MockTrack); + session.Start(); + var firstId = session.SessionId; + + session.Start(); + var secondId = session.SessionId; + + Assert.AreNotEqual(firstId, secondId, + "second Start should generate a fresh sessionId"); + + Assert.AreEqual(2, _events.Count(e => e.name == "session_start"), + "both session_start events should fire"); + } + + // ----------------------------------------------------------------- + // Heartbeat-during-pause quiescence + // ----------------------------------------------------------------- + + [Test] + public void Heartbeat_WhilePaused_DoesNotFire() + { + // The class contract is session_heartbeat every 60s while + // foregrounded. A paused session is backgrounded by definition, + // so OnHeartbeat must not emit until Resume clears the pause. + // Without this guard a backgrounded alt-tab would dribble + // stable-duration heartbeats for the entire pause window. + using var session = new Session(MockTrack); + session.Start(); + session.Pause(); + + session.OnHeartbeat(); + + Assert.IsFalse(_events.Any(e => e.name == "session_heartbeat"), + "OnHeartbeat should not emit while the session is paused"); + } + + [Test] + public void End_HeartbeatExceedsDrainBudget_LogsWarningAndContinues() + { + // DrainHeartbeatTimer waits up to 1 second for an in-flight + // heartbeat callback before End emits session_end. If the + // callback exceeds the budget the implementation logs a + // warning and continues, accepting the risk of a trailing + // heartbeat racing the next lifecycle event. Sabotage paths + // that would skip the warning: raising the budget past the + // 1.5 s callback sleep (WaitOne returns true before timeout); + // removing the `if (!waited.WaitOne(...))` guard. WaitOne + // (TimeSpan.Zero) on an unsignaled handle returns false, so + // lowering the budget does not skip the warning. This test + // pins the budget-exceeded path so future drain-budget edits + // cannot silently drop the warning. + var warnings = new List(); + var prevWriter = Log.Writer; + Log.Writer = line => { lock (warnings) warnings.Add(line); }; + try + { + using var beatStarted = new ManualResetEvent(false); + void Track(string name, Dictionary props) + { + if (name == "session_heartbeat") + { + beatStarted.Set(); + // Block past the 1 s drain budget so DrainHeartbeatTimer + // times out. Self-releases after 1.5 s so the callback + // does eventually finish. + Thread.Sleep(1500); + } + } + + using var session = new Session(Track, heartbeatIntervalMs: 50); + session.Start(); + Assert.IsTrue(beatStarted.WaitOne(TimeSpan.FromSeconds(2)), + "heartbeat callback must enter before End is invoked"); + + session.End(); + + lock (warnings) + { + Assert.IsTrue(warnings.Any(w => w.Contains("heartbeat callback did not complete")), + "End must log the drain-timeout warning when an in-flight heartbeat exceeds 1 s"); + } + } + finally + { + Log.Writer = prevWriter; + } + } + + [Test] + public void Heartbeat_AfterResume_Fires() + { + // Pair for Heartbeat_WhilePaused_DoesNotFire: once Resume + // clears _pausedAt, heartbeats must flow again. + using var session = new Session(MockTrack); + session.Start(); + session.Pause(); + session.Resume(); + + session.OnHeartbeat(); + + Assert.IsTrue(_events.Any(e => e.name == "session_heartbeat"), + "OnHeartbeat should emit again once the session is resumed"); + } + + // ----------------------------------------------------------------- + // Exception containment + // ----------------------------------------------------------------- + + [Test] + public void OnHeartbeat_TrackCallbackThrows_DoesNotEscape() + { + // OnHeartbeat runs on a thread-pool timer callback; an unhandled + // exception there can terminate the process on .NET 5+. The + // SafeTrack wrapper catches and logs instead. Sabotage: removing + // the try/catch in SafeTrack would let the exception propagate + // up to the caller (which here is the test thread, so this + // assertion would fail). + var warnings = new List(); + var prevWriter = Log.Writer; + Log.Writer = line => { lock (warnings) warnings.Add(line); }; + try + { + void ThrowingTrack(string name, Dictionary props) + { + if (name == "session_heartbeat") + throw new InvalidOperationException("track explode"); + } + + using var session = new Session(ThrowingTrack); + session.Start(); + + Assert.DoesNotThrow(() => session.OnHeartbeat(), + "a throwing track callback on the heartbeat path must not escape Session"); + + lock (warnings) + { + Assert.IsTrue(warnings.Any(w => w.Contains("session_heartbeat track callback threw")), + "SafeTrack must log a warning when the callback throws"); + } + } + finally { Log.Writer = prevWriter; } + } + + [Test] + public void OnHeartbeat_PerformanceSnapshotThrows_ShipsHeartbeatWithoutPerfFields() + { + // PerformanceSnapshotProvider is studio-supplied and crosses an + // API boundary. A throwing provider must not prevent the + // heartbeat from shipping — the SafePerformanceSnapshot wrapper + // returns null on exception so the heartbeat ships with the + // core fields only. + var warnings = new List(); + var prevWriter = Log.Writer; + Log.Writer = line => { lock (warnings) warnings.Add(line); }; + try + { + Func> snapshot = () => + throw new InvalidOperationException("perf explode"); + + using var session = new Session(MockTrack, snapshot); + session.Start(); + + Assert.DoesNotThrow(() => session.OnHeartbeat(), + "a throwing performance snapshot must not escape Session"); + + var beat = _events.Last(e => e.name == "session_heartbeat"); + CollectionAssert.AreEquivalent( + new[] { "sessionId", "durationSec" }, + beat.props.Keys, + "heartbeat should carry only the core fields when the snapshot throws"); + + lock (warnings) + { + Assert.IsTrue(warnings.Any(w => w.Contains("performance snapshot threw")), + "SafePerformanceSnapshot must log a warning when the provider throws"); + } + } + finally { Log.Writer = prevWriter; } + } + + [Test] + public void Start_TrackCallbackThrows_DoesNotEscape() + { + // Start fires session_start via _track. A throwing implementation + // would otherwise propagate into Init / SetConsent on the caller's + // thread. SafeTrack swallows and logs. Sabotage: removing + // SafeTrack's try/catch fails Assert.DoesNotThrow; a regression + // that swallows silently without logging fails the warning check. + var warnings = new List(); + var prevWriter = Log.Writer; + Log.Writer = line => { lock (warnings) warnings.Add(line); }; + try + { + void ThrowingTrack(string name, Dictionary props) + { + if (name == "session_start") + throw new InvalidOperationException("track explode"); + } + + using var session = new Session(ThrowingTrack); + + Assert.DoesNotThrow(() => session.Start(), + "a throwing track callback on session_start must not escape Start"); + + lock (warnings) + { + Assert.IsTrue(warnings.Any(w => w.Contains("session_start track callback threw")), + "SafeTrack must log a warning when the Start callback throws"); + } + } + finally { Log.Writer = prevWriter; } + } + + [Test] + public void End_TrackCallbackThrows_DoesNotEscape() + { + // End fires session_end via _track. Dispose wraps End, so a + // throwing implementation would otherwise propagate into + // Shutdown / SetConsent on the caller's thread. Sabotage: + // removing SafeTrack's try/catch fails Assert.DoesNotThrow; a + // regression that swallows silently without logging fails the + // warning check. + var warnings = new List(); + var prevWriter = Log.Writer; + Log.Writer = line => { lock (warnings) warnings.Add(line); }; + try + { + void ThrowingTrack(string name, Dictionary props) + { + if (name == "session_end") + throw new InvalidOperationException("track explode"); + } + + using var session = new Session(ThrowingTrack); + session.Start(); + + Assert.DoesNotThrow(() => session.End(), + "a throwing track callback on session_end must not escape End"); + + lock (warnings) + { + Assert.IsTrue(warnings.Any(w => w.Contains("session_end track callback threw")), + "SafeTrack must log a warning when the End callback throws"); + } + } + finally { Log.Writer = prevWriter; } + } + + [Test] + public void SafeTrack_LogWriterThrows_DoesNotEscape() + { + // SafeTrack logs to Log.Warn when the _track delegate throws. If + // the Log.Writer itself throws, the log call would escape + // SafeTrack's catch and propagate up to the Timer thread (process + // kill on .NET 5+) or the caller thread (Init / Shutdown / + // SetConsent). Log.Emit's internal try/catch must swallow the + // Writer failure. Sabotage: removing Log.Emit's try/catch would + // fail Assert.DoesNotThrow below. + var prevWriter = Log.Writer; + Log.Writer = _ => throw new InvalidOperationException("log explode"); + try + { + void ThrowingTrack(string name, Dictionary props) + { + if (name == "session_heartbeat") + throw new InvalidOperationException("track explode"); + } + + using var session = new Session(ThrowingTrack); + session.Start(); + + Assert.DoesNotThrow(() => session.OnHeartbeat(), + "Log.Writer throwing inside SafeTrack's catch must not escape Session"); + } + finally { Log.Writer = prevWriter; } + } + } + + // ----------------------------------------------------------------- + // Session integration through ImmutableAudience + // ----------------------------------------------------------------- + + [TestFixture] + internal class SessionIntegrationTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + ImmutableAudience.ResetState(); + Identity.Reset(_testDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + private AudienceConfig MakeConfig(ConsentLevel consent = ConsentLevel.Anonymous) + { + return new AudienceConfig + { + PublishableKey = "pk_imapik-test-key1", + Consent = consent, + PersistentDataPath = _testDir, + FlushIntervalSeconds = 600, + FlushSize = 1000, + HttpHandler = new KeepOnDiskHandler() + }; + } + + // Reads every queued event file. Returns an empty array when the + // queue directory has not been created yet (SetConsent(None) purges + // it, Init with ConsentLevel.None never writes one) so tests can + // assert "no such event" without a DirectoryNotFoundException crash + // masking the real signal. + private string[] ReadQueueFiles() + { + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + if (!Directory.Exists(queueDir)) return Array.Empty(); + return Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToArray(); + } + + [Test] + public void Init_FiresSessionStart() + { + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Shutdown(); + + Assert.IsTrue(ReadQueueFiles().Any(c => c.Contains("\"session_start\""))); + } + + [Test] + public void Shutdown_FiresSessionEnd() + { + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Shutdown(); + + Assert.IsTrue(ReadQueueFiles().Any(c => c.Contains("\"session_end\""))); + } + + [Test] + public void Init_ConsentNone_DoesNotStartSession() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + ImmutableAudience.Shutdown(); + + Assert.IsFalse(ReadQueueFiles().Any(c => c.Contains("\"session_start\""))); + } + + [Test] + public void SetConsent_NoneToAnonymous_StartsSession() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + ImmutableAudience.Shutdown(); + + Assert.IsTrue(ReadQueueFiles().Any(c => c.Contains("\"session_start\"")), + "upgrading from None should start a session"); + } + + [Test] + public void OnPauseAndOnResume_RouteThroughActiveSession() + { + // Pin that OnPause / OnResume actually reach the live Session, + // not just that they compile. A heartbeat fired while the + // session is paused must not emit; after OnResume the next + // heartbeat must emit. Sabotage: emptying either wrapper body + // fails one of the two assertions below. + // + // Signature-pin (rename / parameter change) also still holds + // via the direct ImmutableAudience.OnPause() / OnResume() call + // sites — a change there breaks compilation here. + ImmutableAudience.Init(MakeConfig()); + + ImmutableAudience.OnPause(); + ImmutableAudience.InvokeSessionHeartbeatForTesting(); + ImmutableAudience.FlushQueueToDiskForTesting(); + + Assert.IsFalse( + ReadQueueFiles().Any(c => c.Contains("\"session_heartbeat\"")), + "OnPause must route to Session.Pause — paused sessions quiesce heartbeats"); + + ImmutableAudience.OnResume(); + ImmutableAudience.InvokeSessionHeartbeatForTesting(); + ImmutableAudience.FlushQueueToDiskForTesting(); + + Assert.IsTrue( + ReadQueueFiles().Any(c => c.Contains("\"session_heartbeat\"")), + "OnResume must route to Session.Resume — resumed sessions emit heartbeats again"); + + ImmutableAudience.Shutdown(); + } + + [Test] + public void OnPauseAndOnResume_BeforeInit_AreNoOps() + { + // Both wrappers gate on _initialized and return early. A + // lifecycle bridge that fires before Init (rare, but possible + // on subsystem-registration order quirks) must not throw. + Assert.DoesNotThrow(() => ImmutableAudience.OnPause()); + Assert.DoesNotThrow(() => ImmutableAudience.OnResume()); + } + + [Test] + public void Reset_StartsNewSession_DoesNotEmitSessionEnd() + { + // Reset must end the old session and start a new one so subsequent + // Track events carry a fresh sessionId alongside the fresh + // anonymousId — matches Web SDK reset() semantics. The old + // session's session_end is enqueued by Session.Dispose but wiped + // by the PurgeAll in Reset, so the wire sees only a session_start + // for the new session. + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + + // Drain game_launch + session_start for the initial session so we + // only see post-Reset events. + ImmutableAudience.FlushQueueToDiskForTesting(); + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var firstAnonymousId = Identity.Get(_testDir); + Assert.IsNotNull(firstAnonymousId, "first session should have minted an anonymousId"); + + ImmutableAudience.Reset(); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var files = ReadQueueFiles(); + Assert.IsTrue(files.Any(c => c.Contains("\"session_start\"")), + "Reset must fire session_start for the new session"); + Assert.IsFalse(files.Any(c => c.Contains("\"session_end\"")), + "Reset must not leak session_end for the old session (Web SDK parity)"); + + var secondAnonymousId = Identity.Get(_testDir); + Assert.IsNotNull(secondAnonymousId, "Reset should have minted a fresh anonymousId"); + Assert.AreNotEqual(firstAnonymousId, secondAnonymousId, + "Reset must mint a new anonymousId"); + + ImmutableAudience.Shutdown(); + } + + [Test] + public void Reset_ConsentNone_DoesNotStartSession() + { + // At consent=None there is no session running; Reset must not + // spin one up (Web SDK reset() guards on !isTrackingDisabled()). + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + + ImmutableAudience.Reset(); + ImmutableAudience.Shutdown(); + + Assert.IsFalse(ReadQueueFiles().Any(c => c.Contains("\"session_start\"")), + "Reset at consent=None must not fire session_start"); + } + + [Test] + public void SetConsent_AnonymousToNone_DoesNotEmitSessionEnd() + { + // Consent revocation purges the queue and disposes the session. + // Session.Dispose fires End → Track("session_end"), but by the + // time End runs the consent level has already been flipped to + // None, so CanTrack gates the track call. No session_end event + // should appear on disk. Regression guard: a future reorder of + // "flip consent" vs "dispose session" would silently leak a + // session_end that the consent-revocation promise forbids. + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + ImmutableAudience.SetConsent(ConsentLevel.None); + ImmutableAudience.Shutdown(); + + Assert.IsFalse( + ReadQueueFiles().Any(c => c.Contains("\"session_end\"")), + "SetConsent(None) must not leak a session_end event past the queue purge"); + } + + private class KeepOnDiskHandler : System.Net.Http.HttpMessageHandler + { + protected override System.Threading.Tasks.Task SendAsync( + System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken ct) + { + return System.Threading.Tasks.Task.FromResult( + new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.ServiceUnavailable)); + } + } + } +} \ No newline at end of file diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs.meta new file mode 100644 index 000000000..0bf7e4c8f --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a6df3c78a22047d28b52002bac442b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index c5283f0d4..8cd9f491d 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -136,16 +136,42 @@ public void Track_NullOrEmptyEventName_DoesNotEnqueue() { ImmutableAudience.Init(MakeConfig()); - var beforeQueue = AudiencePaths.QueueDir(_testDir); - var beforeCount = Directory.Exists(beforeQueue) ? Directory.GetFiles(beforeQueue, "*.json").Length : 0; - Assert.DoesNotThrow(() => ImmutableAudience.Track((string)null)); Assert.DoesNotThrow(() => ImmutableAudience.Track("")); ImmutableAudience.Shutdown(); - var afterCount = Directory.GetFiles(beforeQueue, "*.json").Length; - // Only game_launch should have been enqueued; null/empty Track calls dropped. - Assert.AreEqual(beforeCount + 1, afterCount, "null/empty event names must be dropped, not enqueued"); + + // Assert the invariant directly: no enqueued message carries a + // null or empty eventName. Earlier versions counted files + // before/after the Track calls, which raced with the async + // disk drain — Init enqueues session_start + game_launch, and + // Shutdown adds session_end, so the file count after Shutdown + // is deterministic but the before-count is not. Counting is + // the wrong axis: what the test actually wants to pin is + // "no empty-name event ever hit the queue", regardless of + // what else was enqueued alongside it. + // + // Deserialize each message and inspect the eventName field + // directly. A raw substring scan would false-positive on an + // event whose property value happened to be the literal + // string "eventName":"" (unlikely but possible) and would + // silently break on any future JSON encoding change (whitespace, + // key ordering, escape style). + var queueDir = AudiencePaths.QueueDir(_testDir); + if (!Directory.Exists(queueDir)) return; + foreach (var file in Directory.GetFiles(queueDir, "*.json")) + { + var msg = JsonReader.DeserializeObject(File.ReadAllText(file)); + if ((string)msg["type"] != "track") continue; + + if (!msg.TryGetValue("eventName", out var eventNameObj)) + Assert.Fail($"track message {Path.GetFileName(file)} missing eventName field"); + + Assert.IsNotNull(eventNameObj, + $"queue file {Path.GetFileName(file)} has a null eventName"); + Assert.IsNotEmpty((string)eventNameObj, + $"queue file {Path.GetFileName(file)} has an empty eventName"); + } } [Test] @@ -601,6 +627,127 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() } } + [Test] + public void Init_ConcurrentWithSetConsent_LeavesConsistentState() + { + // Pre-fix (before 1784ae3f), SetConsent mutated _consent and + // _session outside any lock. A SetConsent landing between Init's + // _initialized = true and its _session = new Session(...) + // observed _session = null, skipped the dispose path, and let + // Init finish creating a Session whose timer was never disposed. + // + // Limitation: the race window is narrow and not deterministically + // reproducible without a test hook inside Init. This is a + // probabilistic guard — many iterations of concurrent Init / + // SetConsent(None) from two threads, asserting only that the + // final state is consistent (consent is whichever the last lock + // holder set, no exceptions escape, Init did not silently ignore + // the race). Regressions that fully remove SetConsent's lock + // would still show up here via ConsentLevel mismatches or + // exceptions on a majority of iterations. + const int iterations = 50; + for (int iter = 0; iter < iterations; iter++) + { + Exception initEx = null; + Exception setConsentEx = null; + + var setConsentTask = Task.Run(() => + { + try + { + Thread.Yield(); + ImmutableAudience.SetConsent(ConsentLevel.None); + } + catch (Exception ex) { setConsentEx = ex; } + }); + + var initTask = Task.Run(() => + { + try { ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); } + catch (Exception ex) { initEx = ex; } + }); + + Assert.IsTrue(Task.WaitAll(new[] { initTask, setConsentTask }, TimeSpan.FromSeconds(5)), + $"iteration {iter}: Init / SetConsent must complete within 5s"); + Assert.IsNull(initEx, $"iteration {iter}: Init threw {initEx}"); + Assert.IsNull(setConsentEx, $"iteration {iter}: SetConsent threw {setConsentEx}"); + + // Either order is valid: + // - SetConsent runs first: _initialized is false, SetConsent + // early-returns, Init then initialises with Anonymous. + // - Init runs first: Init sets Anonymous, SetConsent flips + // to None under the lock, consent ends at None. + var finalConsent = ImmutableAudience.CurrentConsentForTesting; + Assert.That(finalConsent, + Is.EqualTo(ConsentLevel.None).Or.EqualTo(ConsentLevel.Anonymous), + $"iteration {iter}: unexpected final consent {finalConsent}"); + + ImmutableAudience.ResetState(); + if (Directory.Exists(AudiencePaths.AudienceDir(_testDir))) + Directory.Delete(AudiencePaths.AudienceDir(_testDir), recursive: true); + } + } + + [Test] + public void SetConsent_ConcurrentUpgradeFromNone_StartsOneSession_StressTest() + { + // Starting from ConsentLevel.None, N threads race to + // SetConsent(Anonymous). Without the _initLock in SetConsent, + // multiple threads observe previous == None, each take the + // upgrade branch, each build a fresh Session, each Start() it. + // The last _session = new Session(...) wins; the earlier + // instances keep their heartbeat timers running on the + // thread pool forever (heartbeats land as dropped-by-CanTrack + // no-ops but the Timer allocations leak unbounded). + // + // Wire-visible symptom: multiple session_start events hit the + // queue per iteration. With the lock, exactly one thread + // flips _consent, the rest observe previous == Anonymous and + // return without touching _session. + // + // Sabotage: removing the lock (or widening the else-if to skip + // the previous-consent check) fails this test reliably within + // a handful of iterations. + const int iterations = 100; + const int callersPerIteration = 4; + + for (int iter = 0; iter < iterations; iter++) + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + + var barrier = new Barrier(callersPerIteration); + var callers = new Task[callersPerIteration]; + for (int c = 0; c < callersPerIteration; c++) + { + callers[c] = Task.Run(() => + { + barrier.SignalAndWait(); + ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + }); + } + + Task.WaitAll(callers, TimeSpan.FromSeconds(5)); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var sessionStarts = Directory.Exists(queueDir) + ? Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText) + .Count(c => c.Contains("\"session_start\"")) + : 0; + + if (sessionStarts != 1) + { + Assert.Fail( + $"iteration {iter}: expected exactly one session_start from concurrent SetConsent upgrade, got {sessionStarts}"); + } + + ImmutableAudience.ResetState(); + if (Directory.Exists(AudiencePaths.AudienceDir(_testDir))) + Directory.Delete(AudiencePaths.AudienceDir(_testDir), recursive: true); + } + } + [Test] public void ResetState_ClearsIdentityCache_AcrossInitWithDifferentPath() { @@ -804,6 +951,44 @@ public void Track_AfterShutdown_IsIgnored() Assert.DoesNotThrow(() => ImmutableAudience.Track("should_not_crash")); } + [Test] + public void Shutdown_ReleasesInitLock_BeforeBlockingTeardown() + { + // Hanging handler: the final flush inside Shutdown's Phase 2 will + // block in transport.SendBatchAsync().Wait(timeoutMs). Pre-refactor, + // _initLock was held across that wait — SetConsent / Reset on another + // thread would be stranded for the full ShutdownFlushTimeoutMs. + var handler = new BlockingHandler(); + var config = MakeConfig(); + config.HttpHandler = handler; + config.ShutdownFlushTimeoutMs = 10_000; + + ImmutableAudience.Init(config); + ImmutableAudience.Track("ensure_nonempty_queue"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + // Phase 1 flips _initialized and releases the lock; Phase 2 enters + // the hanging final flush. + var shutdown = Task.Run(() => ImmutableAudience.Shutdown()); + + Assert.IsTrue(handler.EnteredSendAsync.Wait(TimeSpan.FromSeconds(5)), + "Shutdown should reach the hanging final flush inside Phase 2"); + + // Reset unconditionally acquires _initLock. If Phase 2 held the lock + // this would block for ~10s; post-refactor the lock is free, Reset + // sees !_initialized and returns in microseconds. + var sw = System.Diagnostics.Stopwatch.StartNew(); + ImmutableAudience.Reset(); + sw.Stop(); + + Assert.Less(sw.ElapsedMilliseconds, 500, + $"Reset must not block on Shutdown's Phase 2; took {sw.ElapsedMilliseconds}ms"); + + handler.Release.Set(); + Assert.IsTrue(shutdown.Wait(TimeSpan.FromSeconds(15)), + "Shutdown should finish after handler release"); + } + // ----------------------------------------------------------------- // Full -> Anonymous consent downgrade // -----------------------------------------------------------------