1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17pub(crate) const THREAD_DUMP_FORMATTED_PATH: &str =
21 "/data/api/v1/diagnostics/threads/dump/formatted";
22
23pub(crate) const THREAD_DEADLOCKS_PATH: &str = "/data/api/v1/diagnostics/threads/deadlocks";
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct FormattedThreadDump {
31 #[serde(default)]
33 pub version: String,
34 #[serde(default)]
37 pub threads: Vec<ThreadInfo>,
38 #[serde(flatten)]
40 pub extra: BTreeMap<String, serde_json::Value>,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct ThreadInfo {
47 #[serde(default)]
49 pub name: String,
50 #[serde(default)]
52 pub id: Option<i64>,
53 #[serde(default)]
55 pub state: String,
56 #[serde(default)]
58 pub daemon: bool,
59 #[serde(default)]
61 pub system: String,
62 #[serde(default)]
64 pub scope: String,
65 #[serde(rename = "cpuUsage", default)]
68 pub cpu_usage: Option<f64>,
69 #[serde(rename = "waitingFor", default)]
72 pub waiting_for: Option<WaitingFor>,
73 #[serde(default)]
75 pub stacktrace: Vec<String>,
76 #[serde(rename = "lockedMonitors", default)]
80 pub locked_monitors: Vec<LockedMonitor>,
81 #[serde(flatten)]
83 pub extra: BTreeMap<String, serde_json::Value>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct WaitingFor {
89 #[serde(default)]
91 pub lock: Option<String>,
92 #[serde(flatten)]
94 pub extra: BTreeMap<String, serde_json::Value>,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct LockedMonitor {
100 #[serde(default)]
102 pub lock: Option<String>,
103 #[serde(default)]
105 pub frame: Option<String>,
106 #[serde(flatten)]
108 pub extra: BTreeMap<String, serde_json::Value>,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DeadlocksWire {
115 #[serde(default)]
118 pub deadlocks: Vec<i64>,
119 #[serde(flatten)]
121 pub extra: BTreeMap<String, serde_json::Value>,
122}
123
124#[cfg(test)]
125mod tests {
126 use super::{DeadlocksWire, FormattedThreadDump};
127
128 #[test]
132 fn formatted_dump_parses_the_spec_shape() {
133 let body = serde_json::json!({
134 "version": "dump-version-1",
135 "threads": [
136 {
137 "name": "Perspective-Worker-3",
138 "id": 42,
139 "state": "RUNNABLE",
140 "daemon": true,
141 "system": "gateway",
142 "scope": "web",
143 "cpuUsage": 3.75,
144 "waitingFor": null,
145 "stacktrace": [
146 "java.base@21.0.5/java.lang.Thread.sleepNanos",
147 "com.inductiveautomation.perspective.gateway.Worker.run"
148 ],
149 "lockedMonitors": [
150 {"lock": "<0x1a2b> (a com.inductiveautomation...) ", "frame": "Worker.lock - line 88"}
151 ]
152 },
153 {
154 "name": "pool-2-thread-1",
155 "id": 7,
156 "state": "BLOCKED",
157 "daemon": false,
158 "cpuUsage": 0.0,
159 "waitingFor": {"lock": "<0x1a2b> (a com.inductiveautomation...)"}
160 }
161 ]
162 });
163 let dump: FormattedThreadDump =
164 serde_json::from_value(body).expect("the spec shape must parse");
165 assert_eq!(dump.version, "dump-version-1");
166 assert_eq!(dump.threads.len(), 2);
167
168 let runnable = &dump.threads[0];
169 assert_eq!(runnable.name, "Perspective-Worker-3");
170 assert_eq!(runnable.id, Some(42));
171 assert_eq!(runnable.state, "RUNNABLE");
172 assert!(runnable.daemon);
173 assert_eq!(runnable.system, "gateway");
174 assert_eq!(runnable.scope, "web");
175 assert!((runnable.cpu_usage.unwrap() - 3.75).abs() < f64::EPSILON);
176 assert_eq!(runnable.stacktrace.len(), 2);
177 assert_eq!(
178 runnable.locked_monitors[0].lock.as_deref(),
179 Some("<0x1a2b> (a com.inductiveautomation...) ")
180 );
181 assert_eq!(
182 runnable.locked_monitors[0].frame.as_deref(),
183 Some("Worker.lock - line 88")
184 );
185
186 let blocked = &dump.threads[1];
187 assert_eq!(blocked.state, "BLOCKED");
188 assert!(!blocked.daemon, "absent daemon defaults false");
189 assert_eq!(
190 blocked
191 .waiting_for
192 .as_ref()
193 .and_then(|w| w.lock.clone())
194 .as_deref(),
195 Some("<0x1a2b> (a com.inductiveautomation...)"),
196 "the waiting-for lock parses through the nested object"
197 );
198 assert!(blocked.locked_monitors.is_empty());
199 assert!(blocked.stacktrace.is_empty(), "absent arrays default empty");
200 }
201
202 #[test]
207 fn thread_fields_are_all_tolerant() {
208 let dump: FormattedThreadDump = serde_json::from_value(serde_json::json!({
209 "threads": [{"name": "minimal"}],
210 "zzFutureKey": {"future": true}
211 }))
212 .expect("a name-only entry must parse");
213 let thread = &dump.threads[0];
214 assert_eq!(thread.name, "minimal");
215 assert_eq!(thread.id, None);
216 assert_eq!(thread.state, "");
217 assert!(!thread.daemon);
218 assert_eq!(thread.cpu_usage, None);
219 assert!(thread.waiting_for.is_none());
220 assert!(thread.stacktrace.is_empty());
221 assert!(thread.locked_monitors.is_empty());
222 assert!(
223 dump.extra.contains_key("zzFutureKey"),
224 "unknown keys round-trip"
225 );
226
227 let round = serde_json::to_value(thread).expect("serialize");
228 assert_eq!(
229 round["name"], "minimal",
230 "gateway-native key on the way out"
231 );
232 assert_eq!(
233 round["cpuUsage"],
234 serde_json::Value::Null,
235 "camelCase rename"
236 );
237 }
238
239 #[test]
242 fn deadlocks_wire_parses_the_id_list() {
243 let wire: DeadlocksWire = serde_json::from_value(serde_json::json!({"deadlocks": [7, 42]}))
244 .expect("the deadlocks shape must parse");
245 assert_eq!(wire.deadlocks, vec![7, 42]);
246
247 let healthy: DeadlocksWire = serde_json::from_value(serde_json::json!({}))
248 .expect("an absent list is the healthy answer");
249 assert!(healthy.deadlocks.is_empty());
250 }
251}