main
Drew Owens 0.6.9 source: launch resilience, honest counts, ownership persistence, drain, quit ladder — 21 adversary passes, 303 te… cf16621 1mo ago
//! THE SERVER'S OWNERSHIP REGISTRY, ON DISK — bug #5.
//!
//! The bridge refuses to write to any document it did not create. That refusal
//! is only as good as its memory of what it created, and until now that memory
//! lived in one process's RAM. Two things emptied it, both observed while
//! building a real crate on 2026-09-03:
//!
//!   * **A bridge restart.** `swx_close_doc` refused all 23 open crate
//!     documents — *"which this bridge did not create"* — because the server had
//!     been restarted mid-build. The documents were the bridge's own.
//!   * **A com-host respawn during a long call.** `app_state` came back as an
//!     ERROR, the caller read that error as "no session, no documents open",
//!     and the prune that follows dropped every entry. Ninety seconds after
//!     creating `Assem27`, the bridge could not save it.
//!
//! Both are the same defect in two places: an unanswerable question answered
//! definitely. So this file does two things. It PERSISTS the registry, keyed by
//! the SOLIDWORKS session (its process id) and, where the OS can tell us, that
//! process's START TIME — so a recycled pid can never inherit another session's
//! claims. And [`crate::verbs::Ctx::prune_scratch`] is now a no-op when the
//! session is unknown, because "I could not read the session" must not mean
//! "forget everything I own".
//!
//! Written and read by the SERVER only. The com host keeps its own independent
//! record in `com-host-created.json` — two vetoes, two files, neither derived
//! from the other. `swx_uninstall` removes both.

use crate::verbs::ScratchDoc;
use serde_json::{json, Value};

pub fn path() -> std::path::PathBuf {
    crate::scratch::root().join("owned-docs.json")
}

/// A stored entry: what we own, in which session, and when that session's
/// process started.
#[derive(Clone, Debug, PartialEq)]
pub struct Owned {
    pub doc: ScratchDoc,
    /// The SOLIDWORKS process's creation time, when the OS could tell us.
    /// `None` on a host that cannot answer (the mock, or a locked-down box),
    /// where the pid alone has to do — which is what the in-memory version
    /// always relied on anyway.
    pub session_started_at: Option<u64>,
}

/// How long a looked-up start time is reused. A process's creation time never
/// changes, so this is only about pid REUSE — and a pid coming round again
/// within ten seconds of a SOLIDWORKS process ending is not a thing that
/// happens. Without the cache, every document-addressing verb would take a full
/// process census (the registry is rewritten on every prune), which on a busy
/// build is thousands of enumerations for an answer that cannot change.
const START_TIME_TTL_SECS: u64 = 10;

static START_CACHE: std::sync::Mutex<Option<(i64, Option<u64>, u64)>> = std::sync::Mutex::new(None);

/// The SOLIDWORKS process's start time, so a recycled pid cannot inherit our
/// claims. `None` when the host cannot say — never a fabricated zero.
pub fn session_started_at(session: Option<i64>) -> Option<u64> {
    let pid_i64 = session?;
    let pid = u32::try_from(pid_i64).ok()?;
    let now = crate::launchos::epoch_secs();
    {
        let cache = START_CACHE.lock().unwrap_or_else(|e| e.into_inner());
        if let Some((cached_pid, value, at)) = *cache {
            if cached_pid == pid_i64 && now.saturating_sub(at) < START_TIME_TTL_SECS {
                return value;
            }
        }
    }
    let found = crate::launchos::os()
        .processes()
        .ok()
        .and_then(|c| c.into_iter().find(|p| p.pid == pid && p.is("SLDWORKS.exe")))
        .and_then(|p| p.created_at);
    *START_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some((pid_i64, found, now));
    found
}

/// What reading the registry produced. ABSENT and UNPARSEABLE are different
/// facts and must not share an answer: the first is an ordinary first run, the
/// second is the bridge silently losing every claim it holds — bug #5's end
/// state reached by a different road.
#[derive(Debug)]
pub enum Registry {
    /// No file. A first run, or a session that ended cleanly.
    Absent,
    Loaded(Vec<Owned>),
    /// The file is there and could not be read. The bridge owns nothing, and
    /// SAYS SO — loudly, once, at startup.
    Unreadable(String),
}

impl Registry {
    pub fn docs(self) -> Vec<Owned> {
        match self {
            Registry::Loaded(v) => v,
            _ => Vec::new(),
        }
    }
}

/// Read the registry back.
pub fn load() -> Registry {
    load_from(&path())
}

pub fn load_from(p: &std::path::Path) -> Registry {
    let text = match std::fs::read_to_string(p) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Registry::Absent,
        Err(e) => return Registry::Unreadable(format!("{p:?} could not be read: {e}")),
    };
    let v: Value = match serde_json::from_str(&text) {
        Ok(v) => v,
        Err(e) => {
            // Keep the evidence. A truncated registry is the visible end of a
            // write that was interrupted, and the next question is always
            // "interrupted by what?".
            let _ = std::fs::copy(p, p.with_extension("json.bad"));
            return Registry::Unreadable(format!(
                "{} is not valid JSON ({e}) — most likely a write that was interrupted. It has \
                 been kept as owned-docs.json.bad.",
                p.display()
            ));
        }
    };
    Registry::Loaded(load_docs(&v))
}

fn load_docs(v: &Value) -> Vec<Owned> {
    v.get("documents")
        .and_then(|d| d.as_array())
        .map(|rows| {
            rows.iter()
                .filter_map(|r| {
                    let title = r.get("title")?.as_str()?.to_string();
                    if title.is_empty() {
                        return None;
                    }
                    Some(Owned {
                        doc: ScratchDoc {
                            title,
                            session: r.get("session").and_then(|s| s.as_i64()),
                            path: r
                                .get("path")
                                .and_then(|x| x.as_str())
                                .filter(|s| !s.is_empty())
                                .map(String::from),
                        },
                        session_started_at: r.get("sessionStartedAt").and_then(|s| s.as_u64()),
                    })
                })
                .collect()
        })
        .unwrap_or_default()
}

/// Read, reconcile, and SAY what happened. The one entry point the server uses.
pub fn restore() -> Vec<ScratchDoc> {
    match load() {
        Registry::Absent => Vec::new(),
        Registry::Unreadable(why) => {
            // FALL BACK TO THE PREVIOUS GENERATION. `write_atomic` keeps one,
            // and it is a strictly better answer than owning nothing: the
            // .bak is a registry this bridge itself wrote, one write ago, so at
            // worst it is missing the most recent claim — while owning nothing
            // refuses every document the bridge created before the restart.
            eprintln!(
                "[swx] OWNERSHIP REGISTRY UNREADABLE — {why} Falling back to the previous \
                 generation (owned-docs.bak)."
            );
            let bak = path().with_extension("bak");
            match load_from(&bak) {
                Registry::Loaded(rows) if !rows.is_empty() => {
                    let kept: Vec<ScratchDoc> =
                        reconcile(rows, |pid| session_started_at(Some(pid))).into_iter().map(|o| o.doc).collect();
                    println!(
                        "[swx] ownership registry recovered from owned-docs.bak: {} document(s) — \
                         the most recent claim may be missing",
                        kept.len()
                    );
                    kept
                }
                _ => {
                    eprintln!(
                        "[swx] ...and there is no usable backup either. This bridge is starting \
                         up owning NOTHING and will refuse to write to documents it created \
                         before the restart."
                    );
                    println!("[swx] ownership registry could not be read; starting with no claims");
                    Vec::new()
                }
            }
        }
        Registry::Loaded(rows) => {
            let kept: Vec<ScratchDoc> =
                reconcile(rows, |pid| session_started_at(Some(pid))).into_iter().map(|o| o.doc).collect();
            if !kept.is_empty() {
                println!("[swx] ownership registry restored: {} document(s)", kept.len());
            }
            kept
        }
    }
}

/// Drop entries whose SOLIDWORKS session cannot still be the one we recorded.
///
/// The pid alone is not an identity — Windows reissues them. When BOTH the
/// stored start time and the live one are known and they differ, that pid is a
/// different process now and its documents are somebody else's. When either is
/// unknown we keep the entry and let [`crate::verbs::Ctx::owns_doc`]'s
/// session check decide, exactly as before: this guard can only ever REMOVE a
/// claim, never add one.
pub fn reconcile(stored: Vec<Owned>, live: impl Fn(i64) -> Option<u64>) -> Vec<Owned> {
    stored
        .into_iter()
        .filter(|o| match (o.doc.session, o.session_started_at) {
            (Some(pid), Some(was)) => match live(pid) {
                Some(now) => now == was,
                None => true,
            },
            _ => true,
        })
        .collect()
}

/// Persist the whole registry. Best effort — a bridge must never fail a real
/// verb because it could not write its own notes — but the write is a full
/// replacement, so a crash mid-write cannot leave a half-registry that grants
/// ownership of something we do not own.
pub fn save(docs: &[ScratchDoc], started_at: impl Fn(Option<i64>) -> Option<u64>) {
    save_to(&path(), docs, started_at)
}

pub fn save_to(
    p: &std::path::Path,
    docs: &[ScratchDoc],
    started_at: impl Fn(Option<i64>) -> Option<u64>,
) {
    let rows: Vec<Value> = docs
        .iter()
        .map(|d| {
            json!({
                "title": d.title,
                "session": d.session,
                "path": d.path,
                "sessionStartedAt": started_at(d.session),
            })
        })
        .collect();
    // ATOMIC. A plain `fs::write` truncates the file and then fills it, and this
    // is written on every remember, forget, rename and prune — hundreds of times
    // in one assembly build. A kill in that window leaves a truncated registry,
    // and the bridge then starts up owning nothing.
    if let Err(e) = crate::launch::write_atomic(p, &json!({"documents": rows}).to_string()) {
        eprintln!(
            "[swx] OWNERSHIP REGISTRY NOT PERSISTED ({e}). The in-memory record is intact for \
             this process, but a restart would start owning nothing."
        );
    }
}

/// The SOLIDWORKS session ended: its untitled numbering restarts, so no
/// remembered title can be trusted and the file goes.
pub fn clear() {
    let _ = std::fs::remove_file(path());
}

#[cfg(test)]
mod tests {
    use super::*;

    fn doc(title: &str, session: Option<i64>, path: Option<&str>) -> ScratchDoc {
        ScratchDoc { title: title.into(), session, path: path.map(String::from) }
    }

    #[test]
    fn the_registry_survives_a_round_trip_through_the_file() {
        let dir = std::env::temp_dir().join(format!("swx-owned-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let p = dir.join("owned-docs.json");

        let docs = vec![doc("Assem27", Some(88556), None), doc("R4.SLDPRT", Some(88556), Some("C:\\p\\R4.sldprt"))];
        save_to(&p, &docs, |_| Some(1_757_000_000));
        let back = load_from(&p).docs();
        assert_eq!(back.len(), 2);
        assert_eq!(back[0].doc, docs[0], "an unsaved document keeps its title AND its session");
        assert_eq!(back[1].doc.path.as_deref(), Some("C:\\p\\R4.sldprt"));
        assert_eq!(back[1].session_started_at, Some(1_757_000_000));

        // An empty title is not a document, and a corrupt file is an empty
        // registry rather than a panic or a claim.
        std::fs::write(&p, r#"{"documents":[{"title":"","session":1}]}"#).unwrap();
        assert!(load_from(&p).docs().is_empty(), "an empty title is not a document");
        // A CORRUPT file is not an empty registry — it is a reported failure.
        std::fs::write(&p, "not json at all").unwrap();
        assert!(
            matches!(load_from(&p), Registry::Unreadable(_)),
            "a truncated registry must be distinguishable from a first run"
        );
        assert!(p.with_extension("json.bad").exists(), "the evidence is kept");
        std::fs::remove_file(&p).unwrap();
        assert!(matches!(load_from(&p), Registry::Absent), "...and both from an absent one");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_recycled_pid_does_not_inherit_the_previous_sessions_documents() {
        let stored = vec![
            Owned { doc: doc("Assem27", Some(88556), None), session_started_at: Some(1000) },
            Owned { doc: doc("Part9", Some(88556), None), session_started_at: None },
        ];
        // SAME pid, DIFFERENT process: the first entry goes, and it must.
        let kept = reconcile(stored.clone(), |_| Some(2000));
        assert_eq!(kept.len(), 1);
        assert_eq!(kept[0].doc.title, "Part9", "an entry with no recorded start time is left to owns_doc");
        // Same process: everything stays.
        assert_eq!(reconcile(stored.clone(), |_| Some(1000)).len(), 2);
        // The host cannot answer: nothing is dropped on a guess.
        assert_eq!(reconcile(stored, |_| None).len(), 2);
    }
}