Skip to main content

ignition_core/client/
threads.rs

1//! Thread-diagnostics capability models — the verified
2//! `/data/api/v1/diagnostics/threads/` endpoints (the Gateway web UI's
3//! Status → Diagnostics → Threads page, wire-shaped from the live
4//! 8.3.3 gateway's own openapi spec, `/tmp/itop-live/spec.json`).
5//!
6//! **Tolerance is the contract here**: the spec's formatted-dump schema
7//! carries the caveat "Not all fields in response shape are
8//! guaranteed", so EVERY field defaults or rides an `Option` — a
9//! future/trimmed entry must parse, never refuse (the Pitfall-2 rule
10//! applied to a diagnostic snapshot). Partial data renders honestly;
11//! a parse refusal is the only unrecoverable shape.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17/// GET path of the formatted thread dump (the legacy-compat shape the
18/// web UI renders — one entry per JVM thread with cpu usage, stack
19/// trace, and monitor locks).
20pub(crate) const THREAD_DUMP_FORMATTED_PATH: &str =
21    "/data/api/v1/diagnostics/threads/dump/formatted";
22
23/// GET path of the deadlocked-thread id list (the JVM's own deadlock
24/// detection, ids matching [`ThreadInfo::id`]).
25pub(crate) const THREAD_DEADLOCKS_PATH: &str = "/data/api/v1/diagnostics/threads/deadlocks";
26
27/// GET `/data/api/v1/diagnostics/threads/dump/formatted` — the whole
28/// dump: a VM version string and one entry per thread.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct FormattedThreadDump {
31    /// The dump format / VM version string (verbatim passthrough).
32    #[serde(default)]
33    pub version: String,
34    /// One entry per JVM thread (order: the gateway's — the itop view
35    /// applies its own sort).
36    #[serde(default)]
37    pub threads: Vec<ThreadInfo>,
38    /// Unknown keys round-trip (version tolerance).
39    #[serde(flatten)]
40    pub extra: BTreeMap<String, serde_json::Value>,
41}
42
43/// One JVM thread from the formatted dump. Every field tolerates
44/// absence (spec caveat: "not all fields guaranteed").
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct ThreadInfo {
47    /// The thread's Java name (e.g. `Perspective-Worker-3`).
48    #[serde(default)]
49    pub name: String,
50    /// The JVM thread id (matches the deadlocks list's ids).
51    #[serde(default)]
52    pub id: Option<i64>,
53    /// `java.lang.Thread.State` verbatim (`RUNNABLE`, `WAITING`, …).
54    #[serde(default)]
55    pub state: String,
56    /// Daemon flag (`false` when absent).
57    #[serde(default)]
58    pub daemon: bool,
59    /// System-thread marker string, verbatim (empty when absent).
60    #[serde(default)]
61    pub system: String,
62    /// Scope string, verbatim (empty when absent).
63    #[serde(default)]
64    pub scope: String,
65    /// CPU usage — PERCENT scale per the web UI's column (absent when
66    /// the gateway doesn't report it).
67    #[serde(rename = "cpuUsage", default)]
68    pub cpu_usage: Option<f64>,
69    /// The monitor the thread is waiting to acquire, when blocked
70    /// (`waitingFor` — the gateway's verbatim camel key).
71    #[serde(rename = "waitingFor", default)]
72    pub waiting_for: Option<WaitingFor>,
73    /// The thread's stack trace, top frame first.
74    #[serde(default)]
75    pub stacktrace: Vec<String>,
76    /// Monitors this thread HOLDS with the frame that locked them —
77    /// the contention evidence the web UI shows as "locks X"
78    /// (`lockedMonitors` — the gateway's verbatim camel key).
79    #[serde(rename = "lockedMonitors", default)]
80    pub locked_monitors: Vec<LockedMonitor>,
81    /// Unknown keys round-trip (version tolerance).
82    #[serde(flatten)]
83    pub extra: BTreeMap<String, serde_json::Value>,
84}
85
86/// The monitor a blocked thread is waiting to acquire.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct WaitingFor {
89    /// The lock's identifying string (verbatim).
90    #[serde(default)]
91    pub lock: Option<String>,
92    /// Unknown keys round-trip.
93    #[serde(flatten)]
94    pub extra: BTreeMap<String, serde_json::Value>,
95}
96
97/// A monitor the thread HOLDS, with the frame that locked it.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct LockedMonitor {
100    /// The lock's identifying string (verbatim).
101    #[serde(default)]
102    pub lock: Option<String>,
103    /// The stack frame that acquired the lock.
104    #[serde(default)]
105    pub frame: Option<String>,
106    /// Unknown keys round-trip.
107    #[serde(flatten)]
108    pub extra: BTreeMap<String, serde_json::Value>,
109}
110
111/// GET `/data/api/v1/diagnostics/threads/deadlocks` — the wire body is
112/// `{"deadlocks": [id, …]}`; the ids match [`ThreadInfo::id`].
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DeadlocksWire {
115    /// Deadlocked thread ids (empty when the JVM reports none — the
116    /// healthy answer, not an error).
117    #[serde(default)]
118    pub deadlocks: Vec<i64>,
119    /// Unknown keys round-trip (version tolerance).
120    #[serde(flatten)]
121    pub extra: BTreeMap<String, serde_json::Value>,
122}
123
124#[cfg(test)]
125mod tests {
126    use super::{DeadlocksWire, FormattedThreadDump};
127
128    /// The spec's schema shape, exercised field by field: camelCase
129    /// `cpuUsage`, the nested `waitingFor.lock`, the `stacktrace` and
130    /// `lockedMonitors` arrays.
131    #[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    /// The spec caveat ("not all fields are guaranteed") is honored:
203    /// an entry of NOTHING but a name parses, every absent field takes
204    /// its honest default, and unknown keys ride the flatten
205    /// passthrough.
206    #[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    /// The deadlocks body: `{"deadlocks": [id, …]}` — ids match the
240    /// dump's `id` field, and an EMPTY list is the healthy answer.
241    #[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}