main
Drew Owens 0.10.5 source 1dc9df6 7d ago
//! The HTTP bridge server — what ab spawns and talks to.
//!
//! Contract (Adom Bridge SDK):
//!   * `--port N` off argv; bind `ADOM_BIND_HOST` (127.0.0.1), never 0.0.0.0 —
//!     a wildcard bind pops the Windows firewall dialog and exposes the bridge
//!     off-box;
//!   * bind FIRST, before anything slow. A failed bind means another instance
//!     already owns the port, so we say so and exit 0 — that is the whole
//!     single-instance guard, and it only works if nothing enables address
//!     reuse (nothing here does);
//!   * `GET /status` and `/health` → 200 JSON with the led/summary/tooltip the
//!     status chip renders verbatim;
//!   * `POST /command` → the verb result, relayed to the caller as-is;
//!   * anything else → 404.
//!
//! Requests are served on a small fixed pool of threads rather than one per
//! connection: COM work serializes on the com host anyway, but `swx_progress`
//! must still be able to answer WHILE a 90-second interference check is in
//! flight — that is the entire reason for more than one worker.

use crate::verbs::{self, Ctx};
use serde_json::{json, Value};
use std::io::Read;
use std::net::TcpListener;
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};

/// Fallback when nothing passes `--port` (ab always does; bridge.json declares
/// `"port": 0`, so ab picks a free one and tells us).
pub const DEFAULT_PORT: u16 = 24695;

const WORKERS: usize = 4;

pub fn run(port: Option<u16>) {
    let port = port.unwrap_or(DEFAULT_PORT);
    let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());

    println!(
        "[swx] starting v{} on port {port} (pid {}, mock={})",
        crate::BRIDGE_VERSION,
        std::process::id(),
        crate::is_mock()
    );

    // Bind before doing anything slow: a duplicate instance must fail here and
    // exit cleanly rather than race the real one.
    let listener = match TcpListener::bind((host.as_str(), port)) {
        Ok(l) => l,
        Err(e) => {
            println!(
                "[swx] port {port} already in use ({e}) — another instance owns it; exiting cleanly."
            );
            return;
        }
    };
    let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
    let server = match Server::from_listener(listener, None) {
        Ok(s) => Arc::new(s),
        Err(e) => {
            eprintln!("[swx] failed to start HTTP server: {e}");
            return;
        }
    };

    // MOVE THE DATA OUT OF ab's DIRECTORIES, BEFORE ANYTHING READS OR WRITES IT.
    // The legacy roots are 0.8.x's install cache, which is re-extracted on every
    // update (measured 2026-09-30: every 09-17 recovery folder was wiped by the
    // 0.8.9 auto-install of 2026-09-18), and 0.9.0's `Adom Bridge\solidworks-data`,
    // inside the directory ab's installer owns. It runs AFTER the bind, so a
    // duplicate instance that is about to exit never touches it, and BEFORE
    // `Ctx::new`, which restores the ownership registry from the data root — a
    // registry read before the migration would start empty, and one written
    // before it would make the migration copy the real one BESIDE it rather than
    // into place.
    if let Some(report) = crate::scratch::migrate_legacy() {
        let count = |k: &str| report[k].as_array().map(|a| a.len()).unwrap_or(0);
        println!(
            "[swx] migrated {} file(s) from older bridges' locations into {} ({} changed since \
             an earlier copy; {} merged into the live file with both versions kept beside it; {} \
             failed verification and were NOT copied; originals left in place)",
            count("copied"),
            crate::scratch::root().display(),
            count("changedSinceCopy"),
            count("merged"),
            count("failed"),
        );
        // A MERGE IS ITS OWN LINE IN THE LEDGER too: the live ledger or registry
        // was rewritten as the union of two versions, and both versions are kept
        // beside it under the names this records.
        if count("merged") > 0 {
            crate::ledger::record(
                "legacy_files_merged",
                serde_json::json!({
                    "merges": report["merged"].clone(),
                    "why": "two versions of the ledger or the ownership registry — from the two \
                            older locations, or one that changed after its copy — were MERGED: \
                            ledger lines united and ordered by `at`, registry claims united. A \
                            rule that keeps one version drops the other's history.",
                }),
            );
        }
        // A CHANGE IS ITS OWN LINE IN THE LEDGER, so the record of "something
        // wrote to an older location after we copied it" can be found without
        // reading every migration report.
        if count("changedSinceCopy") > 0 {
            crate::ledger::record(
                "legacy_file_changed_since_copy",
                serde_json::json!({
                    "files": report["changedSinceCopy"].clone(),
                    "why": "a file in an older bridge's location changed after it was copied \
                            into the data root — a rollback's ledger entries, a rewritten \
                            registry — so it was copied again, beside the first copy, never \
                            over it",
                }),
            );
        }
        crate::ledger::record("data_migrated_from_install_cache", report);
    }
    // AND SAY SO IF THE ROOT FELL BACK TO THE TEMP DIRECTORY — every sweep refuses
    // there (`data_root_is_temp`), and this is the line that explains why.
    if crate::scratch::root_is_temp_fallback() {
        println!(
            "[swx] WARNING data root {} is in the OS temp directory (neither LOCALAPPDATA nor \
             USERPROFILE is set), which Windows empties on a schedule: every safe-close sweep \
             will refuse with data_root_is_temp rather than put the only copy of unsaved work \
             there.",
            crate::scratch::root().display()
        );
    }
    // AND SAY SO IF AN OVERRIDE PUT RECOVERY BACK INSIDE AN INSTALL LOCATION.
    // Every sweep refuses to write there (`recovery_inside_install_dir`); this is
    // the line in the log that explains why before anybody asks.
    if let Err(why) =
        crate::scratch::refuse_inside_install(&crate::scratch::root().join("recovery"))
    {
        println!(
            "[swx] WARNING recovery is inside an install location, so every safe-close sweep \
             will refuse: {why}"
        );
    }

    // Detection AFTER the bind — it touches the registry and shells out to
    // tasklist, which is exactly the kind of slow the port must not wait on.
    let detection = crate::detect::detect();
    println!(
        "[swx] detection: installed={} exe={:?} running={}",
        detection.installed, detection.exe, detection.running
    );
    let ctx = Arc::new(Ctx::new(detection));
    crate::scratch::prune();

    let (data_root, source) = crate::scratch::root_and_source();
    println!("[swx] data root {} (from {})", data_root.display(), source.name());
    println!("[swx] listening on http://{host}:{bound} — /status and /command");

    let mut workers = Vec::new();
    for i in 0..WORKERS {
        let server = Arc::clone(&server);
        let ctx = Arc::clone(&ctx);
        workers.push(
            std::thread::Builder::new()
                .name(format!("swx-http-{i}"))
                .spawn(move || loop {
                    match server.recv() {
                        // A handler panic must cost ONE request, never the worker
                        // thread — otherwise WORKERS panics kill every worker and
                        // the process ab supervises silently dies. The inner
                        // dispatch is also panic-caught (so the caller gets JSON);
                        // this outer guard covers a panic anywhere else in handle
                        // (header parsing, the response write) where `req` is gone
                        // and tiny_http closes the connection on its own.
                        Ok(req) => {
                            let ctx = Arc::clone(&ctx);
                            if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                                handle(&ctx, req)
                            }))
                            .is_err()
                            {
                                crate::caller::clear();
                                eprintln!("[swx] worker recovered from a panic; still serving");
                            }
                        }
                        Err(e) => {
                            eprintln!("[swx] accept failed: {e}");
                            return;
                        }
                    }
                })
                .expect("spawn worker"),
        );
    }
    for w in workers {
        let _ = w.join();
    }
    ctx.com.shutdown();
}

/// The health payload. `led`/`summary`/`tooltip` are rendered verbatim on ab's
/// status chip — yellow (not red) with SOLIDWORKS absent, because the BRIDGE is
/// fine and the host app is simply not there yet.
pub fn status_payload(ctx: &Ctx) -> Value {
    let d = ctx.detection();
    let (led, summary, tooltip) = if crate::is_mock() {
        (
            "yellow",
            "mock mode (no SOLIDWORKS)".to_string(),
            "SOLIDWORKS bridge is serving the MOCK backend — for development only. Verbs answer \
             with canned data; nothing touches a real document."
                .to_string(),
        )
    } else if d.installed {
        (
            "green",
            "SOLIDWORKS ready".to_string(),
            format!("SOLIDWORKS bridge up — {}", d.exe.clone().unwrap_or_default()),
        )
    } else {
        (
            "yellow",
            "SOLIDWORKS not installed".to_string(),
            "Bridge up; SOLIDWORKS absent. A 30-day trial serial from Hawk Ridge Systems is \
             pending — install once it arrives. Detection and file-search verbs work today."
                .to_string(),
        )
    };
    json!({
        "status": "ok",
        "led": led,
        "summary": summary,
        "tooltip": tooltip,
        "bridgeVersion": crate::BRIDGE_VERSION,
        "solidworks": d.to_json(),
        "mock": crate::is_mock(),
    })
}

fn handle(ctx: &Arc<Ctx>, mut req: Request) {
    let method = req.method().clone();
    let url = req.url().to_string();
    match (&method, url.as_str()) {
        (Method::Get, "/status") | (Method::Get, "/health") => {
            respond(req, 200, &status_payload(ctx));
        }
        (Method::Post, "/command") => {
            // Read the caller headers before the body borrow starts.
            let headers: Vec<(String, String)> = req
                .headers()
                .iter()
                .map(|h| (h.field.as_str().as_str().to_string(), h.value.as_str().to_string()))
                .collect();
            // Cap the body: a bridge on loopback never needs megabytes of args,
            // and an unbounded read is a trivial memory-exhaustion vector.
            const MAX_BODY: u64 = 8 * 1024 * 1024;
            let mut raw = String::new();
            if let Err(e) = req.as_reader().take(MAX_BODY + 1).read_to_string(&mut raw) {
                respond(req, 400, &bad_request(format!("read body: {e}")));
                return;
            }
            if raw.len() as u64 > MAX_BODY {
                respond(
                    req,
                    413,
                    &bad_request(format!("request body exceeds the {MAX_BODY}-byte cap")),
                );
                return;
            }
            if raw.trim().is_empty() {
                raw = "{}".to_string();
            }
            let envelope: Value = match serde_json::from_str(&raw) {
                Ok(v) => v,
                Err(e) => {
                    respond(req, 400, &bad_request(format!("Invalid JSON: {e}")));
                    return;
                }
            };
            let command =
                envelope.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let args = envelope.get("args").cloned().unwrap_or_else(|| json!({}));

            crate::caller::set_from_request(
                |name| {
                    headers
                        .iter()
                        .find(|(k, _)| k.eq_ignore_ascii_case(name))
                        .map(|(_, v)| v.clone())
                },
                &args,
            );
            // ONE line per incoming verb — never per item inside a loop, never a
            // parallel log file. ab captures stdout; this is the whole log.
            let mut args_str = args.to_string();
            if args_str.len() > 400 {
                // Truncate on a char boundary — a Windows path with a non-ASCII
                // folder name must not panic the logger.
                let mut cut = 400;
                while cut > 0 && !args_str.is_char_boundary(cut) {
                    cut -= 1;
                }
                args_str.truncate(cut);
                args_str.push('…');
            }
            // ...with the memory sample (0.10.5): commit available and
            // SOLIDWORKS's own share, so a long run's growth reads off the log.
            println!("[swx] {command}{} | args: {args_str}{}", crate::caller::log_suffix(), crate::memory::log_suffix());

            // A verb panic becomes a JSON error the caller can read (and report),
            // not a dropped connection or a dead worker.
            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                verbs::dispatch(ctx, &command, &args)
            }))
            .unwrap_or_else(|panic| {
                let what = panic
                    .downcast_ref::<&str>()
                    .map(|s| s.to_string())
                    .or_else(|| panic.downcast_ref::<String>().cloned())
                    .unwrap_or_else(|| "unknown panic".into());
                eprintln!("[swx] verb '{command}' panicked: {what}");
                json!({
                    "success": false,
                    "error": format!("the bridge panicked handling '{command}': {what}"),
                    "_hint": "This is a bug in the bridge — the worker recovered and is still \
                              serving. Retry, and please report it.",
                    "_reportIssues": crate::verbs::report_issues(),
                })
            });
            crate::caller::clear();
            respond(req, 200, &result);
        }
        _ => {
            respond(req, 404, &json!({"error": format!("{method} {url} not found")}));
        }
    }
}

/// A 400/413 body with the same _hint/_reportIssues courtesy every verb failure
/// gets — a malformed request should still tell the caller how to proceed.
fn bad_request(error: String) -> Value {
    json!({
        "success": false,
        "error": error,
        "_hint": "POST /command with a JSON body {\"command\":\"swx_...\",\"args\":{...}}. \
                  swx_describe lists the verbs; GET /status is the health check.",
        "_reportIssues": crate::verbs::report_issues(),
    })
}

fn respond(req: Request, status: u16, body: &Value) {
    let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
    let header = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
        .expect("static header parses");
    let response =
        Response::from_data(bytes).with_status_code(StatusCode(status)).with_header(header);
    if let Err(e) = req.respond(response) {
        eprintln!("[swx] respond failed: {e}");
    }
}