use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub(crate) const THREAD_DUMP_FORMATTED_PATH: &str =
"/data/api/v1/diagnostics/threads/dump/formatted";
pub(crate) const THREAD_DEADLOCKS_PATH: &str = "/data/api/v1/diagnostics/threads/deadlocks";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FormattedThreadDump {
#[serde(default)]
pub version: String,
#[serde(default)]
pub threads: Vec<ThreadInfo>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThreadInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub id: Option<i64>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub daemon: bool,
#[serde(default)]
pub system: String,
#[serde(default)]
pub scope: String,
#[serde(rename = "cpuUsage", default)]
pub cpu_usage: Option<f64>,
#[serde(rename = "waitingFor", default)]
pub waiting_for: Option<WaitingFor>,
#[serde(default)]
pub stacktrace: Vec<String>,
#[serde(rename = "lockedMonitors", default)]
pub locked_monitors: Vec<LockedMonitor>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WaitingFor {
#[serde(default)]
pub lock: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LockedMonitor {
#[serde(default)]
pub lock: Option<String>,
#[serde(default)]
pub frame: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeadlocksWire {
#[serde(default)]
pub deadlocks: Vec<i64>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::{DeadlocksWire, FormattedThreadDump};
#[test]
fn formatted_dump_parses_the_spec_shape() {
let body = serde_json::json!({
"version": "dump-version-1",
"threads": [
{
"name": "Perspective-Worker-3",
"id": 42,
"state": "RUNNABLE",
"daemon": true,
"system": "gateway",
"scope": "web",
"cpuUsage": 3.75,
"waitingFor": null,
"stacktrace": [
"java.base@21.0.5/java.lang.Thread.sleepNanos",
"com.inductiveautomation.perspective.gateway.Worker.run"
],
"lockedMonitors": [
{"lock": "<0x1a2b> (a com.inductiveautomation...) ", "frame": "Worker.lock - line 88"}
]
},
{
"name": "pool-2-thread-1",
"id": 7,
"state": "BLOCKED",
"daemon": false,
"cpuUsage": 0.0,
"waitingFor": {"lock": "<0x1a2b> (a com.inductiveautomation...)"}
}
]
});
let dump: FormattedThreadDump =
serde_json::from_value(body).expect("the spec shape must parse");
assert_eq!(dump.version, "dump-version-1");
assert_eq!(dump.threads.len(), 2);
let runnable = &dump.threads[0];
assert_eq!(runnable.name, "Perspective-Worker-3");
assert_eq!(runnable.id, Some(42));
assert_eq!(runnable.state, "RUNNABLE");
assert!(runnable.daemon);
assert_eq!(runnable.system, "gateway");
assert_eq!(runnable.scope, "web");
assert!((runnable.cpu_usage.unwrap() - 3.75).abs() < f64::EPSILON);
assert_eq!(runnable.stacktrace.len(), 2);
assert_eq!(
runnable.locked_monitors[0].lock.as_deref(),
Some("<0x1a2b> (a com.inductiveautomation...) ")
);
assert_eq!(
runnable.locked_monitors[0].frame.as_deref(),
Some("Worker.lock - line 88")
);
let blocked = &dump.threads[1];
assert_eq!(blocked.state, "BLOCKED");
assert!(!blocked.daemon, "absent daemon defaults false");
assert_eq!(
blocked
.waiting_for
.as_ref()
.and_then(|w| w.lock.clone())
.as_deref(),
Some("<0x1a2b> (a com.inductiveautomation...)"),
"the waiting-for lock parses through the nested object"
);
assert!(blocked.locked_monitors.is_empty());
assert!(blocked.stacktrace.is_empty(), "absent arrays default empty");
}
#[test]
fn thread_fields_are_all_tolerant() {
let dump: FormattedThreadDump = serde_json::from_value(serde_json::json!({
"threads": [{"name": "minimal"}],
"zzFutureKey": {"future": true}
}))
.expect("a name-only entry must parse");
let thread = &dump.threads[0];
assert_eq!(thread.name, "minimal");
assert_eq!(thread.id, None);
assert_eq!(thread.state, "");
assert!(!thread.daemon);
assert_eq!(thread.cpu_usage, None);
assert!(thread.waiting_for.is_none());
assert!(thread.stacktrace.is_empty());
assert!(thread.locked_monitors.is_empty());
assert!(
dump.extra.contains_key("zzFutureKey"),
"unknown keys round-trip"
);
let round = serde_json::to_value(thread).expect("serialize");
assert_eq!(
round["name"], "minimal",
"gateway-native key on the way out"
);
assert_eq!(
round["cpuUsage"],
serde_json::Value::Null,
"camelCase rename"
);
}
#[test]
fn deadlocks_wire_parses_the_id_list() {
let wire: DeadlocksWire = serde_json::from_value(serde_json::json!({"deadlocks": [7, 42]}))
.expect("the deadlocks shape must parse");
assert_eq!(wire.deadlocks, vec![7, 42]);
let healthy: DeadlocksWire = serde_json::from_value(serde_json::json!({}))
.expect("an absent list is the healthy answer");
assert!(healthy.deadlocks.is_empty());
}
}