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}