poe2-agent 0.1.0

AI agent for Path of Exile 2 build analysis
Documentation
//! Thread-safe PoB XML parser.
//!
//! Wraps `PobHeadless` on a dedicated OS thread (mlua LuaJIT is `!Send`)
//! and communicates via channels.

use std::path::Path;
use std::sync::mpsc;
use std::thread;

use tokio::sync::oneshot;

use crate::pob::PobHeadless;

/// Request sent to the dedicated parser thread.
struct ParseRequest {
    xml: String,
    reply: oneshot::Sender<Result<Vec<u8>, PobParseError>>,
}

/// Errors from build parsing.
#[derive(Debug, thiserror::Error)]
pub enum PobParseError {
    /// PoB couldn't parse the XML (bad data from the user).
    #[error("invalid build: {0}")]
    InvalidBuild(String),

    /// The parser thread died or is unreachable.
    #[error("parser unavailable")]
    Unavailable,
}

/// Thread-safe handle to a `PobHeadless` instance running on a dedicated OS thread.
///
/// `mlua::Lua` with LuaJIT is `!Send`, so we keep it pinned to one thread and
/// communicate via channels. This handle is `Send + Sync` and cheap to clone.
pub struct PobParser {
    sender: Option<mpsc::Sender<ParseRequest>>,
    _thread: Option<thread::JoinHandle<()>>,
}

impl PobParser {
    /// Spawn the parser thread and initialize `PobHeadless`.
    ///
    /// Awaits until PoB is fully initialized. Returns an error if
    /// initialization fails so the server can fail-fast at startup.
    pub async fn new(pob_path: &Path) -> Result<Self, anyhow::Error> {
        let (tx, rx) = mpsc::channel::<ParseRequest>();
        let (init_tx, init_rx) = oneshot::channel::<Result<(), String>>();

        let pob_path_abs = pob_path
            .canonicalize()
            .map_err(|e| anyhow::anyhow!("pob_path {}: {e}", pob_path.display()))?;
        let pob_path_str = pob_path_abs
            .to_str()
            .ok_or_else(|| anyhow::anyhow!("pob_path is not valid UTF-8"))?
            .to_owned();

        let handle = thread::spawn(move || {
            run_parser_thread(&pob_path_str, init_tx, rx);
        });

        let init_result = init_rx
            .await
            .map_err(|_| anyhow::anyhow!("parser thread died during init"))?;

        init_result.map_err(|e| anyhow::anyhow!("PobHeadless init failed: {e}"))?;

        tracing::info!("PobParser ready");
        Ok(Self {
            sender: Some(tx),
            _thread: Some(handle),
        })
    }

    /// Parse a PoB XML export, returning the `BuildStats` as JSON bytes.
    pub async fn parse(&self, xml: &[u8]) -> Result<Vec<u8>, PobParseError> {
        let xml_str =
            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

        let (reply_tx, reply_rx) = oneshot::channel();

        self.sender
            .as_ref()
            .ok_or(PobParseError::Unavailable)?
            .send(ParseRequest {
                xml: xml_str.to_owned(),
                reply: reply_tx,
            })
            .map_err(|_| PobParseError::Unavailable)?;

        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
    }
}

impl Drop for PobParser {
    fn drop(&mut self) {
        // Drop sender first to close the channel so the thread's recv loop exits.
        // Field auto-drop happens *after* drop() returns, so we must do this
        // explicitly -- otherwise join() deadlocks waiting for a channel that
        // won't close until after join() returns.
        self.sender.take();
        if let Some(handle) = self._thread.take() {
            let _ = handle.join();
        }
    }
}

/// Entry point for the dedicated parser thread.
fn run_parser_thread(
    pob_path: &str,
    init_tx: oneshot::Sender<Result<(), String>>,
    rx: mpsc::Receiver<ParseRequest>,
) {
    let mut pob = match PobHeadless::new() {
        Ok(p) => p,
        Err(e) => {
            let _ = init_tx.send(Err(format!("failed to create Lua runtime: {e}")));
            return;
        }
    };

    if let Err(e) = pob.init(pob_path) {
        let _ = init_tx.send(Err(e.to_string()));
        return;
    }

    let _ = init_tx.send(Ok(()));

    // Process requests until the channel is closed.
    for req in &rx {
        let result = parse_one(&pob, &req.xml);
        let _ = req.reply.send(result);
    }

    tracing::info!("parser thread shutting down");
}

/// Execute a single parse: load XML -> calculate -> serialize.
fn parse_one(pob: &PobHeadless, xml: &str) -> Result<Vec<u8>, PobParseError> {
    pob.load_build_xml(xml)
        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

    let stats = pob
        .calculate()
        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

    serde_json::to_vec(&stats).map_err(|e| PobParseError::InvalidBuild(e.to_string()))
}