1414///
1515/// Layout under <c>Application.persistentDataPath/CrashReports</c>:
1616/// session.active : present while a session is running; deleted on a clean quit.
17- /// pending.jsonl : one base64-delimited record per captured report this session.
18- /// If <c>session.active</c> still exists at startup, the previous run did not exit cleanly,
19- /// so <c>pending.jsonl</c> from that run is loaded for replay before being rotated out.
17+ /// pending.jsonl : reports captured during the CURRENT session.
18+ /// replay.jsonl : an outbox of reports carried over from a crashed session, awaiting
19+ /// send. It is deleted only once the reports have actually been handed off
20+ /// for sending, so a crash before that point simply retries next launch,
21+ /// while a successful send removes the outbox so the same reports are
22+ /// never sent again on a later reboot.
2023///
21- /// Records are stored one- per- line as <c>severity \t base64(system) \t base64(message) \t
22- /// base64(stack)</c> — base64 keeps newlines and arbitrary characters from breaking the
23- /// line format without pulling in a JSON dependency.
24+ /// Records are stored one per line as <c>severity \t base64(system) \t base64(message) \t
25+ /// base64(stack)</c> — base64 keeps newlines/ arbitrary characters from breaking the line
26+ /// format without pulling in a JSON dependency.
2427/// </summary>
2528public static class BasisCrashReportStore
2629{
2730 private const int MaxReplayEntries = 50 ;
28- private const long MaxPendingBytes = 2L * 1024 * 1024 ;
31+ private const long MaxFileBytes = 2L * 1024 * 1024 ;
2932 private const int MaxMessageChars = 2000 ;
3033 private const int MaxStackChars = 12000 ;
3134
@@ -34,6 +37,7 @@ public static class BasisCrashReportStore
3437
3538 private static string _markerPath ;
3639 private static string _pendingPath ;
40+ private static string _replayPath ;
3741 private static bool _initialized ;
3842 private static bool _replayed ;
3943
@@ -54,17 +58,34 @@ private static void Initialize()
5458 string dir = Path . Combine ( Application . persistentDataPath , "CrashReports" ) ;
5559 _markerPath = Path . Combine ( dir , "session.active" ) ;
5660 _pendingPath = Path . Combine ( dir , "pending.jsonl" ) ;
61+ _replayPath = Path . Combine ( dir , "replay.jsonl" ) ;
5762 Directory . CreateDirectory ( dir ) ;
5863
59- // Marker survived from last time → the previous session crashed. Grab its reports.
60- if ( File . Exists ( _markerPath ) && File . Exists ( _pendingPath ) )
64+ lock ( FileLock )
6165 {
62- LoadPrevious ( ) ;
63- }
66+ bool previousCrashed = File . Exists ( _markerPath ) ;
67+
68+ // A crashed session's freshly-captured reports move into the outbox so they
69+ // survive even if THIS session also crashes before it can send them.
70+ if ( previousCrashed && File . Exists ( _pendingPath ) )
71+ {
72+ FoldPendingIntoReplay ( ) ;
73+ }
6474
65- // Rotate: start a fresh pending log and (re)arm the session marker.
66- try { if ( File . Exists ( _pendingPath ) ) File . Delete ( _pendingPath ) ; } catch { }
67- try { File . WriteAllText ( _markerPath , DateTime . UtcNow . ToString ( "o" ) ) ; } catch { }
75+ // The current session always starts with an empty pending log.
76+ TryDelete ( _pendingPath ) ;
77+
78+ // Load whatever is still waiting to be sent — this boot's fold, plus anything a
79+ // previous boot loaded but never managed to send. Independent of the marker, so
80+ // an outbox that survived a clean (but offline) shutdown still gets retried.
81+ if ( File . Exists ( _replayPath ) )
82+ {
83+ LoadReplay ( ) ;
84+ }
85+
86+ // (Re)arm the marker for this session.
87+ try { File . WriteAllText ( _markerPath , DateTime . UtcNow . ToString ( "o" ) ) ; } catch { }
88+ }
6889
6990 Application . quitting += OnQuit ;
7091 BasisNetworkModeration . OnCrashReportingStateChanged += OnCrashReportingStateChanged ;
@@ -77,11 +98,13 @@ private static void Initialize()
7798
7899 private static void OnQuit ( )
79100 {
80- // Clean shutdown → nothing to report next time.
101+ // Clean shutdown → drop this session's pending captures (they were sent live, or
102+ // intentionally not sent). The outbox (replay.jsonl) is deliberately left alone: if it
103+ // still holds unsent reports they should go out on the next launch.
81104 lock ( FileLock )
82105 {
83- try { if ( _pendingPath != null && File . Exists ( _pendingPath ) ) File . Delete ( _pendingPath ) ; } catch { }
84- try { if ( _markerPath != null && File . Exists ( _markerPath ) ) File . Delete ( _markerPath ) ; } catch { }
106+ TryDelete ( _pendingPath ) ;
107+ TryDelete ( _markerPath ) ;
85108 }
86109 }
87110
@@ -90,10 +113,7 @@ private static void OnCrashReportingStateChanged(bool enabled)
90113 if ( enabled ) TryReplay ( ) ;
91114 }
92115
93- /// <summary>
94- /// Append one captured report to the pending log. Thread-safe — called from Unity's
95- /// threaded log callback. Best-effort; IO failures are swallowed.
96- /// </summary>
116+ /// <summary>Append one captured report to the current session's pending log. Thread-safe.</summary>
97117 public static void Persist ( byte severity , string system , string message , string stackTrace )
98118 {
99119 if ( ! _initialized || string . IsNullOrEmpty ( _pendingPath ) ) return ;
@@ -105,22 +125,18 @@ public static void Persist(byte severity, string system, string message, string
105125 + "\t " + Encode ( Truncate ( stackTrace , MaxStackChars ) ) ;
106126 lock ( FileLock )
107127 {
108- // Don't let an exception storm fill the disk.
109- try
110- {
111- FileInfo info = new FileInfo ( _pendingPath ) ;
112- if ( info . Exists && info . Length > MaxPendingBytes ) return ;
113- }
114- catch { }
128+ if ( OverSizeLimit ( _pendingPath ) ) return ; // don't let an exception storm fill the disk
115129 File . AppendAllText ( _pendingPath , line + "\n " ) ;
116130 }
117131 }
118132 catch { }
119133 }
120134
121135 /// <summary>
122- /// Send any reports captured from a previous crashed session, once connected and the
123- /// server allows reporting. Runs at most once per launch.
136+ /// Send any reports carried over from a previous crashed session, once connected and the
137+ /// server allows reporting. Runs at most once per launch; the on-disk outbox is deleted as
138+ /// soon as the reports are taken for sending, so the same reports are never re-sent on a
139+ /// later reboot (and a crash before this point simply retries the unchanged outbox).
124140 /// </summary>
125141 public static void TryReplay ( )
126142 {
@@ -131,9 +147,18 @@ public static void TryReplay()
131147 List < Entry > toSend ;
132148 lock ( FileLock )
133149 {
134- if ( _previous . Count == 0 ) { _replayed = true ; return ; }
150+ if ( _previous . Count == 0 )
151+ {
152+ // Nothing to replay — clear any stale outbox and stop checking.
153+ TryDelete ( _replayPath ) ;
154+ _replayed = true ;
155+ return ;
156+ }
135157 toSend = new List < Entry > ( _previous ) ;
136158 _previous . Clear ( ) ;
159+ // Mark as handled: deleting the outbox here is what guarantees these reports are
160+ // not picked up and sent again after the next reboot.
161+ TryDelete ( _replayPath ) ;
137162 }
138163 _replayed = true ;
139164
@@ -144,11 +169,38 @@ public static void TryReplay()
144169 BasisDebug . Log ( $ "Replayed { toSend . Count } crash report(s) from the previous session.", BasisDebug . LogTag . Networking ) ;
145170 }
146171
147- private static void LoadPrevious ( )
172+ // Append pending.jsonl onto replay.jsonl (capped to the most recent entries), so
173+ // carried-over reports accumulate in the outbox. Caller holds FileLock.
174+ private static void FoldPendingIntoReplay ( )
175+ {
176+ try
177+ {
178+ List < string > lines = new List < string > ( ) ;
179+ if ( File . Exists ( _replayPath ) ) lines . AddRange ( File . ReadAllLines ( _replayPath ) ) ;
180+ if ( File . Exists ( _pendingPath ) ) lines . AddRange ( File . ReadAllLines ( _pendingPath ) ) ;
181+
182+ if ( lines . Count > MaxReplayEntries )
183+ lines . RemoveRange ( 0 , lines . Count - MaxReplayEntries ) ;
184+
185+ StringBuilder sb = new StringBuilder ( ) ;
186+ foreach ( string l in lines )
187+ {
188+ if ( ! string . IsNullOrEmpty ( l ) ) sb . Append ( l ) . Append ( '\n ' ) ;
189+ }
190+ File . WriteAllText ( _replayPath , sb . ToString ( ) ) ;
191+ }
192+ catch ( Exception e )
193+ {
194+ BasisDebug . LogWarning ( $ "Failed to fold pending crash reports into the outbox: { e . Message } ") ;
195+ }
196+ }
197+
198+ // Caller holds FileLock.
199+ private static void LoadReplay ( )
148200 {
149201 try
150202 {
151- string [ ] lines = File . ReadAllLines ( _pendingPath ) ;
203+ string [ ] lines = File . ReadAllLines ( _replayPath ) ;
152204 int start = Math . Max ( 0 , lines . Length - MaxReplayEntries ) ;
153205 for ( int i = start ; i < lines . Length ; i ++ )
154206 {
@@ -157,10 +209,25 @@ private static void LoadPrevious()
157209 }
158210 catch ( Exception e )
159211 {
160- BasisDebug . LogWarning ( $ "Failed to load previous crash reports: { e . Message } ") ;
212+ BasisDebug . LogWarning ( $ "Failed to load carried-over crash reports: { e . Message } ") ;
161213 }
162214 }
163215
216+ private static bool OverSizeLimit ( string path )
217+ {
218+ try
219+ {
220+ FileInfo info = new FileInfo ( path ) ;
221+ return info . Exists && info . Length > MaxFileBytes ;
222+ }
223+ catch { return false ; }
224+ }
225+
226+ private static void TryDelete ( string path )
227+ {
228+ try { if ( ! string . IsNullOrEmpty ( path ) && File . Exists ( path ) ) File . Delete ( path ) ; } catch { }
229+ }
230+
164231 private static bool TryParse ( string line , out Entry entry )
165232 {
166233 entry = default ;
0 commit comments