@@ -12,13 +12,15 @@ namespace Immutable.Audience
1212 internal delegate void TrackDelegate ( string eventName , Dictionary < string , object > properties ) ;
1313
1414 // Unity session lifecycle. Emits session_start / session_heartbeat / session_end.
15- // duration is engagement time (excludes pause). Heartbeat fires off-thread;
16- // public methods run on the caller's thread. _track fires outside _lock.
15+ // duration is engagement time (excludes pause). The heartbeat runs on a
16+ // background thread; other methods run on the thread that called them. The
17+ // track callback is invoked with the internal lock released.
1718 //
18- // Serialisation: Start / End / Dispose are NOT reentrant-safe from multiple
19- // threads. Callers serialise them (ImmutableAudience holds _initLock across
20- // Init / SetConsent / Shutdown / Reset, which are the only public entry points
21- // that touch a Session). Pause / Resume / OnHeartbeat are thread-safe.
19+ // Start / End / Dispose are not safe to call from multiple threads at once.
20+ // Callers run them one at a time (ImmutableAudience holds its init lock while
21+ // calling Init / SetConsent / Shutdown / Reset — the only public entry points
22+ // that touch a Session). Pause / Resume / OnHeartbeat are safe to call from
23+ // any thread.
2224 internal sealed class Session : IDisposable
2325 {
2426 internal const int HeartbeatIntervalMs = 60_000 ;
@@ -63,9 +65,10 @@ internal Session(
6365 // Starts a session. Fires session_start and arms the heartbeat timer.
6466 internal void Start ( )
6567 {
66- // Phase 1: drain old timer outside _lock (callback re-enters _lock).
67- // Old state left intact so a trailing callback emits for the old session
68- // — wire-ordered before the new session_start.
68+ // Phase 1: shut down the old timer with the internal lock released
69+ // (the callback takes that lock itself). Old state left intact so a
70+ // trailing callback sends a heartbeat for the old session — the
71+ // backend receives it before the new session_start.
6972 Timer ? oldTimer ;
7073 lock ( _lock )
7174 {
@@ -216,7 +219,8 @@ internal void OnHeartbeat()
216219 lock ( _lock )
217220 {
218221 if ( _disposed || _sessionId == null ) return ;
219- // Paused sessions don't ship heartbeats (timer still fires; this gates _track).
222+ // A paused session doesn't send heartbeats. The timer keeps
223+ // firing internally; this check stops the event from going out.
220224 if ( _pausedAt . HasValue ) return ;
221225 sessionId = _sessionId ! ;
222226
@@ -244,8 +248,10 @@ internal void OnHeartbeat()
244248 SafeTrack ( "session_heartbeat" , properties ) ;
245249 }
246250
247- // Guards _track from escaping. Heartbeat is a Timer callback (process
248- // kill on .NET 5+); Start/End are caller-thread (would bubble to Init/Shutdown).
251+ // Stops exceptions from the track callback from reaching upstream.
252+ // Heartbeat runs on a background timer — an uncaught exception there
253+ // crashes the game on modern .NET. Start / End run on the caller's
254+ // thread, where it would bubble into Init / Shutdown.
249255 private void SafeTrack ( string eventName , Dictionary < string , object > properties )
250256 {
251257 try
@@ -258,7 +264,8 @@ private void SafeTrack(string eventName, Dictionary<string, object> properties)
258264 }
259265 }
260266
261- // Guards the studio-supplied snapshot from escaping to the timer thread.
267+ // Stops exceptions from the studio-supplied snapshot callback from
268+ // reaching the background timer.
262269 private Dictionary < string , object > ? SafePerformanceSnapshot ( )
263270 {
264271 if ( _performanceSnapshot == null ) return null ;
@@ -288,7 +295,8 @@ private void DrainHeartbeatTimer()
288295 using var waited = new ManualResetEvent ( false ) ;
289296 try
290297 {
291- // Dispose(wh)=false: already disposed, wh never signals — skip the wait.
298+ // Timer was already disposed. The signal handle won't fire, so
299+ // don't wait for it.
292300 if ( ! timer . Dispose ( waited ) )
293301 return ;
294302
0 commit comments