Skip to main content

poe2_agent/
pob_parser.rs

1//! Thread-safe PoB XML parser.
2//!
3//! Wraps `PobHeadless` on a dedicated OS thread (mlua LuaJIT is `!Send`)
4//! and communicates via channels.
5
6use std::path::Path;
7use std::sync::mpsc;
8use std::thread;
9
10use tokio::sync::oneshot;
11
12use crate::pob::PobHeadless;
13
14/// Which query to run against a loaded build.
15#[derive(Debug, Clone)]
16pub enum PobQuery {
17    /// Extended stats (~40 fields) grouped by category.
18    BuildStats,
19    /// Per-skill DPS + gem links.
20    SkillList,
21    /// Configuration flags.
22    Config,
23    /// Item equipped in the given slot.
24    Item(String),
25    /// Jewel socketed in the given passive tree socket node.
26    Jewel(i64),
27    /// Allocated passive tree nodes.
28    PassiveTree,
29    /// Stat contribution from allocated passives and nearby unallocated nodes.
30    PassiveStats { stat: String, radius: u32 },
31    /// All equipment slots: which are empty and which are filled.
32    EmptySlots,
33    /// Ascendancy nodes: allocated vs available for primary and secondary ascendancies.
34    UnallocatedAscendancy,
35}
36
37/// Request sent to the dedicated parser thread.
38enum PobRequest {
39    Parse {
40        xml: String,
41        reply: oneshot::Sender<Result<Vec<u8>, PobParseError>>,
42    },
43    Query {
44        xml: String,
45        query: PobQuery,
46        reply: oneshot::Sender<Result<serde_json::Value, PobParseError>>,
47    },
48}
49
50/// Errors from build parsing.
51#[derive(Debug, thiserror::Error)]
52pub enum PobParseError {
53    /// PoB couldn't parse the XML (bad data from the user).
54    #[error("invalid build: {0}")]
55    InvalidBuild(String),
56
57    /// The parser thread died or is unreachable.
58    #[error("parser unavailable")]
59    Unavailable,
60}
61
62/// Thread-safe handle to a `PobHeadless` instance running on a dedicated OS thread.
63///
64/// `mlua::Lua` with LuaJIT is `!Send`, so we keep it pinned to one thread and
65/// communicate via channels. This handle is `Send + Sync` and cheap to clone.
66pub struct PobParser {
67    sender: Option<mpsc::Sender<PobRequest>>,
68    _thread: Option<thread::JoinHandle<()>>,
69}
70
71impl PobParser {
72    /// Spawn the parser thread and initialize `PobHeadless`.
73    ///
74    /// Awaits until PoB is fully initialized. Returns an error if
75    /// initialization fails so the server can fail-fast at startup.
76    pub async fn new(pob_path: &Path) -> Result<Self, anyhow::Error> {
77        let (tx, rx) = mpsc::channel::<PobRequest>();
78        let (init_tx, init_rx) = oneshot::channel::<Result<(), String>>();
79
80        let pob_path_abs = pob_path
81            .canonicalize()
82            .map_err(|e| anyhow::anyhow!("pob_path {}: {e}", pob_path.display()))?;
83        let pob_path_str = pob_path_abs
84            .to_str()
85            .ok_or_else(|| anyhow::anyhow!("pob_path is not valid UTF-8"))?
86            .to_owned();
87
88        let handle = thread::spawn(move || {
89            run_parser_thread(&pob_path_str, init_tx, rx);
90        });
91
92        let init_result = init_rx
93            .await
94            .map_err(|_| anyhow::anyhow!("parser thread died during init"))?;
95
96        init_result.map_err(|e| anyhow::anyhow!("PobHeadless init failed: {e}"))?;
97
98        tracing::info!("PobParser ready");
99        Ok(Self {
100            sender: Some(tx),
101            _thread: Some(handle),
102        })
103    }
104
105    /// Parse a PoB XML export, returning the `BuildStats` as JSON bytes.
106    pub async fn parse(&self, xml: &[u8]) -> Result<Vec<u8>, PobParseError> {
107        let xml_str =
108            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
109
110        let (reply_tx, reply_rx) = oneshot::channel();
111
112        self.sender
113            .as_ref()
114            .ok_or(PobParseError::Unavailable)?
115            .send(PobRequest::Parse {
116                xml: xml_str.to_owned(),
117                reply: reply_tx,
118            })
119            .map_err(|_| PobParseError::Unavailable)?;
120
121        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
122    }
123
124    /// Run a query against a build. The build XML is loaded fresh each time
125    /// to avoid interleaving problems with concurrent callers.
126    pub async fn query(
127        &self,
128        xml: &[u8],
129        query: PobQuery,
130    ) -> Result<serde_json::Value, PobParseError> {
131        let xml_str =
132            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
133
134        let (reply_tx, reply_rx) = oneshot::channel();
135
136        self.sender
137            .as_ref()
138            .ok_or(PobParseError::Unavailable)?
139            .send(PobRequest::Query {
140                xml: xml_str.to_owned(),
141                query,
142                reply: reply_tx,
143            })
144            .map_err(|_| PobParseError::Unavailable)?;
145
146        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
147    }
148}
149
150impl Drop for PobParser {
151    fn drop(&mut self) {
152        // Drop sender first to close the channel so the thread's recv loop exits.
153        // Field auto-drop happens *after* drop() returns, so we must do this
154        // explicitly -- otherwise join() deadlocks waiting for a channel that
155        // won't close until after join() returns.
156        self.sender.take();
157        if let Some(handle) = self._thread.take() {
158            let _ = handle.join();
159        }
160    }
161}
162
163/// Entry point for the dedicated parser thread.
164fn run_parser_thread(
165    pob_path: &str,
166    init_tx: oneshot::Sender<Result<(), String>>,
167    rx: mpsc::Receiver<PobRequest>,
168) {
169    let mut pob = match PobHeadless::new() {
170        Ok(p) => p,
171        Err(e) => {
172            let _ = init_tx.send(Err(format!("failed to create Lua runtime: {e}")));
173            return;
174        }
175    };
176
177    if let Err(e) = pob.init(pob_path) {
178        let _ = init_tx.send(Err(e.to_string()));
179        return;
180    }
181
182    let _ = init_tx.send(Ok(()));
183
184    // Process requests until the channel is closed.
185    for req in &rx {
186        match req {
187            PobRequest::Parse { xml, reply } => {
188                let result = parse_one(&pob, &xml);
189                let _ = reply.send(result);
190            }
191            PobRequest::Query { xml, query, reply } => {
192                let result = load_and_query(&pob, &xml, &query);
193                let _ = reply.send(result);
194            }
195        }
196    }
197
198    tracing::info!("parser thread shutting down");
199}
200
201/// Execute a single parse: load XML -> calculate -> serialize.
202fn parse_one(pob: &PobHeadless, xml: &str) -> Result<Vec<u8>, PobParseError> {
203    pob.load_build_xml(xml)
204        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
205
206    let stats = pob
207        .calculate()
208        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
209
210    serde_json::to_vec(&stats).map_err(|e| PobParseError::InvalidBuild(e.to_string()))
211}
212
213/// Load a build and run a query against it.
214fn load_and_query(
215    pob: &PobHeadless,
216    xml: &str,
217    query: &PobQuery,
218) -> Result<serde_json::Value, PobParseError> {
219    pob.load_build_xml(xml)
220        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
221
222    let result = match query {
223        PobQuery::BuildStats => pob.query_build_stats(),
224        PobQuery::SkillList => pob.query_skill_list(),
225        PobQuery::Config => pob.query_config(),
226        PobQuery::Item(ref slot) => pob.query_item(slot),
227        PobQuery::Jewel(node_id) => pob.query_jewel(*node_id),
228        PobQuery::PassiveTree => pob.query_passive_tree(),
229        PobQuery::PassiveStats { ref stat, radius } => pob.query_passive_stats(stat, *radius),
230        PobQuery::EmptySlots => pob.query_empty_slots(),
231        PobQuery::UnallocatedAscendancy => pob.query_unallocated_ascendancy(),
232    };
233
234    result.map_err(|e| PobParseError::InvalidBuild(e.to_string()))
235}