Skip to main content

mold_server/
instance.rs

1//! Persistent server instance identity.
2//!
3//! Every `mold serve` endpoint gets a stable UUIDv4, generated on first boot
4//! and persisted in the metadata DB's settings table. Clients (desktop
5//! multi-host, discovery) use it to recognise the same server across
6//! hostname/IP changes — it is surfaced in `GET /api/status` and the mDNS
7//! `id` TXT record.
8//!
9//! The identity is scoped per (data dir, serving port): two servers sharing
10//! one `mold.db` (e.g. the desktop's embedded server next to a standalone
11//! `mold serve` on the same box) listen on different ports and must report
12//! distinct ids, while one server keeps its id across restarts and address
13//! changes because its port is stable.
14
15use mold_db::{MetadataDb, Settings, DEFAULT_PROFILE};
16
17/// Legacy port-less settings key from before identity was port-scoped. Never
18/// written anymore; its value is adopted (renamed) by the first port that
19/// resolves an id so existing installs keep their identity.
20pub const SERVER_INSTANCE_ID_KEY: &str = "server.instance_id";
21
22/// Port-scoped settings key holding the installation UUID. Like
23/// [`mold_db::settings::ACTIVE_PROFILE`], this is identity metadata — it is
24/// always read and written under [`DEFAULT_PROFILE`] so the id never varies
25/// with `MOLD_PROFILE`.
26pub fn instance_id_key(port: u16) -> String {
27    format!("{SERVER_INSTANCE_ID_KEY}.{port}")
28}
29
30/// Resolve the persistent instance id for the server listening on `port`,
31/// generating and storing one on first boot. A legacy port-less id (written
32/// by older versions) is adopted by the first port that asks — renamed to the
33/// port-scoped key so a second server on the same DB mints its own id instead
34/// of colliding. When the metadata DB is unavailable (`MOLD_DB_DISABLE=1`,
35/// open failure), falls back to a fresh ephemeral UUID — callers resolve once
36/// at startup and hold the result, making the fallback per-process.
37pub fn resolve_instance_id(db: Option<&MetadataDb>, port: u16) -> String {
38    if let Some(db) = db {
39        let settings = Settings::for_profile(db, DEFAULT_PROFILE);
40        let key = instance_id_key(port);
41        match settings.get_str(&key) {
42            Ok(Some(id)) if !id.trim().is_empty() => return id,
43            Ok(_) => {
44                // Adopt the legacy port-less id when present so pre-scoping
45                // installs keep their identity across the upgrade.
46                let adopted = match settings.get_str(SERVER_INSTANCE_ID_KEY) {
47                    Ok(Some(id)) if !id.trim().is_empty() => Some(id),
48                    Ok(_) => None,
49                    Err(e) => {
50                        tracing::warn!("failed to read legacy server instance id: {e:#}");
51                        None
52                    }
53                };
54                let id = adopted
55                    .clone()
56                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
57                if let Err(e) = settings.set_str(&key, &id) {
58                    tracing::warn!(
59                        "failed to persist server instance id: {e:#} — using ephemeral id"
60                    );
61                } else if adopted.is_some() {
62                    // Delete the legacy key only after the port-scoped copy is
63                    // durable — a second port must mint its own id, not adopt
64                    // the same legacy value.
65                    if let Err(e) = settings.delete(SERVER_INSTANCE_ID_KEY) {
66                        tracing::warn!("failed to retire legacy server instance id key: {e:#}");
67                    }
68                }
69                return id;
70            }
71            Err(e) => {
72                tracing::warn!("failed to read server instance id: {e:#} — using ephemeral id");
73            }
74        }
75    }
76    uuid::Uuid::new_v4().to_string()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn instance_id_persists_across_resolver_calls() {
85        let db = MetadataDb::open_in_memory().unwrap();
86        let first = resolve_instance_id(Some(&db), 7680);
87        let second = resolve_instance_id(Some(&db), 7680);
88        assert_eq!(first, second);
89        uuid::Uuid::parse_str(&first).expect("instance id must be a valid UUID");
90    }
91
92    #[test]
93    fn two_ports_on_one_db_get_distinct_ids() {
94        // Two live servers sharing one mold.db (desktop embedded server +
95        // standalone `mold serve`, or one process per GPU) must not report
96        // the same identity — clients dedupe hosts by this id.
97        let db = MetadataDb::open_in_memory().unwrap();
98        let a = resolve_instance_id(Some(&db), 7680);
99        let b = resolve_instance_id(Some(&db), 7681);
100        assert_ne!(a, b);
101        // Each port's id stays stable across restarts.
102        assert_eq!(resolve_instance_id(Some(&db), 7680), a);
103        assert_eq!(resolve_instance_id(Some(&db), 7681), b);
104    }
105
106    #[test]
107    fn legacy_portless_id_is_adopted_by_the_first_port() {
108        // Pre-scoping installs persisted a single port-less id. The first
109        // port that resolves must keep that identity (desktop saved-host
110        // dedupe depends on it surviving the upgrade)…
111        let db = MetadataDb::open_in_memory().unwrap();
112        let settings = Settings::for_profile(&db, DEFAULT_PROFILE);
113        settings
114            .set_str(SERVER_INSTANCE_ID_KEY, "legacy-id-1234")
115            .unwrap();
116
117        let first = resolve_instance_id(Some(&db), 7680);
118        assert_eq!(first, "legacy-id-1234");
119        assert_eq!(
120            settings.get_str(&instance_id_key(7680)).unwrap().as_deref(),
121            Some("legacy-id-1234")
122        );
123        // …and the legacy key is retired (renamed, not copied) so a second
124        // port mints a fresh id instead of adopting the same one.
125        assert_eq!(settings.get_str(SERVER_INSTANCE_ID_KEY).unwrap(), None);
126        let second = resolve_instance_id(Some(&db), 7681);
127        assert_ne!(second, "legacy-id-1234");
128    }
129
130    #[test]
131    fn instance_id_is_stored_under_the_default_profile() {
132        // Identity must be pinned to the default profile: a view onto any
133        // other profile must not see (or shadow) the id.
134        let db = MetadataDb::open_in_memory().unwrap();
135        let id = resolve_instance_id(Some(&db), 7680);
136        let default_view = Settings::for_profile(&db, DEFAULT_PROFILE);
137        assert_eq!(
138            default_view
139                .get_str(&instance_id_key(7680))
140                .unwrap()
141                .as_deref(),
142            Some(id.as_str())
143        );
144        let other_view = Settings::for_profile(&db, "staging");
145        assert_eq!(other_view.get_str(&instance_id_key(7680)).unwrap(), None);
146    }
147
148    #[test]
149    fn instance_id_ephemeral_fallback_without_db() {
150        let id = resolve_instance_id(None, 7680);
151        uuid::Uuid::parse_str(&id).expect("ephemeral id must be a valid UUID");
152        // No storage → each resolution mints a fresh id; run_server resolves
153        // once and holds it, which is what makes the fallback per-process.
154        assert_ne!(id, resolve_instance_id(None, 7680));
155    }
156}