Skip to main content

rightkit_daemon/
record.rs

1use crate::suite::Suite;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6/// Bumped on any incompatible change to discovery files, frames or the
7/// allowlist semantics. A client rejects a different major.
8pub const PROTOCOL_MAJOR: u32 = 1;
9pub const PROTOCOL_MINOR: u32 = 0;
10
11/// Rank of a record or takeover request that carries none (HeardRight's
12/// `service.json` / `takeover.json` name only a `role`). Never wins a takeover:
13/// a host resolves it through [`crate::ServiceConfig::known_roles`] or treats
14/// the incumbent as unbeatable.
15pub const RANK_UNKNOWN: u8 = u8::MAX;
16
17fn is_zero(v: &u64) -> bool {
18    *v == 0
19}
20
21fn rank_unknown() -> u8 {
22    RANK_UNKNOWN
23}
24
25/// `<suite>/runtime/<service>/service.json`.
26///
27/// A superset of HeardRight's `ServiceRecord` (`protocol_major`, `protocol_minor`,
28/// `role`, `pid`, `endpoint`, `engine_version`): the fields HeardRight does not
29/// write default on read, and `engine_version` is written alongside `version`
30/// so an unchanged HeardRight client still parses a record written here.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ServiceRecord {
33    pub protocol_major: u32,
34    pub protocol_minor: u32,
35    #[serde(default)]
36    pub service: String,
37    /// Product label of the host (HeardRight: `heard_right_shell`, ...).
38    pub role: String,
39    /// Lower rank wins the service. [`RANK_UNKNOWN`] when the writer gave none.
40    #[serde(default = "rank_unknown")]
41    pub rank: u8,
42    pub pid: u32,
43    /// Unix socket path or named-pipe name.
44    pub endpoint: String,
45    #[serde(default)]
46    pub version: String,
47    /// Publication time (ms since epoch) of this record. HeardRight-compatible
48    /// meaning; never used to validate the PID.
49    #[serde(default)]
50    pub started_ms: u64,
51    /// Birth time of the owning process (ms since epoch), when the writer knows
52    /// it. Only when present is it compared against the live process to reject a
53    /// reused PID; records without it fall back to plain PID liveness.
54    #[serde(default, skip_serializing_if = "is_zero")]
55    pub process_started_ms: u64,
56    /// HeardRight's name for `version`; mirrored so either reader works.
57    #[serde(default)]
58    pub engine_version: String,
59    /// Last heartbeat of the host (ms since epoch), rewritten by
60    /// [`ticker::spawn_record_heartbeat`](crate::ticker::spawn_record_heartbeat) so a reader tells
61    /// a live host from a dead one without connecting (rightkit#404). Zero when the host never
62    /// beats; a reader then falls back to pid liveness.
63    #[serde(default, skip_serializing_if = "is_zero")]
64    pub heartbeat_ms: u64,
65    /// The host's coarse state (`"ready"`, `"draining"`, ...), whatever the host publishes with
66    /// its heartbeat. Empty when it publishes none. A label, never content.
67    #[serde(default, skip_serializing_if = "String::is_empty")]
68    pub state: String,
69}
70
71impl ServiceRecord {
72    /// Milliseconds since this host last beat, as of `now_ms`; `None` when it never beat.
73    pub fn heartbeat_age_ms(&self, now_ms: u64) -> Option<u64> {
74        (self.heartbeat_ms != 0).then(|| now_ms.saturating_sub(self.heartbeat_ms))
75    }
76
77    /// The host version, from whichever field the writer used.
78    pub fn host_version(&self) -> &str {
79        if self.version.is_empty() {
80            &self.engine_version
81        } else {
82            &self.version
83        }
84    }
85}
86
87/// `<suite>/runtime/<service>/takeover.json`: written by a would-be host that
88/// outranks the incumbent; deleted by the requester once hosting.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct TakeoverRequest {
91    pub role: String,
92    /// HeardRight's `takeover.json` has no rank; [`RANK_UNKNOWN`] then.
93    #[serde(default = "rank_unknown")]
94    pub rank: u8,
95    pub pid: u32,
96    pub requested_at_ms: u64,
97}
98
99pub fn record_path(suite: &Suite, service: &str) -> PathBuf {
100    suite.service_dir(service).join("service.json")
101}
102pub fn takeover_path(suite: &Suite, service: &str) -> PathBuf {
103    suite.service_dir(service).join("takeover.json")
104}
105
106/// Read and parse one discovery file. `Ok(None)` when the file is absent (`NotFound`) or was read
107/// but does not parse (not a record this crate or HeardRight wrote). Any other read error is
108/// returned: a file another process is replacing can be refused for an instant (Windows
109/// `ERROR_ACCESS_DENIED` or `ERROR_SHARING_VIOLATION`), and a permission or I/O error says nothing
110/// about whether the file, or the service, exists.
111fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> std::io::Result<Option<T>> {
112    match rightkit_fs::read_replaced(path) {
113        Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
114        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
115        Err(e) => Err(e),
116    }
117}
118
119/// The published record, telling "absent" from "unreadable".
120///
121/// `Ok(None)`: there is no `service.json` (or it holds no record). `Err`: it could not be read
122/// right now, so whether a service exists is unknown. Treat that as "try again", never as "no
123/// service". A refused open (Windows access denied or sharing violation) is retried first by `rightkit_fs::read_replaced`.
124pub fn try_read_record(suite: &Suite, service: &str) -> std::io::Result<Option<ServiceRecord>> {
125    read_json(&record_path(suite, service))
126}
127
128/// The pending takeover request, telling "absent" from "unreadable" as [`try_read_record`] does.
129/// `Err` means whether a request exists is unknown: a host must not conclude there is none.
130pub fn try_read_takeover(suite: &Suite, service: &str) -> std::io::Result<Option<TakeoverRequest>> {
131    read_json(&takeover_path(suite, service))
132}
133
134/// The published record. A record that is absent, unparseable or unreadable is `None`: use
135/// [`try_read_record`] where "unreadable" must not be taken for "no service".
136pub fn read_record(suite: &Suite, service: &str) -> Option<ServiceRecord> {
137    try_read_record(suite, service).ok().flatten()
138}
139/// The pending takeover request. Absent, unparseable and unreadable are all `None`: use
140/// [`try_read_takeover`] where "unreadable" must not be taken for "no request".
141pub fn read_takeover(suite: &Suite, service: &str) -> Option<TakeoverRequest> {
142    try_read_takeover(suite, service).ok().flatten()
143}
144
145/// The record only when its owner process is alive (HeardRight's staleness
146/// rule: a dead pid means the record is stale). Does not probe the endpoint;
147/// connecting does that and reports `service_unavailable`. Unreadable is `None`; see
148/// [`try_read_live_record`].
149pub fn read_live_record(suite: &Suite, service: &str) -> Option<ServiceRecord> {
150    try_read_live_record(suite, service).ok().flatten()
151}
152
153/// [`read_live_record`] with `Err` for a record that could not be read.
154pub fn try_read_live_record(
155    suite: &Suite,
156    service: &str,
157) -> std::io::Result<Option<ServiceRecord>> {
158    Ok(try_read_record(suite, service)?.filter(crate::discovery::record_process_matches))
159}
160
161/// Publish a takeover request (atomic, owner-only).
162///
163/// A host in another process finds the request on its next check of `takeover.json`, at most
164/// about a second later. A host in this process is woken at once.
165pub fn write_takeover(suite: &Suite, service: &str, req: &TakeoverRequest) -> std::io::Result<()> {
166    crate::suite::make_private_dir(&suite.service_dir(service)).map_err(std::io::Error::other)?;
167    let path = takeover_path(suite, service);
168    write_atomic(&path, &serde_json::to_vec_pretty(req).unwrap_or_default())?;
169    crate::takeover_watch::poke(&path);
170    Ok(())
171}
172
173/// Remove `takeover.json` only if `pid` wrote it.
174pub fn clear_takeover_if_owner(suite: &Suite, service: &str, pid: u32) {
175    if read_takeover(suite, service).is_some_and(|t| t.pid == pid) {
176        let _ = std::fs::remove_file(takeover_path(suite, service));
177    }
178}
179
180/// Atomic, durable, owner-only replace (`rightkit_fs::atomic_write_private`: unique temp file in
181/// the same directory, `fsync`, rename, directory `fsync`). The parent directory must exist.
182pub fn write_atomic(path: &Path, body: &[u8]) -> std::io::Result<()> {
183    write_atomic_with(path, body, true)
184}
185
186/// [`write_atomic`] with the durability chosen: `durable` `true` is `write_atomic` (`fsync` of the
187/// file and its directory), `false` still writes a temp file and renames it over the target, so a
188/// reader never sees a partial record, but skips the `fsync`s. Use `false` only for a record that
189/// is rewritten unchanged on a timer and rebuilt from the process's own state, such as a
190/// heartbeat (rightkit#1035); a crash can then lose that rewrite, never corrupt the record. Both
191/// share the bounded retry of Windows' transient "Access is denied" when a reader holds the
192/// target open.
193pub fn write_atomic_with(path: &Path, body: &[u8], durable: bool) -> std::io::Result<()> {
194    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
195    loop {
196        let written = if durable {
197            rightkit_fs::atomic_write_private(path, body)
198        } else {
199            rightkit_fs::atomic_write_with(
200                path,
201                rightkit_fs::AtomicOptions {
202                    private: true,
203                    create_parents: false,
204                    durable: false,
205                },
206                |f| std::io::Write::write_all(f, body),
207            )
208        };
209        match written {
210            Ok(()) => return Ok(()),
211            // Windows refuses to rename over a file another process has open without delete
212            // sharing (the incumbent host and clients poll these records), failing transiently
213            // with "Access is denied". Retry briefly; elsewhere rename never fails that way.
214            Err(e)
215                if cfg!(windows)
216                    && e.kind() == Some(std::io::ErrorKind::PermissionDenied)
217                    && std::time::Instant::now() < deadline =>
218            {
219                std::thread::sleep(std::time::Duration::from_millis(10));
220            }
221            Err(rightkit_fs::FsError::Io { source, .. }) => return Err(source),
222            Err(e) => return Err(std::io::Error::other(e)),
223        }
224    }
225}
226
227/// Which methods each calling app may use. `"*"` as an app key is the default
228/// for apps with no entry of their own; `"*"` as a method allows every method
229/// for that app. `ping` is always allowed. An empty allowlist denies everyone.
230#[derive(Debug, Clone, Default, PartialEq, Eq)]
231pub struct Allowlist {
232    apps: BTreeMap<String, BTreeSet<String>>,
233}
234
235impl Allowlist {
236    pub fn new() -> Self {
237        Self::default()
238    }
239    pub fn allow<I, S>(mut self, app: &str, methods: I) -> Self
240    where
241        I: IntoIterator<Item = S>,
242        S: Into<String>,
243    {
244        self.apps
245            .entry(app.to_string())
246            .or_default()
247            .extend(methods.into_iter().map(Into::into));
248        self
249    }
250    fn entry(&self, app: &str) -> Option<&BTreeSet<String>> {
251        self.apps.get(app).or_else(|| self.apps.get("*"))
252    }
253    pub fn app_known(&self, app: &str) -> bool {
254        self.entry(app).is_some()
255    }
256    pub fn permits(&self, app: &str, method: &str) -> bool {
257        method == "ping"
258            || self
259                .entry(app)
260                .is_some_and(|m| m.contains(method) || m.contains("*"))
261    }
262    pub fn methods_for(&self, app: &str) -> Vec<String> {
263        self.entry(app)
264            .map(|m| m.iter().cloned().collect())
265            .unwrap_or_default()
266    }
267}