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}