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