use crate::events::{EventMetadata, GameEvent, TcpConnectionCloseEvent, WebSocketClosedEvent};
use crate::log::entry::{EntryHeader, LogEntry};
use crate::parsers::api_common;
const TCP_CONNECTION_CLOSE_MARKER: &str = "Client.TcpConnection.Close";
const WEBSOCKET_CLOSED_MARKER: &str = "GREConnection.HandleWebSocketClosed";
pub fn try_parse(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
if entry.header != EntryHeader::UnityCrossThreadLogger {
return None;
}
if entry.body.contains(TCP_CONNECTION_CLOSE_MARKER) {
return try_parse_tcp_close(entry, timestamp);
}
if entry.body.contains(WEBSOCKET_CLOSED_MARKER) {
return try_parse_websocket_closed(entry, timestamp);
}
None
}
fn try_parse_tcp_close(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
let json_str = api_common::extract_json_from_body(&entry.body)?;
let payload: serde_json::Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
::log::warn!("Client.TcpConnection.Close: malformed JSON payload: {e}");
return None;
}
};
let metadata = EventMetadata::new(timestamp, entry.body.as_bytes().to_vec());
Some(GameEvent::TcpConnectionClose(TcpConnectionCloseEvent::new(
metadata, payload,
)))
}
fn try_parse_websocket_closed(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
let json_str = api_common::extract_json_from_body(&entry.body)?;
let payload: serde_json::Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
::log::warn!("GREConnection.HandleWebSocketClosed: malformed JSON payload: {e}");
return None;
}
};
let metadata = EventMetadata::new(timestamp, entry.body.as_bytes().to_vec());
Some(GameEvent::WebSocketClosed(WebSocketClosedEvent::new(
metadata, payload,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsers::test_helpers::{
tcp_connection_close_payload, test_timestamp, unity_entry, websocket_closed_payload,
};
fn tcp_close_body(json: &str) -> String {
format!("[UnityCrossThreadLogger]Client.TcpConnection.Close {json}")
}
fn websocket_closed_body(json: &str) -> String {
format!("[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {json}")
}
mod tcp_close_normal {
use super::*;
#[test]
fn test_status_7_closed_by_remote_end() {
let body =
tcp_close_body(r#"{"status":7,"reason":"Closed by remote end","connectionId":42}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(
matches!(event, GameEvent::TcpConnectionClose(_)),
"expected TcpConnectionClose, got {event:?}"
);
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 7);
assert_eq!(payload["reason"], "Closed by remote end");
assert_eq!(payload["connectionId"], 42);
}
#[test]
fn test_status_2_cleanup_before_reconnecting() {
let body = tcp_close_body(r#"{"status":2,"reason":"Cleanup before reconnecting"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 2);
assert_eq!(payload["reason"], "Cleanup before reconnecting");
}
#[test]
fn test_status_2_match_manager_reset() {
let body = tcp_close_body(r#"{"status":2,"reason":"MatchManager.Reset"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 2);
assert_eq!(payload["reason"], "MatchManager.Reset");
}
#[test]
fn test_status_2_on_destroy() {
let body = tcp_close_body(r#"{"status":2,"reason":"OnDestroy"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 2);
assert_eq!(payload["reason"], "OnDestroy");
}
#[test]
fn test_status_2_match_manager_dispose() {
let body = tcp_close_body(r#"{"status":2,"reason":"MatchManager.Dispose"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 2);
assert_eq!(payload["reason"], "MatchManager.Dispose");
}
#[test]
fn test_status_5_inactivity_timeout() {
let body = tcp_close_body(r#"{"status":5,"reason":"Inactivity timeout"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 5);
assert_eq!(payload["reason"], "Inactivity timeout");
}
}
mod tcp_close_abnormal {
use super::*;
#[test]
fn test_status_1_with_inner_exception_native_error_code_10054_windows() {
let body = tcp_close_body(
r#"{
"status":1,
"reason":"",
"function":"ReadAsync",
"description":"An established connection was aborted by the software in your host machine",
"exception":{
"Message":"Unable to read data from the transport connection",
"InnerException":{
"Message":"An established connection was aborted",
"NativeErrorCode":10054,
"SocketErrorCode":"ConnectionAborted"
}
}
}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 1);
assert_eq!(payload["function"], "ReadAsync");
assert_eq!(
payload["exception"]["InnerException"]["NativeErrorCode"],
10054
);
assert_eq!(
payload["exception"]["InnerException"]["SocketErrorCode"],
"ConnectionAborted"
);
}
#[test]
fn test_status_1_with_inner_exception_native_error_code_10060_macos() {
let body = tcp_close_body(
r#"{
"status":1,
"reason":"",
"function":"ReadAsync",
"description":"Connection timed out",
"exception":{
"Message":"Unable to read data",
"InnerException":{
"Message":"Operation timed out",
"NativeErrorCode":10060,
"SocketErrorCode":"TimedOut"
}
}
}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 1);
assert_eq!(
payload["exception"]["InnerException"]["NativeErrorCode"],
10060
);
assert_eq!(
payload["exception"]["InnerException"]["SocketErrorCode"],
"TimedOut"
);
}
#[test]
fn test_status_9_connection_timed_out() {
let body = tcp_close_body(
r#"{
"status":9,
"reason":"Connection timed out",
"function":"WriteAsync"
}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 9);
assert_eq!(payload["reason"], "Connection timed out");
assert_eq!(payload["function"], "WriteAsync");
}
#[test]
fn test_status_9_firewall_permissions_with_embedded_null_bytes() {
let body = tcp_close_body(
r#"{
"status":9,
"reason":"An attempt was made to access a socket in a way forbidden by its access permissions\u0000.\u0000",
"function":"ConnectAsync"
}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert_eq!(payload["status"], 9);
let reason = payload["reason"].as_str().unwrap_or("");
assert!(
reason.starts_with("An attempt was made to access a socket"),
"reason prefix preserved, got: {reason:?}"
);
assert!(
reason.contains('\u{0000}'),
"reason must preserve embedded NUL bytes, got: {reason:?}"
);
assert_eq!(
reason.matches('\u{0000}').count(),
2,
"reason must preserve both embedded NUL bytes, got: {reason:?}"
);
}
}
mod tcp_close_bare_marker {
use super::*;
#[test]
fn test_bare_marker_no_json_returns_none() {
let body = "[UnityCrossThreadLogger]Client.TcpConnection.Close";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_bare_marker_with_trailing_whitespace_returns_none() {
let body = "[UnityCrossThreadLogger]Client.TcpConnection.Close ";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_bare_marker_followed_by_newline_returns_none() {
let body = "[UnityCrossThreadLogger]Client.TcpConnection.Close\n";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
}
mod tcp_close_numeric_types {
use super::*;
#[test]
fn test_status_and_native_error_code_stay_numeric() {
let body = tcp_close_body(
r#"{"status":1,"exception":{"InnerException":{"NativeErrorCode":10054}}}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = tcp_connection_close_payload(event);
assert!(
payload["status"].is_number(),
"status must remain numeric, got {:?}",
payload["status"]
);
assert!(
payload["exception"]["InnerException"]["NativeErrorCode"].is_number(),
"NativeErrorCode must remain numeric, got {:?}",
payload["exception"]["InnerException"]["NativeErrorCode"]
);
}
}
mod websocket_closed {
use super::*;
fn websocket_closed_payload_json(close_type: u32, reason: &str) -> String {
format!(
r#"{{
"closeType":{close_type},
"reason":"{reason}",
"tcpConn":{{
"host":"mtgarena-prod.example.com",
"port":443,
"rtTicksRollingAvg":123.45,
"rtTicksSamples":[100,110,125,140,130],
"lastLocalActivity":637123456789,
"lastRemoteActivity":637123456999,
"lastRemotePing":637123456800,
"inactivityTimeoutMs":30000
}}
}}"#
)
}
#[test]
fn test_close_type_1_abnormal() {
let body = websocket_closed_body(&websocket_closed_payload_json(1, "Abnormal closure"));
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(
matches!(event, GameEvent::WebSocketClosed(_)),
"expected WebSocketClosed, got {event:?}"
);
let payload = websocket_closed_payload(event);
assert_eq!(payload["closeType"], 1);
assert_eq!(payload["reason"], "Abnormal closure");
}
#[test]
fn test_close_type_7_closed_by_remote() {
let body = websocket_closed_body(&websocket_closed_payload_json(7, "Closed by remote"));
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = websocket_closed_payload(event);
assert_eq!(payload["closeType"], 7);
assert_eq!(payload["reason"], "Closed by remote");
}
#[test]
fn test_close_type_9_timeout() {
let body =
websocket_closed_body(&websocket_closed_payload_json(9, "Connection timed out"));
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = websocket_closed_payload(event);
assert_eq!(payload["closeType"], 9);
assert_eq!(payload["reason"], "Connection timed out");
}
#[test]
fn test_payload_preserves_nested_tcp_conn_object() {
let body = websocket_closed_body(&websocket_closed_payload_json(1, "Abnormal"));
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = websocket_closed_payload(event);
let tcp = &payload["tcpConn"];
assert!(tcp.is_object(), "tcpConn must be preserved as object");
assert_eq!(tcp["host"], "mtgarena-prod.example.com");
assert_eq!(tcp["port"], 443);
assert_eq!(tcp["inactivityTimeoutMs"], 30000);
}
#[test]
fn test_tcp_conn_numeric_types_round_trip() {
let body = websocket_closed_body(&websocket_closed_payload_json(7, "Closed"));
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
let payload = websocket_closed_payload(event);
let tcp = &payload["tcpConn"];
assert!(
tcp["rtTicksRollingAvg"].is_number(),
"rtTicksRollingAvg must be numeric, got {:?}",
tcp["rtTicksRollingAvg"]
);
assert_eq!(tcp["rtTicksRollingAvg"].as_f64(), Some(123.45));
assert!(
tcp["rtTicksSamples"].is_array(),
"rtTicksSamples must be an array, got {:?}",
tcp["rtTicksSamples"]
);
let samples = tcp["rtTicksSamples"].as_array().unwrap_or_else(|| {
unreachable!()
});
assert_eq!(samples.len(), 5);
for sample in samples {
assert!(
sample.is_number(),
"each rtTicksSamples entry must be numeric, got {sample:?}"
);
}
assert_eq!(samples[0].as_u64(), Some(100));
assert_eq!(samples[4].as_u64(), Some(130));
}
}
mod non_matching {
use super::*;
#[test]
fn test_plain_gre_message_returns_none() {
let body =
"[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent\n{\"data\":1}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_front_door_connection_close_returns_none() {
let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_similar_but_different_marker_returns_none() {
let body = "[UnityCrossThreadLogger]Client.TcpConnection.Open {\"status\":0}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_empty_unity_body_returns_none() {
let body = "[UnityCrossThreadLogger]";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_non_unity_cross_thread_logger_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::ConnectionManager,
body: "[ConnectionManager]Client.TcpConnection.Close {\"status\":7,\"reason\":\"Closed by remote end\"}"
.to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_metadata_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::Metadata,
body: "Client.TcpConnection.Close {\"status\":7}".to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_connection_manager_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::ConnectionManager,
body: "[ConnectionManager] GREConnection.HandleWebSocketClosed {\"closeType\":1}"
.to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_tcp_close_malformed_json_returns_none() {
let body =
"[UnityCrossThreadLogger]Client.TcpConnection.Close {\"status\":7,\"reason\":";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_websocket_closed_malformed_json_returns_none() {
let body =
"[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {\"closeType\":";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
}
mod metadata {
use super::*;
#[test]
fn test_tcp_close_preserves_raw_bytes() {
let body = tcp_close_body(r#"{"status":7,"reason":"Closed by remote end"}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
}
#[test]
fn test_websocket_closed_preserves_raw_bytes() {
let body = websocket_closed_body(
r#"{"closeType":7,"reason":"Closed","tcpConn":{"host":"h"}}"#,
);
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
}
#[test]
fn test_tcp_close_preserves_timestamp() {
let body = tcp_close_body(r#"{"status":7}"#);
let entry = unity_entry(&body);
let ts = Some(test_timestamp());
let result = try_parse(&entry, ts);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().timestamp(), ts);
}
#[test]
fn test_tcp_close_passes_through_none_timestamp() {
let body = tcp_close_body(r#"{"status":7}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, None);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(event.metadata().timestamp().is_none());
}
#[test]
fn test_websocket_closed_passes_through_none_timestamp() {
let body = websocket_closed_body(r#"{"closeType":7,"reason":"Closed","tcpConn":{}}"#);
let entry = unity_entry(&body);
let result = try_parse(&entry, None);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(event.metadata().timestamp().is_none());
}
}
}