1use crate::events::{ConnectionErrorEvent, EventMetadata, GameEvent};
80use crate::log::entry::{EntryHeader, LogEntry};
81use crate::parsers::api_common;
82
83const PROCESS_READ_EXCEPTION_MARKER: &str = "TcpConnection.ProcessRead.Exception";
85
86const PROCESS_FAILURE_MARKER: &str = "Client.TcpConnection.ProcessFailure";
88
89const MATCH_DOOR_ERROR_MARKER: &str = "GREConnection.MatchDoorConnectionError";
91
92const CLOSE_EXCEPTION_MARKER: &str = "TcpConnection.Close.Exception";
94
95const ERROR_TYPE_PROCESS_READ: &str = "tcp_process_read_exception";
97
98const ERROR_TYPE_PROCESS_FAILURE: &str = "tcp_process_failure_socket_error";
100
101const ERROR_TYPE_MATCH_DOOR: &str = "gre_match_door_connection_error";
103
104const ERROR_TYPE_CLOSE_EXCEPTION: &str = "tcp_close_exception";
106
107pub fn try_parse(
125 entry: &LogEntry,
126 timestamp: Option<chrono::DateTime<chrono::Utc>>,
127) -> Option<GameEvent> {
128 let payload = match entry.header {
129 EntryHeader::UnityCrossThreadLogger => try_unity_error(&entry.body)?,
130 EntryHeader::ConnectionManager => try_connection_manager(&entry.body)?,
131 EntryHeader::Matchmaking => try_matchmaking(&entry.body)?,
132 _ => return None,
133 };
134
135 let metadata = EventMetadata::new(timestamp, entry.body.as_bytes().to_vec());
136 Some(GameEvent::ConnectionError(ConnectionErrorEvent::new(
137 metadata, payload,
138 )))
139}
140
141fn try_unity_error(body: &str) -> Option<serde_json::Value> {
148 if body.contains(PROCESS_READ_EXCEPTION_MARKER) {
149 return try_exception_marker(body, ERROR_TYPE_PROCESS_READ);
150 }
151 if body.contains(PROCESS_FAILURE_MARKER) {
152 return try_exception_marker(body, ERROR_TYPE_PROCESS_FAILURE);
153 }
154 if body.contains(MATCH_DOOR_ERROR_MARKER) {
155 return try_exception_marker(body, ERROR_TYPE_MATCH_DOOR);
156 }
157 if body.contains(CLOSE_EXCEPTION_MARKER) {
158 return try_exception_marker(body, ERROR_TYPE_CLOSE_EXCEPTION);
159 }
160 None
161}
162
163fn try_exception_marker(body: &str, error_type: &str) -> Option<serde_json::Value> {
171 let json_str = api_common::extract_json_from_body(body)?;
172 let parsed: serde_json::Value = match serde_json::from_str(json_str) {
173 Ok(v) => v,
174 Err(e) => {
175 ::log::warn!("{error_type}: malformed JSON payload: {e}");
176 return None;
177 }
178 };
179 Some(serde_json::json!({
180 "error_type": error_type,
181 "payload": parsed,
182 }))
183}
184
185fn try_connection_manager(body: &str) -> Option<serde_json::Value> {
207 let content = body.strip_prefix("[ConnectionManager] ")?;
208
209 if let Some(rest) = content.strip_prefix("Reconnect result : ") {
210 let result = rest.trim();
211 return match result {
212 "Connected" | "Error" | "None" => Some(serde_json::json!({
213 "error_type": "reconnect_result",
214 "result": result,
215 })),
216 _ => None,
217 };
218 }
219
220 if let Some(rest) = content.strip_prefix("Reconnect succeeded after ") {
221 let attempts = rest
222 .split_whitespace()
223 .next()
224 .and_then(|s| s.parse::<i64>().ok());
225 return Some(serde_json::json!({
226 "error_type": "reconnect_outcome",
227 "outcome": "succeeded",
228 "attempts": attempts,
229 }));
230 }
231
232 if content.starts_with("Reconnect failed") {
233 return Some(serde_json::json!({
234 "error_type": "reconnect_outcome",
235 "outcome": "failed",
236 "attempts": serde_json::Value::Null,
237 }));
238 }
239
240 if content.starts_with("Reconnect timed out") {
241 return Some(serde_json::json!({
242 "error_type": "reconnect_outcome",
243 "outcome": "timed_out",
244 "attempts": serde_json::Value::Null,
245 }));
246 }
247
248 None
249}
250
251fn try_matchmaking(body: &str) -> Option<serde_json::Value> {
261 if body.starts_with("Matchmaking: GRE connection lost") {
262 return Some(serde_json::json!({"error_type": "gre_connection_lost"}));
263 }
264 None
265}
266
267#[cfg(test)]
272#[allow(deprecated)]
273mod tests {
274 use super::*;
275 use crate::parsers::test_helpers::{
276 connection_error_payload, connection_manager_entry, matchmaking_entry, test_timestamp,
277 unity_entry,
278 };
279
280 fn unity_body(marker: &str, json: &str) -> String {
282 format!("[UnityCrossThreadLogger]{marker} {json}")
283 }
284
285 fn assert_connection_error<'a>(
288 event: &'a GameEvent,
289 expected_error_type: &str,
290 ) -> &'a serde_json::Value {
291 assert!(
292 matches!(event, GameEvent::ConnectionError(_)),
293 "expected ConnectionError, got {event:?}"
294 );
295 let outer = connection_error_payload(event);
296 assert_eq!(
297 outer["error_type"], expected_error_type,
298 "error_type mismatch"
299 );
300 &outer["payload"]
301 }
302
303 mod process_read_exception {
306 use super::*;
307
308 #[test]
309 fn test_windows_native_error_code_10054() {
310 let body = unity_body(
311 PROCESS_READ_EXCEPTION_MARKER,
312 r#"{
313 "function":"ReadAsync",
314 "description":"An established connection was aborted by the software in your host machine",
315 "exception":{
316 "Message":"Unable to read data from the transport connection",
317 "ClassName":"System.IO.IOException",
318 "InnerException":{
319 "ClassName":"System.Net.Sockets.SocketException",
320 "NativeErrorCode":10054,
321 "SocketErrorCode":"ConnectionAborted",
322 "Message":"An established connection was aborted"
323 }
324 }
325 }"#,
326 );
327 let entry = unity_entry(&body);
328 let result = try_parse(&entry, Some(test_timestamp()));
329
330 assert!(result.is_some());
331 let event = result.as_ref().unwrap_or_else(|| unreachable!());
332 let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
333 assert_eq!(payload["function"], "ReadAsync");
334 assert_eq!(
335 payload["exception"]["InnerException"]["NativeErrorCode"],
336 10054
337 );
338 assert_eq!(
339 payload["exception"]["InnerException"]["SocketErrorCode"],
340 "ConnectionAborted"
341 );
342 }
343
344 #[test]
345 fn test_macos_native_error_code_10060() {
346 let body = unity_body(
347 PROCESS_READ_EXCEPTION_MARKER,
348 r#"{
349 "function":"ReadAsync",
350 "description":"Connection timed out",
351 "exception":{
352 "ClassName":"System.IO.IOException",
353 "InnerException":{
354 "ClassName":"System.Net.Sockets.SocketException",
355 "NativeErrorCode":10060,
356 "SocketErrorCode":"TimedOut",
357 "Message":"Operation timed out"
358 }
359 }
360 }"#,
361 );
362 let entry = unity_entry(&body);
363 let result = try_parse(&entry, Some(test_timestamp()));
364
365 assert!(result.is_some());
366 let event = result.as_ref().unwrap_or_else(|| unreachable!());
367 let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
368 assert_eq!(
369 payload["exception"]["InnerException"]["NativeErrorCode"],
370 10060
371 );
372 assert_eq!(
373 payload["exception"]["InnerException"]["SocketErrorCode"],
374 "TimedOut"
375 );
376 }
377
378 #[test]
379 fn test_macos_native_error_code_10049() {
380 let body = unity_body(
381 PROCESS_READ_EXCEPTION_MARKER,
382 r#"{
383 "function":"ReadAsync",
384 "description":"Address not valid",
385 "exception":{
386 "InnerException":{
387 "NativeErrorCode":10049,
388 "SocketErrorCode":"AddressNotAvailable"
389 }
390 }
391 }"#,
392 );
393 let entry = unity_entry(&body);
394 let result = try_parse(&entry, Some(test_timestamp()));
395
396 assert!(result.is_some());
397 let event = result.as_ref().unwrap_or_else(|| unreachable!());
398 let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
399 assert_eq!(
400 payload["exception"]["InnerException"]["NativeErrorCode"],
401 10049
402 );
403 }
404
405 #[test]
406 fn test_bare_marker_returns_none() {
407 let body = format!("[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER}");
408 let entry = unity_entry(&body);
409 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
410 }
411
412 #[test]
413 fn test_bare_marker_with_trailing_whitespace_returns_none() {
414 let body = format!("[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER} ");
415 let entry = unity_entry(&body);
416 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
417 }
418
419 #[test]
420 fn test_numeric_native_error_code_stays_numeric() {
421 let body = unity_body(
422 PROCESS_READ_EXCEPTION_MARKER,
423 r#"{"exception":{"InnerException":{"NativeErrorCode":10054}}}"#,
424 );
425 let entry = unity_entry(&body);
426 let result = try_parse(&entry, Some(test_timestamp()));
427
428 assert!(result.is_some());
429 let event = result.as_ref().unwrap_or_else(|| unreachable!());
430 let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
431 assert!(
432 payload["exception"]["InnerException"]["NativeErrorCode"].is_number(),
433 "NativeErrorCode must remain numeric"
434 );
435 }
436 }
437
438 mod process_failure {
441 use super::*;
442
443 #[test]
444 fn test_socket_error_firewall_block() {
445 let body = unity_body(
446 PROCESS_FAILURE_MARKER,
447 r#"{"SocketError":"AccessDenied","function":"ConnectAsync"}"#,
448 );
449 let entry = unity_entry(&body);
450 let result = try_parse(&entry, Some(test_timestamp()));
451
452 assert!(result.is_some());
453 let event = result.as_ref().unwrap_or_else(|| unreachable!());
454 let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_FAILURE);
455 assert_eq!(payload["SocketError"], "AccessDenied");
456 assert_eq!(payload["function"], "ConnectAsync");
457 }
458
459 #[test]
460 fn test_bare_marker_returns_none() {
461 let body = format!("[UnityCrossThreadLogger]{PROCESS_FAILURE_MARKER}");
462 let entry = unity_entry(&body);
463 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
464 }
465 }
466
467 mod match_door_error {
470 use super::*;
471
472 #[test]
473 fn test_close_type_and_tcp_conn() {
474 let body = unity_body(
475 MATCH_DOOR_ERROR_MARKER,
476 r#"{
477 "closeType":1,
478 "reason":"Connection lost",
479 "tcpConn":{
480 "host":"mtgarena-match.example.com",
481 "port":443,
482 "inactivityTimeoutMs":30000
483 }
484 }"#,
485 );
486 let entry = unity_entry(&body);
487 let result = try_parse(&entry, Some(test_timestamp()));
488
489 assert!(result.is_some());
490 let event = result.as_ref().unwrap_or_else(|| unreachable!());
491 let payload = assert_connection_error(event, ERROR_TYPE_MATCH_DOOR);
492 assert_eq!(payload["closeType"], 1);
493 assert_eq!(payload["reason"], "Connection lost");
494 assert_eq!(payload["tcpConn"]["host"], "mtgarena-match.example.com");
495 assert_eq!(payload["tcpConn"]["port"], 443);
496 }
497
498 #[test]
499 fn test_bare_marker_returns_none() {
500 let body = format!("[UnityCrossThreadLogger]{MATCH_DOOR_ERROR_MARKER}");
501 let entry = unity_entry(&body);
502 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
503 }
504 }
505
506 mod close_exception {
509 use super::*;
510
511 #[test]
512 fn test_single_exception_top_level_key() {
513 let body = unity_body(
514 CLOSE_EXCEPTION_MARKER,
515 r#"{
516 "exception":{
517 "NativeErrorCode":10049,
518 "ClassName":"System.Net.Sockets.SocketException",
519 "Message":"The requested address is not valid in this context",
520 "InnerException":null
521 }
522 }"#,
523 );
524 let entry = unity_entry(&body);
525 let result = try_parse(&entry, Some(test_timestamp()));
526
527 assert!(result.is_some());
528 let event = result.as_ref().unwrap_or_else(|| unreachable!());
529 let payload = assert_connection_error(event, ERROR_TYPE_CLOSE_EXCEPTION);
530 assert!(payload["exception"].is_object());
533 assert_eq!(payload["exception"]["NativeErrorCode"], 10049);
534 assert_eq!(
535 payload["exception"]["ClassName"],
536 "System.Net.Sockets.SocketException"
537 );
538 assert!(payload["exception"]["InnerException"].is_null());
539 }
540
541 #[test]
542 fn test_bare_marker_returns_none() {
543 let body = format!("[UnityCrossThreadLogger]{CLOSE_EXCEPTION_MARKER}");
544 let entry = unity_entry(&body);
545 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
546 }
547 }
548
549 mod non_matching {
552 use super::*;
553
554 #[test]
555 fn test_plain_gre_message_returns_none() {
556 let body =
557 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent\n{\"data\":1}";
558 let entry = unity_entry(body);
559 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
560 }
561
562 #[test]
563 fn test_tcp_connection_close_returns_none() {
564 let body =
567 "[UnityCrossThreadLogger]Client.TcpConnection.Close {\"status\":7,\"reason\":\"x\"}";
568 let entry = unity_entry(body);
569 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
570 }
571
572 #[test]
573 fn test_websocket_closed_returns_none() {
574 let body =
575 "[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {\"closeType\":1}";
576 let entry = unity_entry(body);
577 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
578 }
579
580 #[test]
581 fn test_empty_unity_body_returns_none() {
582 let body = "[UnityCrossThreadLogger]";
583 let entry = unity_entry(body);
584 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
585 }
586
587 #[test]
588 fn test_malformed_json_returns_none() {
589 let body = format!(
590 "[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER} {{\"function\":\"ReadAsync\""
591 );
592 let entry = unity_entry(&body);
593 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
594 }
595 }
596
597 mod non_unity_headers {
600 use super::*;
601
602 #[test]
603 fn test_matchmaking_header_returns_none() {
604 let entry = LogEntry {
605 header: EntryHeader::Matchmaking,
606 body: format!(
607 "Matchmaking:{PROCESS_READ_EXCEPTION_MARKER} {{\"function\":\"ReadAsync\"}}"
608 ),
609 };
610 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
611 }
612
613 #[test]
614 fn test_metadata_header_returns_none() {
615 let entry = LogEntry {
616 header: EntryHeader::Metadata,
617 body: format!(
618 "{PROCESS_READ_EXCEPTION_MARKER} {{\"exception\":{{\"InnerException\":{{\"NativeErrorCode\":10054}}}}}}"
619 ),
620 };
621 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
622 }
623
624 #[test]
625 fn test_unrecognized_connection_manager_body_returns_none() {
626 let entry =
629 connection_manager_entry("[ConnectionManager] Some unrelated diagnostic line");
630 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
631 }
632
633 #[test]
634 fn test_unrecognized_matchmaking_body_returns_none() {
635 let entry = matchmaking_entry("Matchmaking: queue entered");
638 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
639 }
640 }
641
642 mod reconnect_result {
645 use super::*;
646
647 fn parse(body: &str) -> Option<GameEvent> {
648 let entry = connection_manager_entry(body);
649 try_parse(&entry, Some(test_timestamp()))
650 }
651
652 fn assert_result(body: &str, expected: &str) {
653 let result = parse(body);
654 assert!(result.is_some(), "expected Some for {body:?}, got None");
655 let event = result.as_ref().unwrap_or_else(|| unreachable!());
656 let payload = connection_error_payload(event);
657 assert_eq!(payload["error_type"], "reconnect_result");
658 assert_eq!(payload["result"], expected);
659 }
660
661 #[test]
662 fn test_reconnect_result_connected() {
663 assert_result(
664 "[ConnectionManager] Reconnect result : Connected",
665 "Connected",
666 );
667 }
668
669 #[test]
670 fn test_reconnect_result_error() {
671 assert_result("[ConnectionManager] Reconnect result : Error", "Error");
672 }
673
674 #[test]
675 fn test_reconnect_result_none() {
676 assert_result("[ConnectionManager] Reconnect result : None", "None");
677 }
678
679 #[test]
680 fn test_reconnect_result_invalid_value_returns_none() {
681 assert!(parse("[ConnectionManager] Reconnect result : Unknown").is_none());
683 }
684
685 #[test]
686 fn test_reconnect_result_empty_value_returns_none() {
687 assert!(parse("[ConnectionManager] Reconnect result : ").is_none());
688 }
689 }
690
691 mod reconnect_outcome {
694 use super::*;
695
696 fn parse(body: &str) -> Option<GameEvent> {
697 let entry = connection_manager_entry(body);
698 try_parse(&entry, Some(test_timestamp()))
699 }
700
701 #[test]
702 fn test_reconnect_succeeded_after_1_attempts() {
703 let result = parse("[ConnectionManager] Reconnect succeeded after 1 attempts");
704 assert!(result.is_some());
705 let event = result.as_ref().unwrap_or_else(|| unreachable!());
706 let payload = connection_error_payload(event);
707 assert_eq!(payload["error_type"], "reconnect_outcome");
708 assert_eq!(payload["outcome"], "succeeded");
709 assert_eq!(payload["attempts"], 1);
710 }
711
712 #[test]
713 fn test_reconnect_succeeded_with_trailing_descriptor() {
714 let result = parse("[ConnectionManager] Reconnect succeeded after 3 attempts (1.5s)");
717 assert!(result.is_some());
718 let event = result.as_ref().unwrap_or_else(|| unreachable!());
719 let payload = connection_error_payload(event);
720 assert_eq!(payload["outcome"], "succeeded");
721 assert_eq!(payload["attempts"], 3);
722 }
723
724 #[test]
725 fn test_reconnect_failed() {
726 let result = parse("[ConnectionManager] Reconnect failed");
727 assert!(result.is_some());
728 let event = result.as_ref().unwrap_or_else(|| unreachable!());
729 let payload = connection_error_payload(event);
730 assert_eq!(payload["error_type"], "reconnect_outcome");
731 assert_eq!(payload["outcome"], "failed");
732 assert!(payload["attempts"].is_null());
733 }
734
735 #[test]
736 fn test_reconnect_timed_out() {
737 let result = parse("[ConnectionManager] Reconnect timed out");
738 assert!(result.is_some());
739 let event = result.as_ref().unwrap_or_else(|| unreachable!());
740 let payload = connection_error_payload(event);
741 assert_eq!(payload["error_type"], "reconnect_outcome");
742 assert_eq!(payload["outcome"], "timed_out");
743 assert!(payload["attempts"].is_null());
744 }
745
746 #[test]
747 fn test_reconnect_succeeded_unparseable_attempts_is_null() {
748 let result = parse("[ConnectionManager] Reconnect succeeded after banana attempts");
751 assert!(result.is_some());
752 let event = result.as_ref().unwrap_or_else(|| unreachable!());
753 let payload = connection_error_payload(event);
754 assert_eq!(payload["error_type"], "reconnect_outcome");
755 assert_eq!(payload["outcome"], "succeeded");
756 assert!(
757 payload["attempts"].is_null(),
758 "unparseable attempts must be null, got {:?}",
759 payload["attempts"]
760 );
761 }
762 }
763
764 mod gre_connection_lost {
767 use super::*;
768
769 #[test]
770 fn test_gre_connection_lost_bare() {
771 let entry = matchmaking_entry("Matchmaking: GRE connection lost");
772 let result = try_parse(&entry, Some(test_timestamp()));
773 assert!(result.is_some());
774 let event = result.as_ref().unwrap_or_else(|| unreachable!());
775 let payload = connection_error_payload(event);
776 assert_eq!(payload["error_type"], "gre_connection_lost");
777 assert!(payload.get("payload").is_none());
779 }
780
781 #[test]
782 fn test_gre_connection_lost_with_trailing_descriptor() {
783 let entry = matchmaking_entry("Matchmaking: GRE connection lost, attempting reconnect");
786 let result = try_parse(&entry, Some(test_timestamp()));
787 assert!(result.is_some());
788 let event = result.as_ref().unwrap_or_else(|| unreachable!());
789 let payload = connection_error_payload(event);
790 assert_eq!(payload["error_type"], "gre_connection_lost");
791 }
792
793 #[test]
794 fn test_non_matching_matchmaking_suffix_returns_none() {
795 let entry = matchmaking_entry("Matchmaking: GRE connected");
798 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
799 }
800 }
801
802 mod plain_text_dispatch {
805 use super::*;
806
807 #[test]
808 fn test_connection_manager_without_prefix_returns_none() {
809 let entry = LogEntry {
811 header: EntryHeader::ConnectionManager,
812 body: "Reconnect result : Connected".to_owned(),
813 };
814 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
815 }
816
817 #[test]
818 fn test_matchmaking_empty_body_returns_none() {
819 let entry = matchmaking_entry("");
820 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
821 }
822
823 #[test]
824 fn test_unity_header_with_reconnect_body_returns_none() {
825 let entry = LogEntry {
828 header: EntryHeader::UnityCrossThreadLogger,
829 body: "[ConnectionManager] Reconnect result : Connected".to_owned(),
830 };
831 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
832 }
833 }
834
835 mod metadata {
838 use super::*;
839
840 #[test]
841 fn test_preserves_raw_bytes() {
842 let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
843 let entry = unity_entry(&body);
844 let result = try_parse(&entry, Some(test_timestamp()));
845
846 assert!(result.is_some());
847 let event = result.as_ref().unwrap_or_else(|| unreachable!());
848 assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
849 }
850
851 #[test]
852 fn test_preserves_timestamp() {
853 let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
854 let entry = unity_entry(&body);
855 let ts = Some(test_timestamp());
856 let result = try_parse(&entry, ts);
857
858 assert!(result.is_some());
859 let event = result.as_ref().unwrap_or_else(|| unreachable!());
860 assert_eq!(event.metadata().timestamp(), ts);
861 }
862
863 #[test]
864 fn test_passes_through_none_timestamp() {
865 let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
866 let entry = unity_entry(&body);
867 let result = try_parse(&entry, None);
868
869 assert!(result.is_some());
870 let event = result.as_ref().unwrap_or_else(|| unreachable!());
871 assert!(event.metadata().timestamp().is_none());
872 }
873 }
874}